Compare commits
750 Commits
main
...
44e119331d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44e119331d | ||
|
|
bd67034927 | ||
|
|
17937a1ad8 | ||
|
|
e0ae57828e | ||
|
|
800ca1a8b1 | ||
|
|
6d633aa292 | ||
|
|
a5eebfb32d | ||
|
|
804c238bff | ||
|
|
762c7265d7 | ||
|
|
67aa521dd9 | ||
|
|
bc2071ef42 | ||
|
|
5f79df3fe2 | ||
|
|
99e81a289e | ||
|
|
aa12317b54 | ||
|
|
b7f6e47ce3 | ||
|
|
08e2d8c0f0 | ||
|
|
009e50a902 | ||
|
|
fe770132c7 | ||
|
|
c08c7308d3 | ||
|
|
a60a628d25 | ||
|
|
abfed27846 | ||
|
|
fdcac05559 | ||
|
|
4e356876b1 | ||
|
|
887c92cd70 | ||
|
|
5f2b3672cb | ||
|
|
6bc3bf14de | ||
|
|
a46072a5e7 | ||
|
|
536d37ca9b | ||
|
|
e847e1c7e8 | ||
|
|
00d416964b | ||
|
|
55a65d2bc6 | ||
|
|
8b7614d2d6 | ||
|
|
ebaf67a134 | ||
|
|
fe005a8296 | ||
|
|
0cb1763326 | ||
|
|
44b3839e83 | ||
|
|
81014bf7ce | ||
|
|
ce3a6225ec | ||
|
|
378706cc5c | ||
|
|
97c2db01f8 | ||
|
|
e314f8330f | ||
|
|
286840c19c | ||
|
|
7ac1390394 | ||
|
|
b9b5409acb | ||
|
|
77c5730e77 | ||
|
|
aefd34bc59 | ||
|
|
2aa7b394a6 | ||
|
|
f068fc772d | ||
|
|
6246887b86 | ||
|
|
ad644d43b1 | ||
|
|
6b9f269b89 | ||
|
|
4ce466895a | ||
|
|
6f97181ec5 | ||
|
|
ab8ae961e6 | ||
|
|
68fed933d9 | ||
|
|
3e9128b441 | ||
|
|
08f2b62362 | ||
|
|
6a15503c7b | ||
|
|
89214a6b7f | ||
|
|
311a29a1dc | ||
|
|
53a6aff662 | ||
|
|
e69d8ecd10 | ||
|
|
22f945e0aa | ||
|
|
268c350e63 | ||
|
|
a0158e1ec8 | ||
|
|
13fb0a1a49 | ||
|
|
20b8fa425b | ||
|
|
1d51c92d8c | ||
|
|
26eeba713d | ||
|
|
06904748cc | ||
|
|
442afb8aa5 | ||
|
|
af95f69388 | ||
|
|
d89f7f7168 | ||
|
|
221a67e4cb | ||
|
|
6da5f77267 | ||
|
|
cd5336f353 | ||
|
|
af4d721eeb | ||
|
|
b8199dcb5a | ||
|
|
c00cb0218a | ||
|
|
2299588df6 | ||
|
|
cca574d5eb | ||
|
|
79207d5096 | ||
|
|
0c33648b26 | ||
|
|
6d18e5a017 | ||
|
|
7a02bf2c87 | ||
|
|
fbd9982427 | ||
|
|
07336e08bc | ||
|
|
2e6d62c493 | ||
|
|
4294ba424f | ||
|
|
122ba079fb | ||
|
|
4eda7b7e1b | ||
|
|
15a318112d | ||
|
|
934691e956 | ||
|
|
c2e1341d99 | ||
|
|
b516a532b7 | ||
|
|
3e834d2eb2 | ||
|
|
aa286b810e | ||
|
|
fc86119857 | ||
|
|
eccede509f | ||
|
|
7580fce07e | ||
|
|
a68db5e0eb | ||
|
|
52a73cba2d | ||
|
|
a26fd71bfa | ||
|
|
b892ffce81 | ||
|
|
059e4586a7 | ||
|
|
82faa046b7 | ||
|
|
3efeeb07ac | ||
|
|
168493f76f | ||
|
|
dd5ae740e5 | ||
|
|
68c1c7cef4 | ||
|
|
2b246f414e | ||
|
|
1e4a11d806 | ||
|
|
59d55e7bd6 | ||
|
|
7348cc6186 | ||
|
|
36ac5fe7c5 | ||
|
|
d10291a003 | ||
|
|
0beca74bcf | ||
|
|
2765a3c491 | ||
|
|
dbdce8f312 | ||
|
|
b73cbf8489 | ||
|
|
6bfc08a841 | ||
|
|
30bc03a262 | ||
|
|
e1126a6fcc | ||
|
|
2e0b13dfb5 | ||
|
|
0c3c6e7b3d | ||
|
|
0bfc47bc1c | ||
|
|
4085268971 | ||
|
|
30c15ec8fd | ||
|
|
08c275af34 | ||
|
|
e48ea7d22a | ||
|
|
33f1ed24e4 | ||
|
|
2852cdd7b6 | ||
|
|
ac4b7fcd30 | ||
|
|
3e3bc117f7 | ||
|
|
a04681a65f | ||
|
|
24cabb2122 | ||
|
|
91b60ed63a | ||
|
|
098e927ada | ||
|
|
261dbbacdb | ||
|
|
3be803316a | ||
|
|
680006e36f | ||
|
|
493834b834 | ||
|
|
67fe8567ef | ||
|
|
8dda3c0b85 | ||
|
|
2c6fca90d2 | ||
|
|
2e20ec5c81 | ||
|
|
afbe29b8de | ||
|
|
4b84fd8900 | ||
|
|
8f9180f2ed | ||
|
|
8765ac002a | ||
|
|
4e829da048 | ||
|
|
bc1fb71944 | ||
|
|
89ab43a2dd | ||
|
|
65ac26ef99 | ||
|
|
3d2d065246 | ||
|
|
403a52ce29 | ||
|
|
86961d08bb | ||
|
|
044fd104cc | ||
|
|
06bfe79851 | ||
|
|
0f6b47e97e | ||
|
|
12aef4461a | ||
|
|
50f277738b | ||
|
|
e27d5513ed | ||
|
|
416bf51746 | ||
|
|
1f74ad063c | ||
|
|
d74e52de2e | ||
|
|
b4bcfbb77a | ||
|
|
dd6c63b72b | ||
|
|
d6ae2688a8 | ||
|
|
dab46d2595 | ||
|
|
2824982b60 | ||
|
|
e829f143e7 | ||
|
|
5c73cea263 | ||
|
|
597395852c | ||
|
|
f3c6f1df7c | ||
|
|
96a419f5d4 | ||
|
|
ee257b4aa1 | ||
|
|
109ba09934 | ||
|
|
e94ccc37d8 | ||
|
|
695cecc07b | ||
|
|
4c110566f9 | ||
|
|
2574a3bfa0 | ||
|
|
c7866b3ad2 | ||
|
|
46a1b8c3a8 | ||
|
|
43d6a0438e | ||
|
|
b3ec3def14 | ||
|
|
2453d3e467 | ||
|
|
9a16daae00 | ||
|
|
c0cc7fc54f | ||
|
|
3cdded42c5 | ||
|
|
9f5a091b87 | ||
|
|
9c6973b4ee | ||
|
|
0b7fc31d52 | ||
|
|
993b1adb00 | ||
|
|
a7e10f0983 | ||
|
|
0e86d45288 | ||
|
|
cc3e90e0e9 | ||
|
|
358bb642f4 | ||
|
|
856e4f7dfc | ||
|
|
2b423a3643 | ||
|
|
69496d6ca7 | ||
|
|
488da08d39 | ||
|
|
4d2e1e3536 | ||
|
|
d1b764e59b | ||
|
|
d762403677 | ||
|
|
b5f98bb6d8 | ||
|
|
bb6f577141 | ||
|
|
36dbc26ea7 | ||
|
|
f5cfc86d63 | ||
|
|
84a1f70284 | ||
|
|
1fd4bdb1a2 | ||
|
|
6f3907c002 | ||
|
|
442e4e9390 | ||
|
|
348c51398e | ||
|
|
06d80f8147 | ||
|
|
4445328581 | ||
|
|
8052bf4af4 | ||
|
|
5cb2509457 | ||
|
|
3097fd59eb | ||
|
|
46d2421511 | ||
|
|
8b38b1411d | ||
|
|
1de268942c | ||
|
|
9d41c52cac | ||
|
|
f4891525a6 | ||
|
|
0df1d61eb1 | ||
|
|
6cd95f7ab3 | ||
|
|
1640edf8cf | ||
|
|
07663026e4 | ||
|
|
21828c7b05 | ||
|
|
7ec2bcf3f9 | ||
|
|
7bef3c7e33 | ||
|
|
e8d745949e | ||
|
|
f47475a1a9 | ||
|
|
12caed6310 | ||
|
|
751d290f1b | ||
|
|
5d9576c5c7 | ||
|
|
13ef1779ac | ||
|
|
7657412b8c | ||
|
|
2c881dae7c | ||
|
|
7642f9c9eb | ||
|
|
f45cf90b70 | ||
|
|
f5d38253fe | ||
|
|
72a4761bdb | ||
|
|
11710ec6fa | ||
|
|
83f033244c | ||
|
|
bb18a5075c | ||
|
|
b824289cf6 | ||
|
|
245af2c293 | ||
|
|
4acf86b073 | ||
|
|
9a428198b4 | ||
|
|
74ccddb1a7 | ||
|
|
0613fe9f7b | ||
|
|
2ca656a3f3 | ||
|
|
6888f1a598 | ||
|
|
1780039722 | ||
|
|
44c7748958 | ||
|
|
bf106a9730 | ||
|
|
ee93d149e2 | ||
|
|
f65b035be7 | ||
|
|
c0f5ec186e | ||
|
|
4aa4e6086b | ||
|
|
a005dba2d4 | ||
|
|
6803d51b78 | ||
|
|
5dea611dd9 | ||
|
|
24897850f3 | ||
|
|
b3b4e2203a | ||
|
|
ea18a058ac | ||
|
|
c27767bc5e | ||
|
|
ae42a4092b | ||
|
|
c73c46ca5d | ||
|
|
e6d639572d | ||
|
|
f0f7bc546e | ||
|
|
5d57efabea | ||
|
|
da3a639dd8 | ||
|
|
cd932dc097 | ||
|
|
0fff9e3890 | ||
|
|
09c86022b2 | ||
|
|
fa9c088dec | ||
|
|
4077de9296 | ||
|
|
df6404e789 | ||
|
|
bfb16c548e | ||
|
|
431f4a4505 | ||
|
|
e8f0ebff35 | ||
|
|
4b34a2bf4c | ||
|
|
87fa0f524c | ||
|
|
c0b99e9a3a | ||
|
|
e6188e2569 | ||
|
|
fc57dea551 | ||
|
|
284d2e1741 | ||
|
|
fb4bcbaa55 | ||
|
|
09e9cc937d | ||
|
|
bd0b69ddad | ||
|
|
7a2a8e8a83 | ||
|
|
06d9dc1562 | ||
|
|
259601ed7b | ||
|
|
b0451982a1 | ||
|
|
9e38112ece | ||
|
|
ea76ac22da | ||
|
|
3ccc280b02 | ||
|
|
ff04a5c93f | ||
|
|
0b433c2592 | ||
|
|
41c5e0b338 | ||
|
|
99261997db | ||
|
|
75d232e773 | ||
|
|
cbe7882d51 | ||
|
|
68b260c704 | ||
|
|
504a49a13c | ||
|
|
48642726aa | ||
|
|
bdad6f7bf7 | ||
|
|
b8c8ff35fc | ||
|
|
f840d8be4a | ||
|
|
483d92719b | ||
|
|
89e954cad9 | ||
|
|
1235e76c32 | ||
|
|
eb8f15ca18 | ||
|
|
aa384732ef | ||
|
|
3b817ef874 | ||
|
|
d266396c47 | ||
|
|
2d4b397acd | ||
|
|
747894ce1d | ||
|
|
a65db893d2 | ||
|
|
34a9def4ff | ||
|
|
5ca6b51d0b | ||
|
|
255afe8a84 | ||
|
|
9ac2b90d12 | ||
|
|
d7f1a9279f | ||
|
|
bdb2ebc723 | ||
|
|
318beccaaf | ||
|
|
c060317fb0 | ||
|
|
33da8ba5a4 | ||
|
|
775073a95c | ||
|
|
0c7910d6b9 | ||
|
|
19f260d94a | ||
|
|
a01a958e60 | ||
|
|
7d6d1afbf2 | ||
|
|
fd93ce8d97 | ||
|
|
1838da4b02 | ||
|
|
ef45a75612 | ||
|
|
eaf7fcaa0e | ||
|
|
d72da88c3f | ||
|
|
ff36cb4bd1 | ||
|
|
1f069178d6 | ||
|
|
5bbd110743 | ||
|
|
d133b187ad | ||
|
|
4742361c7f | ||
|
|
46024151a0 | ||
|
|
c96144e92a | ||
|
|
21294e8b79 | ||
|
|
cac978f46f | ||
|
|
df37277717 | ||
|
|
10c598e013 | ||
|
|
cb49798b6d | ||
|
|
b7dd65e435 | ||
|
|
6e5ceafcb7 | ||
|
|
f6710514e3 | ||
|
|
c03e890096 | ||
|
|
d6c2edce7e | ||
|
|
67ebf89db9 | ||
|
|
a91eefd614 | ||
|
|
e515a4251e | ||
|
|
1365ce2a30 | ||
|
|
c4d25593c1 | ||
|
|
b545da3d11 | ||
|
|
1da75a5d75 | ||
|
|
d77754b0a0 | ||
|
|
ff833bc065 | ||
|
|
817590c104 | ||
|
|
9d35f65be6 | ||
|
|
bcc9355400 | ||
|
|
47dfd18462 | ||
|
|
2cff1637ae | ||
|
|
c6875e12a5 | ||
|
|
8c35bf9f2c | ||
|
|
783c8eba5e | ||
|
|
89ddb82f46 | ||
|
|
34116142e6 | ||
|
|
773338cce9 | ||
|
|
a0f17f35c4 | ||
|
|
2e9d3fa5eb | ||
|
|
bed79e164d | ||
|
|
a960d1fdab | ||
|
|
5a133b0bcd | ||
|
|
8dbf91ca25 | ||
|
|
2991a01738 | ||
|
|
2465bd7f16 | ||
|
|
4ef71c37f2 | ||
|
|
276da59194 | ||
|
|
fb6d8c323a | ||
|
|
f305066eff | ||
|
|
2ba30b21cd | ||
|
|
b03f9b1a3a | ||
|
|
27e99f21d8 | ||
|
|
8327eeb2f5 | ||
|
|
442ca3a275 | ||
|
|
e057dc151f | ||
|
|
02011035ae | ||
|
|
e94d0e348f | ||
|
|
688cf0a155 | ||
|
|
8dc64bd294 | ||
|
|
cd9d160f3e | ||
|
|
312382c95d | ||
|
|
adb9d146d9 | ||
|
|
bb1b2f1c46 | ||
|
|
be5a0cf92c | ||
|
|
89c4fffd5d | ||
|
|
1f116ccaa8 | ||
|
|
295a7df396 | ||
|
|
e0de4b353a | ||
|
|
56f07c3488 | ||
|
|
782918b15f | ||
|
|
e59e5f7c11 | ||
|
|
7925be676b | ||
|
|
adff22ef74 | ||
|
|
4791a1fd9c | ||
|
|
b5a72ce0ec | ||
|
|
b127b22a4b | ||
|
|
29f84099b2 | ||
|
|
817e5324f6 | ||
|
|
d4ff7d8b83 | ||
|
|
cb45c44a05 | ||
|
|
16dd9feb1c | ||
|
|
5952bb1a6f | ||
|
|
c4ff0079cf | ||
|
|
a68672fe16 | ||
|
|
d8bea4eb0c | ||
|
|
ad932565ed | ||
|
|
f651e48434 | ||
|
|
e29f8768ca | ||
|
|
b26a37f6f7 | ||
|
|
ff8a868abd | ||
|
|
1f777c4040 | ||
|
|
d671184c09 | ||
|
|
7c1ad16018 | ||
|
|
6ce46f3011 | ||
|
|
7c81285be8 | ||
|
|
208d48115d | ||
|
|
1c75f1d5f1 | ||
|
|
a34a2620c7 | ||
|
|
fc02376272 | ||
|
|
c923ceefad | ||
|
|
a1e384f78d | ||
|
|
7a3f007340 | ||
|
|
5fa7858662 | ||
|
|
4251ea5235 | ||
|
|
1d691306db | ||
|
|
422ac684ed | ||
|
|
ec00dd0166 | ||
|
|
36b75b4c0e | ||
|
|
999d36c94d | ||
|
|
f93c8d6ccc | ||
|
|
c528376ef9 | ||
|
|
2cfb9e551f | ||
|
|
86b65f682d | ||
|
|
455e288fb0 | ||
|
|
c9a809d13d | ||
|
|
df78bb5e20 | ||
|
|
84f578bcc7 | ||
|
|
0366144780 | ||
|
|
db0b979ffb | ||
|
|
266b20b97a | ||
|
|
b9534af233 | ||
|
|
2feaf246be | ||
|
|
c70af50cec | ||
|
|
5234eb5542 | ||
|
|
2997009ac2 | ||
|
|
9fc6f72b2b | ||
|
|
857c96734d | ||
|
|
6dd00f4ebb | ||
|
|
b9a173f8d2 | ||
|
|
8cd90f629b | ||
|
|
1c210897bb | ||
|
|
111154ed39 | ||
|
|
eb7c47b4f5 | ||
|
|
a60f87c9e7 | ||
|
|
bf17c7437e | ||
|
|
6229f837a0 | ||
|
|
5e7c4b56ef | ||
|
|
bc6715b35b | ||
|
|
6ac980adad | ||
|
|
7c3b506fe7 | ||
|
|
999d58da1d | ||
|
|
ef35137236 | ||
|
|
d51c83db2a | ||
|
|
ff74375c45 | ||
|
|
0e7374b039 | ||
|
|
31529230c0 | ||
|
|
65e3c53f69 | ||
|
|
9e35c470a9 | ||
|
|
4d9422a434 | ||
|
|
59bda6a6eb | ||
|
|
2dfe0fbf3a | ||
|
|
04645c888d | ||
|
|
753faca269 | ||
|
|
b53cfbabfe | ||
|
|
7ed5d7620b | ||
|
|
669390e8bb | ||
|
|
40b55600c7 | ||
|
|
40397efa4a | ||
|
|
7f9d327f38 | ||
|
|
0b53abf63d | ||
|
|
e077d99a5e | ||
|
|
7b801b6f71 | ||
|
|
db70f58a7c | ||
|
|
353b71ebbb | ||
|
|
2e50f43ff4 | ||
|
|
a0772be4a6 | ||
|
|
ede44ee3d9 | ||
|
|
b7e8aae317 | ||
|
|
6683ce4df7 | ||
|
|
a2ccc4a498 | ||
|
|
948dc774d0 | ||
|
|
2872e8c94d | ||
|
|
a3b86c7617 | ||
|
|
5d49675582 | ||
|
|
e35565f993 | ||
|
|
f996fa0df9 | ||
|
|
1a86e5d38c | ||
|
|
8dfbdc83e5 | ||
|
|
6c745bf48b | ||
|
|
38e1bf422a | ||
|
|
131f59360c | ||
|
|
d67540c457 | ||
|
|
80bd726455 | ||
|
|
337e9f3dac | ||
|
|
58b125912e | ||
|
|
e7de41894b | ||
|
|
cbd02956ff | ||
|
|
826fa76008 | ||
|
|
a6edc02be5 | ||
|
|
b36d0480ee | ||
|
|
4c27cfa28d | ||
|
|
6b1e36a5a6 | ||
|
|
526377c13f | ||
|
|
5d401a8f0b | ||
|
|
14408d135d | ||
|
|
b7de38605d | ||
|
|
aa3b39f5e3 | ||
|
|
8e800f795c | ||
|
|
5000d5cd1c | ||
|
|
dc3e12328e | ||
|
|
9886fe2481 | ||
|
|
a12d86e6f4 | ||
|
|
edca567d52 | ||
|
|
55834a4651 | ||
|
|
5f84139c00 | ||
|
|
657674f233 | ||
|
|
8e5cb5b8c7 | ||
|
|
beb5034b9b | ||
|
|
703a7b112f | ||
|
|
3e76210d7d | ||
|
|
377cfcdfca | ||
|
|
4bfaf8c4c8 | ||
|
|
081f8b5099 | ||
|
|
bbbf589d60 | ||
|
|
894d200a16 | ||
|
|
a4ff210c82 | ||
|
|
20e2781f35 | ||
|
|
f62fe13647 | ||
|
|
0f371ab3c9 | ||
|
|
3cb2d3479b | ||
|
|
f30eb4fbff | ||
|
|
cb1a3097d8 | ||
|
|
52b3040506 | ||
|
|
ff1fb4d2ac | ||
|
|
0503b03aa1 | ||
|
|
bfdfb5761a | ||
|
|
a5ca34797d | ||
|
|
8af622e7f6 | ||
|
|
d4fdd7527b | ||
|
|
6c53172ba7 | ||
|
|
fe92b34356 | ||
|
|
562c26f061 | ||
|
|
fd60d2644e | ||
|
|
532b0d9aec | ||
|
|
37ca94ab5f | ||
|
|
05c0643a69 | ||
|
|
73d32d3b0e | ||
|
|
c9e94639d1 | ||
|
|
54d74178ea | ||
|
|
4ca1bdb8fe | ||
|
|
a4e9a8afb8 | ||
|
|
a644a6617c | ||
|
|
55b069e27d | ||
|
|
37f9d7a307 | ||
|
|
71b714bbf7 | ||
|
|
b73292b97e | ||
|
|
909f7d6720 | ||
|
|
ea367fe581 | ||
|
|
b3168dda2a | ||
|
|
94ea30223b | ||
|
|
3b29851c1e | ||
|
|
ad76eb45e4 | ||
|
|
d3c2d8dac5 | ||
|
|
98b23df2ed | ||
|
|
354c146506 | ||
|
|
2c24d4734c | ||
|
|
91c2168cb0 | ||
|
|
4ff94a4e92 | ||
|
|
2efb62d8d1 | ||
|
|
42fec61b4b | ||
|
|
e68028799e | ||
|
|
1293035045 | ||
|
|
28d1a68823 | ||
|
|
39e2788e26 | ||
|
|
72aa20b347 | ||
|
|
ea96b6909a | ||
|
|
c34996dcfb | ||
|
|
9359254d28 | ||
|
|
2fcce04c5a | ||
|
|
2d064e3c53 | ||
|
|
110041a9db | ||
|
|
6352289c25 | ||
|
|
a3a91ef1f8 | ||
|
|
92d7020b36 | ||
|
|
d4bd70fdf4 | ||
|
|
0438d054f6 | ||
|
|
f9b8182949 | ||
|
|
2de569f104 | ||
|
|
859bc3e9ee | ||
|
|
0d8916df47 | ||
|
|
8acd3806c9 | ||
|
|
c735372cc1 | ||
|
|
3edd7462c7 | ||
|
|
68b729fa7b | ||
|
|
3a0d9bd840 | ||
|
|
81e050ec1c | ||
|
|
fb34969602 | ||
|
|
06866b0fa7 | ||
|
|
d08bf17111 | ||
|
|
c43b87d648 | ||
|
|
3b694671fa | ||
|
|
2db05d100d | ||
|
|
07d17e5adf | ||
|
|
3900144ea4 | ||
|
|
afb301cb7a | ||
|
|
018015f9de | ||
|
|
16ee09180c | ||
|
|
71905aca3d | ||
|
|
1cbe29c5fc | ||
|
|
09f3e68891 | ||
|
|
3b0c5fbd5a | ||
|
|
fb03c80a95 | ||
|
|
f8fbeb8936 | ||
|
|
65232812a1 | ||
|
|
291bffa340 | ||
|
|
c1fe1fbfb5 | ||
|
|
79e591f864 | ||
|
|
378a5d5e57 | ||
|
|
368b18fc96 | ||
|
|
2b5de65e31 | ||
|
|
f292d4c0d6 | ||
|
|
1d79dc25e0 | ||
|
|
9994f2baf4 | ||
|
|
b8299faefa | ||
|
|
28c81611a1 | ||
|
|
c0d3b41550 | ||
|
|
a0a4997e79 | ||
|
|
96e9382d73 | ||
|
|
6624930cc1 | ||
|
|
6318c10c41 | ||
|
|
535acdb2a6 | ||
|
|
e1bf2d72e1 | ||
|
|
c359930009 | ||
|
|
a96490618c | ||
|
|
112b02e8c5 | ||
|
|
7bf78f91ba | ||
|
|
0344c65e5b | ||
|
|
76f5a20b79 | ||
|
|
6fb6262d0d | ||
|
|
b948a46e64 | ||
|
|
8789a92428 | ||
|
|
0fa5343ab3 | ||
|
|
9c212815ef | ||
|
|
02075b5ea0 | ||
|
|
cabdac1fe2 | ||
|
|
75fbd92de9 | ||
|
|
a16043c032 | ||
|
|
c2e4c2b955 | ||
|
|
c916120ea2 | ||
|
|
d4c164480f | ||
|
|
1830798d2e | ||
|
|
8f5afd20a6 | ||
|
|
c1640b4332 | ||
|
|
97362e3205 | ||
|
|
4c34c1221b | ||
|
|
c2d058bf75 | ||
|
|
400047e08c | ||
|
|
0b9e803139 | ||
|
|
2af2ba3367 | ||
|
|
0aa71402d1 | ||
|
|
b3ea2bd91f | ||
|
|
eced1873cd | ||
|
|
1c0d7dc03e | ||
|
|
9345fe60b5 | ||
|
|
cb63ceec2e | ||
|
|
b149660b4b | ||
|
|
bb0d001d72 | ||
|
|
04bafb4cd6 | ||
|
|
e89c9fd98f | ||
|
|
7c41b81654 | ||
|
|
e27af63025 | ||
|
|
ef2be36fce | ||
|
|
04ca0d15d8 | ||
|
|
d1e28b9e64 | ||
|
|
da78a989b5 | ||
|
|
bec6326103 | ||
|
|
b23ae3410c | ||
|
|
2ae9794dda | ||
|
|
3248336bea | ||
|
|
3c9678491a | ||
|
|
593dc08870 | ||
|
|
abc1b9870a | ||
|
|
29cd044a57 | ||
|
|
0bbe87e6fc | ||
|
|
be559c0f99 | ||
|
|
8fd38eb87e | ||
|
|
df96ec8879 | ||
|
|
1f4a23ff69 | ||
|
|
41ff0bcef4 | ||
|
|
266a74f225 | ||
|
|
896e841902 | ||
|
|
1dfd540700 | ||
|
|
0bfd1191cb | ||
|
|
44a6bee4ac | ||
|
|
12c15896d0 | ||
|
|
5333ed34b7 | ||
|
|
75b36f4011 | ||
|
|
bc399d2819 | ||
|
|
d0289b1b48 | ||
|
|
5b2e1abcdd | ||
|
|
dac6718ee4 | ||
|
|
f4335641db | ||
|
|
f3b4cbbbb2 | ||
|
|
c27150cc15 | ||
|
|
079bd76d38 | ||
|
|
5145d156bc | ||
|
|
5a958616a3 | ||
|
|
c939cc6b0c | ||
|
|
b27f909109 | ||
|
|
75e7c3fbe5 | ||
|
|
bc9025d566 | ||
|
|
640f70636d | ||
|
|
a80eb2eab4 | ||
|
|
d10d5d75c3 | ||
|
|
924903feb4 | ||
|
|
ba5a28e636 | ||
|
|
7a9a6f441a | ||
|
|
3a67f6e05f | ||
|
|
e7d024405a | ||
|
|
a66f765e16 |
@@ -1,5 +0,0 @@
|
||||
.git
|
||||
.idea
|
||||
.worktrees
|
||||
archive
|
||||
**/.DS_Store
|
||||
47
.superpowers/sdd/final-review-fix-report.md
Normal file
47
.superpowers/sdd/final-review-fix-report.md
Normal file
@@ -0,0 +1,47 @@
|
||||
## final-review-fix (2026-07-08 15:41 CST)
|
||||
|
||||
### Changes
|
||||
|
||||
- Made realtime duplicate suppression source-aware in `go/vehicle-gateway/internal/stats/daily_metric.go` by keying the in-memory mileage cache with `vin + protocol + source_key + stat_date`, so identical mileage from different sources is retained while same-source repeats are still skipped.
|
||||
- Added optional previous-baseline querying and per-source/day caching in `Writer`; realtime candidates now:
|
||||
- become `NO_PREVIOUS_BASELINE` with reason `missing_previous_source` when the same-source previous-day candidate is absent,
|
||||
- use previous-day `latest_total_mileage_km` as `first_total_mileage_km`,
|
||||
- compute `daily_mileage_km` from previous-day baseline when present,
|
||||
- become `INVALID_DELTA` with reason `outside_daily_range` when the delta is negative or above `maxSelectedDailyMileageKM`.
|
||||
- Added generic stale-final cleanup to `ProjectDailyMileage` in `go/vehicle-gateway/internal/stats/source_mileage.go` so `vehicle_daily_mileage` is deleted when no candidate remains selected for the exact `(vin, stat_date, protocol)` target.
|
||||
- Changed `BACKFILL_METHOD` default from `scan` to `last_diff` in `go/vehicle-gateway/cmd/stats-backfill/main.go`.
|
||||
- Expanded regression coverage for source-aware realtime dedupe, realtime baseline/no-baseline behavior, projector cleanup SQL, stale-final cleanup through backfill, and the backfill method default.
|
||||
|
||||
### Test Results
|
||||
|
||||
- `cd /Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/go/vehicle-gateway && go test ./internal/stats -run 'TestWriter|TestProjectDailyMileage|TestSource' -count=1`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats 0.435s`
|
||||
- `cd /Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/go/vehicle-gateway && go test ./cmd/stats-backfill ./internal/stats -count=1`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/cmd/stats-backfill 0.643s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats 0.279s`
|
||||
- `cd /Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/go/vehicle-gateway && go test ./... -count=1`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/cmd/capacity-check 0.419s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/cmd/gateway 0.397s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/cmd/history-writer 0.728s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/cmd/load-sim 0.698s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/cmd/nats-fast-writer 0.688s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/cmd/nats-kafka-bridge 0.683s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/cmd/realtime-api 0.574s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/cmd/stat-writer 0.534s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/cmd/stats-backfill 0.634s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/capacity 0.834s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope 0.738s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/eventbus 0.758s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/gateway 0.483s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/health 0.384s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/history 0.502s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity 0.631s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/loadsim 0.640s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics 0.626s`
|
||||
- `? lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability [no test files]`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/gb32960 0.622s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/jt808 0.371s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/yutongmqtt 0.384s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime 0.496s`
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats 0.443s`
|
||||
- `? lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics [no test files]`
|
||||
54
.superpowers/sdd/task-1-report.md
Normal file
54
.superpowers/sdd/task-1-report.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Task 1 Report: Source Metadata Schema And Identity Helpers
|
||||
|
||||
## Outcome
|
||||
- Implemented Task 1 in `go/vehicle-gateway/internal/stats`.
|
||||
- Added source metadata schema, identity helpers, and focused tests.
|
||||
|
||||
## RED Evidence
|
||||
Initial focused test run failed as expected because the new helpers did not exist yet:
|
||||
|
||||
```bash
|
||||
cd /Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/go/vehicle-gateway
|
||||
go test ./internal/stats -run 'TestNormalizeSourceIP|TestNewSourceIdentity|TestUpsertDataSource' -count=1
|
||||
```
|
||||
|
||||
Result:
|
||||
- `undefined: NormalizeSourceIP`
|
||||
- `undefined: NewSourceIdentity`
|
||||
- `undefined: SourceIdentity`
|
||||
- `undefined: UpsertDataSource`
|
||||
|
||||
## GREEN Evidence
|
||||
After implementation, the focused tests passed:
|
||||
|
||||
```bash
|
||||
cd /Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/go/vehicle-gateway
|
||||
go test ./internal/stats -run 'TestNormalizeSourceIP|TestNewSourceIdentity|TestUpsertDataSource' -count=1
|
||||
```
|
||||
|
||||
Result:
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats 0.927s`
|
||||
|
||||
Full package verification also passed:
|
||||
|
||||
```bash
|
||||
cd /Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/go/vehicle-gateway
|
||||
go test ./internal/stats -count=1
|
||||
```
|
||||
|
||||
Result:
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats 0.355s`
|
||||
|
||||
## Files Changed
|
||||
- `go/vehicle-gateway/internal/stats/schema.go`
|
||||
- `go/vehicle-gateway/internal/stats/source.go`
|
||||
- `go/vehicle-gateway/internal/stats/source_test.go`
|
||||
|
||||
## Notes
|
||||
- `DataSourceTableSQL` was added verbatim to `schema.go` for the future schema wiring step.
|
||||
- `UpsertDataSource` follows the brief exactly, including the nil exec panic, empty-source short circuit, and SQL shape.
|
||||
|
||||
## Self-Review
|
||||
- The implementation is tightly scoped to Task 1.
|
||||
- The new tests cover IP normalization, identity construction, and the SQL shape for the upsert helper.
|
||||
- No additional concerns at this stage.
|
||||
62
.superpowers/sdd/task-2-report.md
Normal file
62
.superpowers/sdd/task-2-report.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Task 2 Report: Candidate Mileage Schema And Writer
|
||||
|
||||
## Outcome
|
||||
- Implemented Task 2 in `go/vehicle-gateway/internal/stats`.
|
||||
- Added candidate mileage schema, sample-to-candidate mapping, and the upsert writer.
|
||||
|
||||
## RED Evidence
|
||||
The focused candidate tests failed before implementation because the new symbols did not exist:
|
||||
|
||||
```bash
|
||||
cd /Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/go/vehicle-gateway
|
||||
go test ./internal/stats -run 'TestSourceKey|TestUpsertSourceMileage' -count=1
|
||||
```
|
||||
|
||||
Result:
|
||||
- `undefined: SourceKey`
|
||||
- `undefined: SourceMileageSample`
|
||||
- `undefined: QualityOK`
|
||||
- `undefined: UpsertSourceMileage`
|
||||
|
||||
## GREEN Evidence
|
||||
After implementation, the focused candidate tests passed:
|
||||
|
||||
```bash
|
||||
cd /Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/go/vehicle-gateway
|
||||
go test ./internal/stats -run 'TestSourceKey|TestUpsertSourceMileage' -count=1
|
||||
```
|
||||
|
||||
Result:
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats 0.556s`
|
||||
|
||||
Package verification also passed:
|
||||
|
||||
```bash
|
||||
cd /Users/lingniu/project/ai-coding/lingniu-vehicle-ingest/go/vehicle-gateway
|
||||
go test ./internal/stats -count=1
|
||||
```
|
||||
|
||||
Result:
|
||||
- `ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats 0.235s`
|
||||
|
||||
## Files Changed
|
||||
- `go/vehicle-gateway/internal/stats/schema.go`
|
||||
- `go/vehicle-gateway/internal/stats/daily_metric.go`
|
||||
- `go/vehicle-gateway/internal/stats/source_mileage.go`
|
||||
- `go/vehicle-gateway/internal/stats/source_mileage_test.go`
|
||||
|
||||
## Self-Review
|
||||
- The candidate schema matches the brief's table shape and indexes.
|
||||
- `SamplesFromEnvelope` now carries `EventTime` and `DeviceID`, which the new candidate mapping needs.
|
||||
- The upsert SQL is focused on the candidate table and reuses the shared `Execer` interface.
|
||||
- No unrelated stats files were modified.
|
||||
|
||||
## Review Fix Addendum
|
||||
- Preserved manual `platform_name` values when the candidate upsert receives blank runtime input by switching to `COALESCE(NULLIF(TRIM(VALUES(platform_name)), ''), platform_name)`.
|
||||
- Bootstrapped `vehicle_data_source`, `vehicle_daily_mileage_source`, and `vehicle_daily_mileage` before running alter statements in `Writer.EnsureSchema`.
|
||||
- Added a defensive blank `SourceIP` guard in `UpsertSourceMileage` so malformed candidate rows are skipped instead of written.
|
||||
- Removed the SQL comment that existed only to satisfy a string-match test and updated the focused assertions to check the real `daily_mileage_km` and `platform_name` SQL expressions.
|
||||
- Verification run:
|
||||
- `go test ./internal/stats -run 'TestUpsertSourceMileage|TestWriterEnsuresSchema' -count=1`
|
||||
- `go test ./internal/stats -count=1`
|
||||
- Both passed.
|
||||
110
.superpowers/sdd/task-3-report.md
Normal file
110
.superpowers/sdd/task-3-report.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Task 3 Report: Final Mileage Election Projector
|
||||
|
||||
## Outcome
|
||||
|
||||
Implemented `ProjectDailyMileage` in `go/vehicle-gateway/internal/stats/source_mileage.go` and added focused projector coverage in `go/vehicle-gateway/internal/stats/source_mileage_test.go`.
|
||||
|
||||
## RED
|
||||
|
||||
Before implementation, the new projector test failed as expected because the function did not exist yet:
|
||||
|
||||
```text
|
||||
internal/stats/source_mileage_test.go:87:9: undefined: ProjectDailyMileage
|
||||
```
|
||||
|
||||
That confirmed the test was exercising the missing task-3 surface rather than passing accidentally.
|
||||
|
||||
## GREEN
|
||||
|
||||
After implementation:
|
||||
|
||||
- `go test ./internal/stats -run TestProjectDailyMileageSelectsCandidateAndMarksSource -count=1`
|
||||
- `go test ./internal/stats -count=1`
|
||||
|
||||
Both passed.
|
||||
|
||||
## What Changed
|
||||
|
||||
- Added `maxSelectedDailyMileageKM = 1000`.
|
||||
- Added `ProjectDailyMileage(...)` with three SQL steps:
|
||||
- clear `is_selected` for the VIN/date/protocol slice,
|
||||
- project the elected row from `vehicle_daily_mileage_source` into `vehicle_daily_mileage`,
|
||||
- mark the winning source row as selected.
|
||||
- Kept the candidate identity intact: `source_key` still carries protocol + phone/device + source IP, while the data-source join uses protocol + source IP.
|
||||
- Added a focused test that asserts the clear/project/mark sequence and the key SQL fragments.
|
||||
|
||||
## Files Changed
|
||||
|
||||
- `go/vehicle-gateway/internal/stats/source_mileage.go`
|
||||
- `go/vehicle-gateway/internal/stats/source_mileage_test.go`
|
||||
|
||||
## Self-Review
|
||||
|
||||
- The projector stays aligned with the existing stats schema and helper conventions.
|
||||
- The SQL ordering and join logic preserve the intended source identity split; there is no IP-only simplification.
|
||||
- I did not add an unrelated schema migration because the current repository state already contained the needed `vehicle_daily_mileage_source` and `vehicle_daily_mileage` columns/indexes for this projector.
|
||||
- The only notable follow-up risk is that the final selection query relies on MySQL-style `INSERT ... SELECT ... LIMIT 1 ON DUPLICATE KEY UPDATE` behavior, which matches the rest of the stats package but should still be exercised against the target DB in an integration run.
|
||||
|
||||
## Commit
|
||||
|
||||
`aa12317b5` - `feat(stats): project elected mileage source`
|
||||
|
||||
## Fix (reviewer findings)
|
||||
|
||||
Applied the reviewer correction for the trusted source endpoint contract:
|
||||
|
||||
- In `go/vehicle-gateway/internal/stats/source_mileage.go`, `projectDailyMileageSQL` now writes `trusted_source_endpoint` from `vehicle_data_source.latest_source_endpoint` via `COALESCE(NULLIF(ds.latest_source_endpoint, ''), s.source_endpoint)` so latest connection metadata is authoritative with a simple fallback.
|
||||
- Replaced hardcoded quality predicate with the existing constant using `s.quality_status = '` + QualityOK + `'` in `projectDailyMileageSQL`.
|
||||
- Updated `go/vehicle-gateway/internal/stats/source_mileage_test.go` to assert both the endpoint expression and constant-based quality predicate.
|
||||
|
||||
Validation commands:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway && go test ./internal/stats -run TestProjectDailyMileageSelectsCandidateAndMarksSource -count=1
|
||||
# PASS: lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats
|
||||
|
||||
cd go/vehicle-gateway && go test ./internal/stats -count=1
|
||||
# PASS: lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats
|
||||
```
|
||||
|
||||
## Fix (reviewer findings #2)
|
||||
|
||||
Corrected the remaining `projectDailyMileageSQL` contract issue so `trusted_source_endpoint` uses only `vehicle_data_source.latest_source_endpoint`:
|
||||
|
||||
- In `go/vehicle-gateway/internal/stats/source_mileage.go`, changed the projection from:
|
||||
`COALESCE(NULLIF(ds.latest_source_endpoint, ''), s.source_endpoint)`
|
||||
to:
|
||||
`ds.latest_source_endpoint`.
|
||||
- In `go/vehicle-gateway/internal/stats/source_mileage_test.go`, extended `TestProjectDailyMileageSelectsCandidateAndMarksSource` assertions to:
|
||||
- require `ds.latest_source_endpoint` is selected, and
|
||||
- explicitly verify no fallback expression using `s.source_endpoint` remains.
|
||||
|
||||
This enforces the strict source-of-truth rule that endpoint data for final projection must come from latest connection metadata only, even when it is null/empty.
|
||||
|
||||
## Fix (reviewer findings #3)
|
||||
|
||||
Addressed stale candidate selection re-flagging by narrowing source marking to the same eligibility rules used by projection:
|
||||
|
||||
- In `go/vehicle-gateway/internal/stats/source_mileage.go`, `markSelectedSourceSQL` now:
|
||||
- No longer joins `vehicle_daily_mileage` to discover the selected source.
|
||||
- Selects the same single winning candidate directly from `vehicle_daily_mileage_source`, constrained by:
|
||||
- VIN/date/protocol
|
||||
- `quality_status = QualityOK`
|
||||
- mileage bounds (`0..maxSelectedDailyMileageKM`)
|
||||
- enabled source metadata
|
||||
- same trust/sample/time ordering
|
||||
- Marks only that candidate as `is_selected = 1`.
|
||||
- In `go/vehicle-gateway/internal/stats/source_mileage_test.go`, `TestProjectDailyMileageSelectsCandidateAndMarksSource` now verifies:
|
||||
- the mark query includes candidate filters and ordering, including `s2.quality_status`, `s2.daily_mileage_km`, and `COALESCE(ds.enabled, 1) = 1`.
|
||||
- it no longer references `vehicle_daily_mileage` as a join source.
|
||||
- it passes the selection window arg through to marking (`maxSelectedDailyMileageKM`) and uses seven bind args.
|
||||
|
||||
Validation commands:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway && go test ./internal/stats -run TestProjectDailyMileage -count=1
|
||||
# PASS: lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats
|
||||
|
||||
cd go/vehicle-gateway && go test ./internal/stats -count=1
|
||||
# PASS: lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats
|
||||
```
|
||||
6
.superpowers/sdd/task-4-report.md
Normal file
6
.superpowers/sdd/task-4-report.md
Normal file
@@ -0,0 +1,6 @@
|
||||
Status: Completed Task 4.
|
||||
Commit: 821ea8e39 (`feat(stats): write realtime mileage candidates`).
|
||||
Test Summary: `go test ./internal/stats -run TestWriterAppendWritesSourceCandidateAndProjection -count=1` (PASS) and `go test ./internal/stats -count=1` (PASS).
|
||||
Concerns: `Writer.Append` now always runs projector projection per sample event, even when source identity is unavailable; this preserves final row refresh behavior and matches existing schema flow.
|
||||
Files changed: `go/vehicle-gateway/internal/stats/daily_metric.go`, `go/vehicle-gateway/internal/stats/daily_metric_test.go`.
|
||||
Report path: `.superpowers/sdd/task-4-report.md`.
|
||||
207
.superpowers/sdd/task-5-report.md
Normal file
207
.superpowers/sdd/task-5-report.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Task 5 Report: Backfill Candidate Rows And Final Projection
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented Task 5 in `go/vehicle-gateway/cmd/stats-backfill` so backfill now writes source candidates into `vehicle_daily_mileage_source` and re-projects `vehicle_daily_mileage` through the shared projector instead of writing final rows directly.
|
||||
|
||||
## TDD Evidence
|
||||
|
||||
1. Added the failing regression in `go/vehicle-gateway/cmd/stats-backfill/main_test.go`:
|
||||
- `TestDailySourceLastBuildsCandidateKeysBySourceIP`
|
||||
- Updated the existing trusted-source test to the new `normalizedSourceKey(protocol, phone, deviceID, endpoint)` signature.
|
||||
2. Verified the red state with:
|
||||
|
||||
```bash
|
||||
go test ./cmd/stats-backfill -run 'TestChooseTrustedSource|TestDailySourceLastBuildsCandidateKeysBySourceIP' -count=1
|
||||
```
|
||||
|
||||
Observed failure:
|
||||
|
||||
```text
|
||||
too many arguments in call to normalizedSourceKey
|
||||
have (string, string, string, string)
|
||||
want (string, string)
|
||||
```
|
||||
|
||||
3. Implemented the production changes.
|
||||
4. Verified green with:
|
||||
|
||||
```bash
|
||||
go test ./cmd/stats-backfill -run 'TestChooseTrustedSource|TestDailySourceLastBuildsCandidateKeysBySourceIP' -count=1
|
||||
go test ./cmd/stats-backfill ./internal/stats -count=1
|
||||
```
|
||||
|
||||
Both commands passed.
|
||||
|
||||
## Files Changed
|
||||
|
||||
- `go/vehicle-gateway/cmd/stats-backfill/main.go`
|
||||
- `go/vehicle-gateway/cmd/stats-backfill/main_test.go`
|
||||
|
||||
## What Changed
|
||||
|
||||
### 1. Source-key normalization now matches runtime semantics
|
||||
|
||||
- Changed backfill `normalizedSourceKey` to call:
|
||||
|
||||
```go
|
||||
stats.SourceKey(envelope.Protocol(protocol), phone, deviceID, stats.NormalizeSourceIP(endpoint))
|
||||
```
|
||||
|
||||
- This means candidate keys now use:
|
||||
- protocol
|
||||
- phone or device ID
|
||||
- normalized source IP
|
||||
- Port changes on the same source endpoint no longer produce different source keys.
|
||||
|
||||
### 2. Scan-mode backfill now preserves per-source candidates
|
||||
|
||||
- Expanded raw-frame reads to include `phone`, `device_id`, and `source_endpoint`.
|
||||
- Populated `envelope.FrameEnvelope` with those fields before calling `stats.SamplesFromEnvelope`.
|
||||
- Changed scan-mode aggregation key from:
|
||||
|
||||
```text
|
||||
vin + stat_date + protocol
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```text
|
||||
vin + stat_date + protocol + source_key
|
||||
```
|
||||
|
||||
- Extended `metricAgg` to carry:
|
||||
- `SourceKey`
|
||||
- `Phone`
|
||||
- `DeviceID`
|
||||
- `SourceEndpoint`
|
||||
- `FirstEventTime`
|
||||
- `LatestEventTime`
|
||||
- `QualityStatus`
|
||||
- `QualityReason`
|
||||
|
||||
This keeps scan-mode candidates source-separated instead of mixing sources together.
|
||||
|
||||
### 3. Backfill writes candidates + re-projects final rows
|
||||
|
||||
- Replaced the direct `vehicle_daily_mileage` batch upsert path.
|
||||
- `writeAggregates` now, per aggregate:
|
||||
1. Upserts `vehicle_data_source` using protocol + normalized source IP.
|
||||
2. Upserts a `vehicle_daily_mileage_source` candidate row.
|
||||
3. Calls `stats.ProjectDailyMileage` for the VIN/date/protocol.
|
||||
|
||||
- Quality handling:
|
||||
- default `OK`
|
||||
- `INVALID_DELTA` when daily delta is `< 0` or `> 1000`
|
||||
- pre-set non-OK statuses from `last_diff` are preserved
|
||||
|
||||
### 4. `last_diff` now preserves every source candidate
|
||||
|
||||
- `buildLastDiffAggregates` no longer picks a single trusted source.
|
||||
- It now creates one aggregate per current-source candidate using:
|
||||
|
||||
```text
|
||||
vin + stat_date + protocol + source_key
|
||||
```
|
||||
|
||||
- If a same-source previous-day baseline exists:
|
||||
- `FirstKM = previous.TotalKM`
|
||||
- `LatestKM = current.TotalKM`
|
||||
- `QualityStatus = OK`
|
||||
- `QualityReason = "same_source_previous_day"`
|
||||
|
||||
- If there is no same-source previous-day baseline:
|
||||
- `FirstKM = current.TotalKM`
|
||||
- `LatestKM = current.TotalKM`
|
||||
- `QualityStatus = NO_PREVIOUS_BASELINE`
|
||||
- `QualityReason = "missing_previous_source"`
|
||||
|
||||
This preserves candidates without projecting them by default, matching the projector’s `quality_status = 'OK'` filter.
|
||||
|
||||
### 5. Reset now clears candidate rows too
|
||||
|
||||
- Updated `resetStats` to delete both:
|
||||
- `vehicle_daily_mileage_source`
|
||||
- `vehicle_daily_mileage`
|
||||
|
||||
within the requested date/protocol scope.
|
||||
|
||||
This prevents stale candidates from affecting a subsequent rebuild run.
|
||||
|
||||
## Test Results
|
||||
|
||||
```bash
|
||||
go test ./cmd/stats-backfill -run 'TestChooseTrustedSource|TestDailySourceLastBuildsCandidateKeysBySourceIP' -count=1
|
||||
ok lingniu-vehicle-ingest/go/vehicle-gateway/cmd/stats-backfill 0.572s
|
||||
```
|
||||
|
||||
```bash
|
||||
go test ./cmd/stats-backfill ./internal/stats -count=1
|
||||
ok lingniu-vehicle-ingest/go/vehicle-gateway/cmd/stats-backfill 0.174s
|
||||
ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats 0.557s
|
||||
```
|
||||
|
||||
## Concerns / Notes
|
||||
|
||||
- `chooseTrustedSource` remains in place for its existing unit test, but the `last_diff` aggregation path no longer relies on it.
|
||||
- `ProjectDailyMileage` still follows the shared runtime semantics: it selects only `quality_status = 'OK'` candidates and does not synthesize cross-source deltas.
|
||||
|
||||
## Review Fix: Stale Final Rows
|
||||
|
||||
### Bug Addressed
|
||||
|
||||
When all candidates for a backfill target `(vin, stat_date, protocol)` are non-qualifying (`NO_PREVIOUS_BASELINE` / `INVALID_DELTA`), `ProjectDailyMileage` runs but inserts no final row, leaving any prior `vehicle_daily_mileage` row in place. That violated the expectation that a run with no selected candidate should not keep a historical final row for that key.
|
||||
|
||||
### Fix Applied
|
||||
|
||||
- Added helper in `cmd/stats-backfill/main.go`:
|
||||
- `clearBackfillFinalMileage(ctx, db, vin, statDate, protocol)`
|
||||
- Executes `DELETE FROM vehicle_daily_mileage WHERE vin = ? AND stat_date = ? AND protocol = ?`
|
||||
- Guarded against empty key values.
|
||||
- Updated `writeAggregates` to call the helper once per exact `(vin, stat_date, protocol)` target before calling `ProjectDailyMileage`.
|
||||
- Kept existing runtime writer behavior unchanged; only backfill projection sequencing changed.
|
||||
|
||||
### Tests Added
|
||||
|
||||
- `TestClearBackfillFinalMileageClearsExactKey` in `cmd/stats-backfill/main_test.go`
|
||||
- Verifies the SQL targets exactly the intended triplet.
|
||||
- `TestWriteAggregatesClearsFinalBeforeProjection` in `cmd/stats-backfill/main_test.go`
|
||||
- Verifies backfill writes still occur and final-row clear is executed before projection.
|
||||
|
||||
### Updated Evidence
|
||||
|
||||
```bash
|
||||
go test ./cmd/stats-backfill -run 'TestChooseTrustedSource|TestDailySourceLastBuildsCandidateKeysBySourceIP|Test.*Reset|Test.*Projection|Test.*Final' -count=1
|
||||
ok lingniu-vehicle-ingest/go/vehicle-gateway/cmd/stats-backfill 0.447s
|
||||
go test ./cmd/stats-backfill ./internal/stats -count=1
|
||||
ok lingniu-vehicle-ingest/go/vehicle-gateway/cmd/stats-backfill 0.188s
|
||||
ok lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats 0.547s
|
||||
```
|
||||
|
||||
## Follow-up Fix: Non-Reset Backfill Idempotency
|
||||
|
||||
### Additional Bug
|
||||
|
||||
The prior fix only cleared `vehicle_daily_mileage` before projection. In non-reset backfills, stale `vehicle_daily_mileage_source` rows for the same `(vin, stat_date, protocol)` could still be selected by `ProjectDailyMileage`, causing old OK candidates to win against fresh backfill input.
|
||||
|
||||
### Additional Fix
|
||||
|
||||
- Added `clearBackfillTargetMileage` in `go/vehicle-gateway/cmd/stats-backfill/main.go` to delete both tables for the exact target key:
|
||||
- `DELETE FROM vehicle_daily_mileage_source WHERE vin = ? AND stat_date = ? AND protocol = ?`
|
||||
- `DELETE FROM vehicle_daily_mileage WHERE vin = ? AND stat_date = ? AND protocol = ?`
|
||||
- Updated `writeAggregates` so this clear happens at most once per `(vin, stat_date, protocol)` and before the first candidate upsert for that target.
|
||||
- Kept existing backfill runtime flow intact: still upserts sources/candidates and calls `ProjectDailyMileage` for each target aggregate.
|
||||
|
||||
### Tests Added/Updated
|
||||
|
||||
- `TestClearBackfillTargetMileageClearsExactKey`:
|
||||
- Verifies both table deletes use the exact VIN/date/protocol tuple.
|
||||
- `TestWriteAggregatesClearsTargetRowsBeforeStaleCandidateUpsert`:
|
||||
- Verifies clear operations happen before candidate writes and projection, and that the stale-candidate flow is tested by using a non-qualifying candidate status.
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
go test ./cmd/stats-backfill -run 'TestChooseTrustedSource|TestDailySourceLastBuildsCandidateKeysBySourceIP|Test.*Reset|Test.*Projection|Test.*Final|Test.*Candidate' -count=1
|
||||
go test ./cmd/stats-backfill ./internal/stats -count=1
|
||||
```
|
||||
113
CHANGELOG.md
113
CHANGELOG.md
@@ -1,113 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
本文件记录本仓库的显著变更。日期使用 `YYYY-MM-DD` 格式。
|
||||
|
||||
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本遵循 [SemVer](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
> 说明:v0.1.0 为本仓库 git 初始化时的基线快照。下面列出的"主题改动"实际上都被压在
|
||||
> `chore: initial import of lingniu-vehicle-ingest` 这一个 commit 里——这是 git
|
||||
> 初始化前已经完成的工作,无法事后拆分。从下一次提交开始走"小步分主题提交"流程。
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added —— 原始报文冷存(Raw Archive)接入 EventSink 扇出
|
||||
- 新增 `VehicleEvent.RawArchive` sealed 变体(`ingest-api`),携带一帧入站的原始字节 +
|
||||
`command` / `infoType` 供按协议命令分片归档。
|
||||
- `Dispatcher.dispatch` 在 interceptor **之前**按 `RawFrame.rawBytes()` 产出一条
|
||||
`RawArchive` 事件,保证 dedup / rate-limit **不过滤** raw archive,满足"每帧必留痕"目标。
|
||||
- 新增 `ArchiveEventSink implements EventSink`(`sink-archive`):
|
||||
- `accepts` 仅 `VehicleEvent.RawArchive`
|
||||
- 写入 key `yyyy/MM/dd/<source>/<vin>/<eventId>.bin`(UTC 日期分片)
|
||||
- 同 eventId 重复写按 put 语义覆盖
|
||||
- 失败 completeExceptionally,EventBus 打 WARN
|
||||
- AutoConfig 在 `ArchiveStore` bean 存在时自动装配
|
||||
- `KafkaEventSink.accepts(RawArchive)` 返回 `false`:本期 Kafka 不投递原始字节,
|
||||
只落 ArchiveStore。未来需要 Kafka 回填 URI 时再开(`TopicRouter` / `EnvelopeMapper`
|
||||
已补好 switch 分支)。
|
||||
- 覆盖测试 `ArchiveEventSinkTest` —— accepts 过滤 / 稳定 key 写入 / 幂等覆盖 3 case。
|
||||
|
||||
### Changed —— GB/T 32960 Body Parser 单块异常隔离
|
||||
- `Gb32960BodyParser` 主循环对单信息块的 parser 异常不再放弃整帧:
|
||||
- 固定长度块(`fixedLength ≥ 0`):回滚 reader → 按 `fixedLen` 截取字节兜成
|
||||
`InfoBlock.Raw` → `continue` 继续解析后续块;
|
||||
- 变长块(`fixedLength == -1`)或剩余字节不足:剩余字节全部兜成 `Raw` 后终止循环;
|
||||
- `parser` 契约违反(声明 `fixedLen=X` 但实际读了 `Y`)仍抛 `DecodeException`,
|
||||
以暴露 parser bug,不走 lenient 兜底,避免静默误解析长期误判;
|
||||
- 只捕获 `DecodeException` / `BufferUnderflowException` / `IndexOutOfBoundsException`,
|
||||
`RuntimeException` 继续向上抛,保留 bug 可见性。
|
||||
- 新增配置 `lingniu.ingest.gb32960.parse.lenient-block-failure`(默认 `true`);
|
||||
需要回退严格模式时设为 `false`。
|
||||
- `InfoBlock.Raw` record 结构**未变**,下游 `Gb32960EventMapper` 按 `findBlock(Class)`
|
||||
类型匹配,新增 Raw 不影响业务事件映射。
|
||||
- 新增测试 `Gb32960BodyParserIsolationTest` —— 3 种隔离场景 + 严格模式回退,共 4 case。
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] — 2026-04-15
|
||||
|
||||
初始基线。多模块 Spring Boot 车辆遥测接入服务。
|
||||
|
||||
### Added
|
||||
- **GB/T 32960.3 接入**(`protocol-gb32960`):Netty server、帧分帧器、消息解码器、
|
||||
V2016/V2025 双版本信息体 parser、平台登入鉴权、VIN 白名单、读空闲告警。
|
||||
- **JT/T 808 / JT/T 1078 / JSATL12 接入**(`protocol-jt808` / `protocol-jt1078` /
|
||||
`protocol-jsatl12`)。
|
||||
- **MQTT 入站**(`inbound-mqtt`)。
|
||||
- **Disruptor 事件总线**(`ingest-core`):高吞吐解耦解码与下游 sink。
|
||||
- **Sink**:本地归档(`sink-archive`)、Kafka(`sink-kafka`,含 protobuf envelope)。
|
||||
- **会话状态**(`session-core`)。
|
||||
- **可观测性**:Micrometer/Actuator 装配(`observability`)。
|
||||
- **终端控制命令网关**(`command-gateway`)。
|
||||
|
||||
### Removed
|
||||
- 旧文件型事件索引模块和 DuckDB 依赖;历史查询统一收敛到 TDengine 与 `archive://...` 原始报文引用。
|
||||
|
||||
### Fixed —— GB/T 32960 应答帧时间字段
|
||||
- `Gb32960MessageDecoder` 对 `VEHICLE_LOGIN` / `VEHICLE_LOGOUT` /
|
||||
`PLATFORM_LOGIN` / `PLATFORM_LOGOUT` 命令预解析 body 首 6B 采集时间,写入
|
||||
`header.eventTime`。修复前 handler 回 ACK 时落到 `Instant.now()`,导致应答时间与原
|
||||
报文时间不一致,违反 §6.3.2 应答规则。
|
||||
|
||||
### Added —— Netty 读空闲告警
|
||||
- 配置项 `lingniu.ingest.gb32960.idle-read-seconds`(默认 60s,0 禁用)。
|
||||
- pipeline 注入 `IdleStateHandler(idleReadSeconds, 0, 0)`,仅打 WARN 日志不断链,
|
||||
用于排查"对端登入成功后长时间不推数据"场景。
|
||||
|
||||
### Changed —— GB32960 信息体 parser 包结构重构
|
||||
- 原 `codec/parser/` 下的 16 个共用类按版本严格拆分到 `parser/v2016/`(10 类)
|
||||
和 `parser/v2025/`(11 类)两个独立子包。
|
||||
- 取消 `Vehicle/DriveMotor/Engine/Position` 的 `v2016()/v2025()` 工厂方法,每个
|
||||
版本独立类,零代码共用。
|
||||
- `Alarm` 解析中跨版本静态调用 `readFaultList` 也已改为各版本类内部私有方法。
|
||||
- `Gb32960AutoConfiguration` 同步更新所有 import 与 `@Bean` 工厂。
|
||||
|
||||
### Removed —— 违反 2016 标准的 typeCode 注册
|
||||
- 经核对 GB/T 32960.3-2016 附录 B 表 B.3,`0x30~0x7F` 整段为预留区,
|
||||
**2016 标准里没有 0x30 / 0x31 / 0x32 任何字段定义**。
|
||||
- 删除 `FuelCellStackV2016BlockParser`(曾把 V2025 字段布局硬套到 V2016 帧的 0x30 段,
|
||||
解出来的电压/电流远超规范上限,是非物理值)。
|
||||
- `Gb32960AutoConfiguration` 移除 `gb32960V2016FuelCellStack` bean。
|
||||
- `Gb32960DecoderGoldenTest` 中 749B 真实生产帧的断言由
|
||||
"`isEmpty()`(不应有 Raw 兜底)"翻转为 "`isPresent()`(peer 越界使用 0x30+ 预留
|
||||
typeCode,应有 Raw 兜底)"。这才是符合 2016 国标的正确预期。
|
||||
|
||||
### Diagnostics —— BodyParser hex dump 增强
|
||||
- `Gb32960BodyParser` 在遇到未知 typeCode 触发 unknown 路径时,额外打一条 WARN,
|
||||
内容为整段 info-block 区域的 16 字节/行 hex dump,并在 `typeStartPos` 字节后插入
|
||||
`<` 标记,便于人工反查上一个块的字段对齐。
|
||||
|
||||
### Notes —— 待办
|
||||
- peer 在 V2016 帧(`2323` 起始)里下发 0x30/0x31/0x32 是越界使用 2016 预留区段。
|
||||
建议推动对端:
|
||||
1. 切换到 V2025 帧头(`2424`);
|
||||
2. 或迁移到 `0x80~0xFE` 用户自定义区段;
|
||||
3. 或提供私有协议文档,再决定是否实现 vendor-specific parser。
|
||||
- `Gb32960BodyParser` 当前对 unknown 块的策略是"吞掉剩余所有字节为单个 Raw 后终止
|
||||
循环",未做单块异常隔离。后续可改为对每个 parser.parse 调用加 try/catch +
|
||||
position 回滚,实现单块失败不拖垮整帧。
|
||||
- `platformAuthorizer.authenticate` 是 EventLoop 同步调用,存在 worker 阻塞风险,
|
||||
建议挪到独立业务线程池(与 0x30 议题无关,但属于同模块潜在风险)。
|
||||
|
||||
[0.1.0]: #010--2026-04-15
|
||||
105
DECISIONS.md
105
DECISIONS.md
@@ -1,105 +0,0 @@
|
||||
# 架构决策记录(ADR 汇总)
|
||||
|
||||
> 本文件记录 lingniu-vehicle-ingest v2 的关键架构决策,后续每次重大调整追加条目(不删除旧条目)。
|
||||
|
||||
## ADR-001 消息通道:Kafka
|
||||
- **Status**: Accepted
|
||||
- **Context**: 需要高吞吐、严格单车有序、成熟生态
|
||||
- **Decision**: 使用 Kafka,分区 key = VIN
|
||||
- **Consequences**: 下游消费者需使用消费组 + 分区内顺序消费;生产链路只支持 Kafka。
|
||||
|
||||
## ADR-002 线上消息格式:Protobuf
|
||||
- **Status**: Accepted
|
||||
- **Context**: 需要向前兼容、体积小、性能好
|
||||
- **Decision**: 线上用 Protobuf,调试/诊断保留 JSON 序列化能力
|
||||
- **Consequences**: 需维护 `.proto` schema,`ingest-api` 模块负责生成 Java stub
|
||||
|
||||
## ADR-003 command-gateway 独立模块
|
||||
- **Status**: Accepted
|
||||
- **Context**: 下行命令链路不应与接入进程耦合
|
||||
- **Decision**: 拆出独立 `command-gateway` 模块,复用 `session-core`;本次重构同步交付
|
||||
- **Consequences**: HTTP 对外接口(原 JT808Controller / JT1078Controller)迁移到此模块
|
||||
|
||||
## ADR-004 信达 Push:Removed
|
||||
- **Status**: Superseded by ADR-011
|
||||
- **Context**: 旧实现硬编码凭证、无重连、0300/0401 空实现
|
||||
- **Decision**: 删除信达 Push 源码、Maven profile、私仓依赖和部署入口。
|
||||
- **Consequences**: 信达 Push 后续已从源码和构建面删除,优化优先投向 GB32960、JT808、宇通 MQTT、历史和统计链路。
|
||||
|
||||
## ADR-005 JT1078 / JSATL12 进第一批
|
||||
- **Status**: Superseded by ADR-012
|
||||
- **Decision**: 第一批迁移即覆盖 JT1078 信令 + JSATL12 报警附件上传
|
||||
- **Consequences**: Phase 2 工期相应拉长;JSATL12 需要对象存储后端(本地 / S3 / OSS)
|
||||
|
||||
## ADR-006 部署形态:GB32960 三应用拆分
|
||||
- **Status**: Superseded by ADR-011
|
||||
- **Context**: 原 `bootstrap-all` 一体化启动已被 GB32960 拆分部署替代
|
||||
- **Decision**: 当时生产默认部署 `gb32960-ingest-app`、`vehicle-history-app`、`vehicle-analytics-app`;旧 `bootstrap-all` 模块删除
|
||||
- **Consequences**: 该三应用边界已由 ADR-011 的三协议接入 + 历史 + 统计生产面取代;协议接入、历史查询、统计消费仍保持独立发布和回滚。
|
||||
|
||||
## ADR-007 Java 25 + 虚拟线程 + Disruptor
|
||||
- **Status**: Accepted
|
||||
- **Decision**:
|
||||
- Netty EventLoop 只做解码与 RingBuffer 投递,严禁阻塞
|
||||
- 业务 Handler 走虚拟线程(`Thread.ofVirtual()`)
|
||||
- 协议间背压通过 Disruptor RingBuffer 表达
|
||||
- 禁用 `synchronized`,使用 `ReentrantLock` 避免虚拟线程 pinning
|
||||
- **Consequences**: 需开启 `jdk.VirtualThreadPinned` JFR 监控
|
||||
|
||||
## ADR-008 协议接入应用不直接写业务库
|
||||
- **Status**: Accepted
|
||||
- **Decision**: GB32960、JT808、Yutong MQTT 等协议接入应用只负责收、解析、校验、规整和投递 Kafka,不直接写业务表。
|
||||
- **Consequences**: 业务落库由独立 Kafka 消费应用承接:`vehicle-history-app` 写入 TDengine 历史与 RAW JSON,`vehicle-analytics-app` 写入 MySQL `vehicle_stat_metric`。
|
||||
|
||||
## ADR-009 构建工具:Maven
|
||||
- **Status**: Accepted
|
||||
- **Decision**: 沿用 Maven,统一 BOM 管理版本,Spotless 格式化,ArchUnit 守护分层
|
||||
|
||||
## ADR-010 框架:Spring Boot 3.5
|
||||
- **Status**: Accepted
|
||||
- **Decision**: Spring Boot 3.5.x,支持 JDK 25;AutoConfiguration 用于按需启停
|
||||
|
||||
## ADR-011 默认生产面:三协议接入 + 历史 + 统计
|
||||
- **Status**: Accepted
|
||||
- **Context**: 生产接入范围已经从 GB32960 三应用拆分,扩展为三条活跃接入链路和两个消费应用;信达 Push 已废弃并删除,不应再出现在构建、部署或优化目标里。
|
||||
- **Decision**:
|
||||
1. 默认生产面包含 GB32960、JT808、Yutong MQTT、vehicle-history-app、vehicle-analytics-app。
|
||||
2. Xinda Push 源码、Maven profile、Woodpecker 镜像发布和历史消费绑定全部移除。
|
||||
3. `command-gateway` 和 JT1078 继续作为可选能力,通过 `optional-command-gateway` profile 显式启用。
|
||||
- **Consequences**: 默认构建和部署保持精简;后续性能、可靠性、字段解析和存储优化都服务三条活跃协议链路。
|
||||
|
||||
## ADR-012 JSATL12 附件上传:Optional only
|
||||
- **Status**: Accepted
|
||||
- **Context**: 当前默认生产面只部署 GB32960、JT808、Yutong MQTT、vehicle-history-app、vehicle-analytics-app;JSATL12 附件上传没有独立生产 app,也不在 Portainer 和 Woodpecker 活跃镜像列表中。
|
||||
- **Decision**:
|
||||
1. `protocol-jsatl12` 不进入默认 Maven reactor。
|
||||
2. JSATL12 仅保留在 `optional-attachments` profile,需要附件上传能力时显式构建。
|
||||
3. 默认优化和验证优先覆盖活跃接入链路,附件上传能力保持源码可用但不增加默认构建面。
|
||||
- **Consequences**: 默认构建更轻,生产部署边界更清晰;附件上传相关测试需要通过 `-Poptional-attachments` 显式运行。
|
||||
|
||||
## ADR-013 最新状态服务:Optional only
|
||||
- **Status**: Accepted
|
||||
- **Context**: Redis 最新状态查询是独立消费能力,但当前默认 Portainer 部署和 Woodpecker 镜像发布只包含三条接入链路、history 和 analytics;`vehicle-state-service` 没有独立 app,也不应增加默认构建面。
|
||||
- **Decision**:
|
||||
1. `vehicle-state-service` 不进入默认 Maven reactor。
|
||||
2. vehicle-state-service 仅保留在 `optional-latest-state` profile,需要 Redis 最新状态查询能力时显式构建。
|
||||
3. 默认优化和验证优先覆盖活跃接入、TDengine 历史和 MySQL 指标链路。
|
||||
- **Consequences**: 默认构建和部署边界继续收窄;最新状态能力保持源码可用,但其测试需要通过 `-Poptional-latest-state` 显式运行。
|
||||
|
||||
## ADR-014 raw-archive-store 原型已删除
|
||||
- **Status**: Accepted
|
||||
- **Context**: 默认生产 raw bytes 写入由 `sink-archive` 负责,历史查询通过 TDengine `raw_frames` 和 `archive://...` 引用追溯;`raw-archive-store` 只是未部署的独立读写原型,继续保留会造成 raw archive 路径歧义。
|
||||
- **Decision**:
|
||||
1. 删除 `raw-archive-store` 模块和对应 optional profile。
|
||||
2. 默认生产链路只保留 `sink-archive`、Kafka raw topic、TDengine raw_frames 这条 raw 路径。
|
||||
3. 如需新的 archive store 能力,先以生产链路需求重新设计,不恢复旧原型。
|
||||
- **Consequences**: 默认构建面和可选构建面继续收窄;raw bytes 写入职责集中在 `sink-archive`。
|
||||
|
||||
## ADR-015 文件型事件索引:Removed
|
||||
- **Status**: Accepted
|
||||
- **Context**: 默认历史查询已收敛到 TDengine `raw_frames`、`vehicle_locations` 和按需解码;旧文件型索引会增加一套无生产部署的查询和依赖边界。
|
||||
- **Decision**:
|
||||
1. 删除旧文件型事件索引模块和 profile。
|
||||
2. 父 POM 不再管理旧索引驱动依赖。
|
||||
3. 历史查询和 RAW 回放统一以 TDengine + `archive://...` 引用为准。
|
||||
- **Consequences**: 默认构建面和可选构建面都更小;需要历史查询时只维护 TDengine 一条路径。
|
||||
19
Dockerfile
19
Dockerfile
@@ -1,19 +0,0 @@
|
||||
FROM eclipse-temurin:25-jre
|
||||
|
||||
ARG APP_NAME
|
||||
ARG APP_VERSION
|
||||
|
||||
ENV APP_NAME=${APP_NAME}
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
ENV JAVA_OPTS="--sun-misc-unsafe-memory-access=allow -Dio.netty.transport.noNative=true"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY modules/apps/${APP_NAME}/target/${APP_NAME}.jar /app/app.jar
|
||||
|
||||
EXPOSE 20100 20200 20310 20400 20500 32960 808
|
||||
|
||||
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
|
||||
104
README.md
104
README.md
@@ -1,104 +0,0 @@
|
||||
# lingniu-vehicle-ingest
|
||||
|
||||
> 羚牛车辆数据接入平台 v2
|
||||
|
||||
## 设计目标
|
||||
|
||||
- **协议接入统一抽象**:GB/T 32960、JT/T 808、Yutong MQTT 是默认生产接入;JT/T 1078、JSATL12 作为显式 profile 的可选能力;信达 Push 已废弃并删除源码
|
||||
- **原子能力化**:每个协议一个独立 Maven 模块 + 独立 AutoConfiguration + 独立配置开关
|
||||
- **业务 / 实时彻底解耦**:协议接入应用只负责收、解析、校验、规整和投递 Kafka;历史与统计由独立 Kafka 消费应用落库
|
||||
- **高并发低延迟**:Netty + Disruptor + Java 25 虚拟线程;目标单节点 ≥ 5 万 msg/s,P99 < 50 ms
|
||||
- **可观测、可回放、可灰度**:统一 traceId、Envelope、幂等键、DLQ、原始报文冷存
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 选型 |
|
||||
|---|---|
|
||||
| 语言 | Java **25** |
|
||||
| 框架 | Spring Boot 3.5.3 |
|
||||
| 网络 | Netty 4.2.9.Final |
|
||||
| 并发 | Disruptor 4 + 虚拟线程 |
|
||||
| 事件流 | **Kafka**(唯一生产消息通道,vin 分区,保证单车有序) |
|
||||
| 线上消息格式 | **Protobuf**,调试走 JSON |
|
||||
| MQTT 客户端 | Eclipse Paho 1.2.5 |
|
||||
| 会话索引 | Redis |
|
||||
| 熔断/限流 | Resilience4j 2 |
|
||||
| 可观测 | Micrometer + Prometheus |
|
||||
| 冷存 | 本地文件系统 |
|
||||
| 构建 | Maven |
|
||||
|
||||
## 模块划分
|
||||
|
||||
```
|
||||
lingniu-vehicle-ingest/
|
||||
├── modules/
|
||||
│ ├── core/
|
||||
│ │ ├── ingest-api/ SPI + sealed 领域事件 + 注解
|
||||
│ │ ├── ingest-codec-common/ 公共编解码工具(BCD/CRC/BCC/bit utils)
|
||||
│ │ ├── ingest-core/ Pipeline / Dispatcher / Disruptor / Session 桥
|
||||
│ │ ├── session-core/ 设备会话 + 鉴权 + Token + Redis SessionStore
|
||||
│ │ ├── vehicle-identity/ 跨协议车辆身份解析 + MySQL 外部标识绑定
|
||||
│ │ └── observability/ metrics / health
|
||||
│ ├── protocols/
|
||||
│ │ ├── protocol-gb32960/ GB/T 32960
|
||||
│ │ ├── protocol-jt808/ JT/T 808(统一身份映射 + 事件 metadata 内部 VIN + 注册/鉴权/心跳/注销/位置/批量位置/参数/属性/媒体/透传/未知上行和坏帧兜底/断链清理会话/下行分包)
|
||||
│ │ ├── protocol-jt1078/ JT/T 1078(optional-command-gateway profile;808 信令按需桥接 + 常用下行信令编码 + TCP/UDP RTP 媒体流分段归档 + 事件 metadata 内部 VIN + 坏 RTP 统一 RawArchive/Passthrough + 归档失败兜底 + SIM 身份映射)
|
||||
│ │ └── protocol-jsatl12/ 苏标主动安全报警附件(optional-attachments profile)
|
||||
│ ├── inbound/
|
||||
│ │ └── inbound-mqtt/ MQTT 接入(endpoint 生命周期 + profile 注册扩展 + 统一身份映射 + PEM 双向 TLS + 未知 profile/解析失败/profile异常/连接订阅失败兜底 + 统一 UNKNOWN 身份 metadata)
|
||||
│ ├── sinks/
|
||||
│ │ ├── sink-kafka/ Kafka producer + Protobuf Envelope
|
||||
│ │ └── sink-archive/ 原始报文冷存
|
||||
│ ├── services/
|
||||
│ │ ├── event-history-service/ Kafka 全字段事件消费 + 历史查询
|
||||
│ │ ├── vehicle-state-service/ Kafka 全字段事件消费 + Redis 热状态查询(optional-latest-state profile)
|
||||
│ │ └── vehicle-stat-service/ Kafka 全字段事件消费 + 可配置日统计
|
||||
│ ├── testing/
|
||||
│ │ └── vehicle-identity-test-support/ 协议测试共享身份解析夹具
|
||||
│ └── apps/
|
||||
│ ├── command-gateway/ 可选 HTTP → 设备下行命令(optional-command-gateway profile)
|
||||
│ ├── gb32960-ingest-app/ GB32960 TCP 接入 + Kafka 投递
|
||||
│ ├── jt808-ingest-app/ JT808 TCP 接入 + Kafka 投递
|
||||
│ ├── yutong-mqtt-app/ 宇通 MQTT 接入 + Kafka 投递
|
||||
│ ├── vehicle-history-app/ TDengine 历史查询 + RAW JSON
|
||||
│ └── vehicle-analytics-app/ JT808 每日里程指标消费
|
||||
├── docs/ 架构文档、模块图、实施计划
|
||||
└── reference/ 参考资料
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 要求:JDK 25, Maven 3.9+
|
||||
mvn -v
|
||||
mvn -pl :gb32960-ingest-app,:jt808-ingest-app,:yutong-mqtt-app,:vehicle-history-app,:vehicle-analytics-app -am package -Dmaven.test.skip=true
|
||||
```
|
||||
|
||||
生产服务只在 ECS/Portainer 上运行;本机只用于源码检查、Maven 构建和单元测试。部署、健康检查和真实流量验证步骤见 `docs/operations/gb32960-service-split-runbook.md` 与 `docs/operations/vehicle-ingest-tdengine-verification.md`。
|
||||
|
||||
信达 Push 已废弃并删除源码,不参与 Maven reactor、CI、Portainer 部署和历史消费链路。
|
||||
|
||||
JSATL12 附件上传不在默认 Maven reactor 中;需要时使用 `-Poptional-attachments` 构建。
|
||||
|
||||
Command Gateway/JT1078 下行与音视频信令不在默认 Maven reactor 中;需要时使用 `-Poptional-command-gateway` 构建。
|
||||
|
||||
Redis 最新状态查询不在默认 Maven reactor 中;需要时使用 `-Poptional-latest-state` 构建 `vehicle-state-service`。
|
||||
|
||||
## 迁移说明
|
||||
|
||||
本项目是 `lingniu-vehicle-data-reception` 的 v2 重构,采用 strangler fig 渐进式迁移,旧项目保留只读参考。迁移路径与决策参见 `../REFRACTOR_PLAN.md`。
|
||||
|
||||
## 架构文档
|
||||
|
||||
- 目标架构:`docs/target-architecture.md`
|
||||
- 内部字段模型:`docs/vehicle-telemetry-internal-fields.md`
|
||||
- 模块与数据流:`docs/module-data-flow.html`
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. **接入与业务落库分离**:协议接入应用只负责收、解析、校验、规整和投递 Kafka;`vehicle-history-app` 写入 TDengine 历史与 RAW JSON,`vehicle-analytics-app` 将 JT808 `daily_mileage_km` 写入 MySQL `vehicle_stat_metric`
|
||||
2. **Kafka 唯一消息通道**:生产链路只支持 Kafka;接入、历史、统计统一通过 Kafka Envelope 解耦
|
||||
3. **协议即插拔**:每个 `protocol-*` / `inbound-*` 都可独立开关(`lingniu.ingest.<name>.enabled`)
|
||||
4. **顺序保证**:同一 VIN 严格有序(Disruptor hash + Kafka 分区 key)
|
||||
5. **幂等消费**:Envelope 带 `eventId`,下游去重
|
||||
6. **原始可回放**:协议接入会把 `rawArchiveKey/rawArchiveUri` 追加到事件 metadata;Kafka Envelope、TDengine `raw_frames` 和导出都使用 `archive://...` 逻辑 URI 追溯原始 bytes,实际文件由 `sink-archive` 管理
|
||||
12
deploy/nats/docker-compose.yml
Normal file
12
deploy/nats/docker-compose.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
nats:
|
||||
image: nats:2.10-alpine
|
||||
container_name: lingniu-nats
|
||||
restart: always
|
||||
command: ["-c", "/etc/nats/nats-server.conf"]
|
||||
ports:
|
||||
- "172.17.111.56:4222:4222"
|
||||
- "172.17.111.56:8222:8222"
|
||||
volumes:
|
||||
- /opt/lingniu-nats/conf/nats-server.conf:/etc/nats/nats-server.conf:ro
|
||||
- /opt/lingniu-nats/data:/data
|
||||
13
deploy/nats/nats-server.conf
Normal file
13
deploy/nats/nats-server.conf
Normal file
@@ -0,0 +1,13 @@
|
||||
server_name: lingniu-vehicle-nats
|
||||
port: 4222
|
||||
http_port: 8222
|
||||
|
||||
jetstream {
|
||||
store_dir: "/data/jetstream"
|
||||
max_mem_store: 1G
|
||||
max_file_store: 200G
|
||||
}
|
||||
|
||||
authorization {
|
||||
timeout: 2
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
# Go sidecar stack for the vehicle ingest redesign.
|
||||
# It is intentionally separate from deploy/portainer/docker-compose.yml so the
|
||||
# current Java production stack can keep running until Go evidence is captured.
|
||||
|
||||
x-common-env: &common-env
|
||||
TZ: Asia/Shanghai
|
||||
KAFKA_BROKERS: ${KAFKA_BROKERS:-172.17.111.56:9092}
|
||||
KAFKA_TOPIC_GB32960_RAW: ${KAFKA_TOPIC_GB32960_RAW:-vehicle.raw.gb32960.v1}
|
||||
KAFKA_TOPIC_JT808_RAW: ${KAFKA_TOPIC_JT808_RAW:-vehicle.raw.jt808.v1}
|
||||
KAFKA_TOPIC_YUTONG_MQTT_RAW: ${KAFKA_TOPIC_YUTONG_MQTT_RAW:-vehicle.raw.yutong-mqtt.v1}
|
||||
KAFKA_TOPIC_UNIFIED: ${KAFKA_TOPIC_UNIFIED:-vehicle.event.unified.v1}
|
||||
|
||||
x-restart: &restart-policy
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- vehicle-ingest
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "5"
|
||||
|
||||
services:
|
||||
go-vehicle-gateway:
|
||||
<<: *restart-policy
|
||||
image: ${LINGNIU_GO_IMAGE_REGISTRY:-crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com}/${LINGNIU_GO_IMAGE_NAMESPACE:-oneos}/vehicle-gateway-go:${LINGNIU_GO_IMAGE_VERSION:?set LINGNIU_GO_IMAGE_VERSION}
|
||||
container_name: go-vehicle-gateway
|
||||
command: ["/app/gateway"]
|
||||
mem_limit: ${GO_GATEWAY_MEM_LIMIT:-512m}
|
||||
environment:
|
||||
<<: *common-env
|
||||
GB32960_TCP_ADDR: ":32960"
|
||||
JT808_TCP_ADDR: ":808"
|
||||
TCP_READ_BUFFER_BYTES: ${TCP_READ_BUFFER_BYTES:-65536}
|
||||
TCP_IDLE_TIMEOUT_SECONDS: ${TCP_IDLE_TIMEOUT_SECONDS:-180}
|
||||
TCP_MAX_CONNECTIONS: ${TCP_MAX_CONNECTIONS:-20000}
|
||||
YUTONG_MQTT_ENABLED: ${YUTONG_MQTT_ENABLED:-false}
|
||||
YUTONG_MQTT_ENDPOINT: ${YUTONG_MQTT_ENDPOINT:-yutong}
|
||||
YUTONG_MQTT_URI: ${YUTONG_MQTT_URI:-}
|
||||
YUTONG_MQTT_TOPICS: ${YUTONG_MQTT_TOPICS:-/ytforward/shln/+}
|
||||
YUTONG_MQTT_QOS: ${YUTONG_MQTT_QOS:-2}
|
||||
YUTONG_MQTT_CLIENT_ID: ${YUTONG_MQTT_CLIENT_ID:-lingniu-go-yutong-mqtt}
|
||||
YUTONG_MQTT_USERNAME: ${YUTONG_MQTT_USERNAME:-}
|
||||
YUTONG_MQTT_PASSWORD: ${YUTONG_MQTT_PASSWORD:-}
|
||||
YUTONG_MQTT_CLEAN_SESSION: ${YUTONG_MQTT_CLEAN_SESSION:-false}
|
||||
YUTONG_MQTT_KEEP_ALIVE_SECONDS: ${YUTONG_MQTT_KEEP_ALIVE_SECONDS:-20}
|
||||
YUTONG_MQTT_CONNECTION_TIMEOUT_SECONDS: ${YUTONG_MQTT_CONNECTION_TIMEOUT_SECONDS:-10}
|
||||
YUTONG_MQTT_TLS_CA_PEM: ${YUTONG_MQTT_TLS_CA_PEM:-}
|
||||
YUTONG_MQTT_TLS_CLIENT_PEM: ${YUTONG_MQTT_TLS_CLIENT_PEM:-}
|
||||
YUTONG_MQTT_TLS_CLIENT_KEY: ${YUTONG_MQTT_TLS_CLIENT_KEY:-}
|
||||
YUTONG_MQTT_TLS_HOSTNAME_VERIFICATION_ENABLED: ${YUTONG_MQTT_TLS_HOSTNAME_VERIFICATION_ENABLED:-true}
|
||||
IDENTITY_MYSQL_DSN: ${IDENTITY_MYSQL_DSN:-}
|
||||
VEHICLE_IDENTITY_TABLE: ${VEHICLE_IDENTITY_TABLE:-vehicle_identity_binding}
|
||||
ports:
|
||||
- "${GO_GB32960_TCP_PORT:-32960}:32960"
|
||||
- "${GO_JT808_TCP_PORT:-808}:808"
|
||||
volumes:
|
||||
- "${KAFKA_SPOOL_HOST_DIR:-/opt/lingniu-go/spool/gateway}:${KAFKA_SPOOL_DIR:-/data/spool/gateway}"
|
||||
- "${YUTONG_MQTT_CERT_HOST_DIR:-/opt/lingniuServices/certificate/yutong/vehicledatareception}:${YUTONG_MQTT_CERT_CONTAINER_DIR:-/opt/lingniuServices/certificate/yutong/vehicledatareception}:ro"
|
||||
|
||||
go-history-writer:
|
||||
<<: *restart-policy
|
||||
image: ${LINGNIU_GO_IMAGE_REGISTRY:-crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com}/${LINGNIU_GO_IMAGE_NAMESPACE:-oneos}/vehicle-gateway-go:${LINGNIU_GO_IMAGE_VERSION:?set LINGNIU_GO_IMAGE_VERSION}
|
||||
container_name: go-history-writer
|
||||
command: ["/app/history-writer"]
|
||||
mem_limit: ${GO_HISTORY_WRITER_MEM_LIMIT:-768m}
|
||||
environment:
|
||||
<<: *common-env
|
||||
KAFKA_GROUP: ${KAFKA_GROUP_GO_HISTORY:-go-history-writer}
|
||||
KAFKA_TOPICS: ${KAFKA_TOPICS_GO_HISTORY:-vehicle.raw.gb32960.v1,vehicle.raw.jt808.v1,vehicle.raw.yutong-mqtt.v1}
|
||||
TDENGINE_DRIVER: ${TDENGINE_DRIVER:-taosWS}
|
||||
TDENGINE_DSN: ${TDENGINE_DSN:?set TDENGINE_DSN, example root:password@ws(172.17.111.57:6041)/lingniu_vehicle_ts}
|
||||
TDENGINE_DATABASE: ${TDENGINE_DATABASE:-lingniu_vehicle_ts}
|
||||
TDENGINE_ENSURE_SCHEMA: ${TDENGINE_ENSURE_SCHEMA:-true}
|
||||
|
||||
go-stat-writer:
|
||||
<<: *restart-policy
|
||||
image: ${LINGNIU_GO_IMAGE_REGISTRY:-crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com}/${LINGNIU_GO_IMAGE_NAMESPACE:-oneos}/vehicle-gateway-go:${LINGNIU_GO_IMAGE_VERSION:?set LINGNIU_GO_IMAGE_VERSION}
|
||||
container_name: go-stat-writer
|
||||
command: ["/app/stat-writer"]
|
||||
mem_limit: ${GO_STAT_WRITER_MEM_LIMIT:-512m}
|
||||
environment:
|
||||
<<: *common-env
|
||||
KAFKA_GROUP: ${KAFKA_GROUP_GO_STAT:-go-stat-writer}
|
||||
KAFKA_TOPICS: ${KAFKA_TOPICS_GO_STAT:-vehicle.raw.gb32960.v1,vehicle.raw.jt808.v1}
|
||||
MYSQL_DSN: ${MYSQL_DSN:?set MYSQL_DSN}
|
||||
MYSQL_ENSURE_SCHEMA: ${MYSQL_ENSURE_SCHEMA:-true}
|
||||
LOCAL_TZ: ${LOCAL_TZ:-Asia/Shanghai}
|
||||
|
||||
go-realtime-api:
|
||||
<<: *restart-policy
|
||||
image: ${LINGNIU_GO_IMAGE_REGISTRY:-crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com}/${LINGNIU_GO_IMAGE_NAMESPACE:-oneos}/vehicle-gateway-go:${LINGNIU_GO_IMAGE_VERSION:?set LINGNIU_GO_IMAGE_VERSION}
|
||||
container_name: go-realtime-api
|
||||
command: ["/app/realtime-api"]
|
||||
mem_limit: ${GO_REALTIME_API_MEM_LIMIT:-512m}
|
||||
environment:
|
||||
<<: *common-env
|
||||
HTTP_ADDR: ":20210"
|
||||
KAFKA_GROUP: ${KAFKA_GROUP_GO_REALTIME:-go-realtime-api}
|
||||
KAFKA_TOPICS: ${KAFKA_TOPICS_GO_REALTIME:-vehicle.event.unified.v1}
|
||||
REDIS_ADDR: ${REDIS_ADDR:-r-bp1u741kij7e51i481.redis.rds.aliyuncs.com:6379}
|
||||
REDIS_USERNAME: ${REDIS_USERNAME:-}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:?set REDIS_PASSWORD}
|
||||
REDIS_DB: ${REDIS_DB:-50}
|
||||
ONLINE_TTL_SECONDS: ${ONLINE_TTL_SECONDS:-600}
|
||||
MYSQL_DSN: ${MYSQL_DSN:-}
|
||||
TDENGINE_DRIVER: ${TDENGINE_DRIVER:-taosWS}
|
||||
TDENGINE_DSN: ${TDENGINE_DSN:-}
|
||||
TDENGINE_DATABASE: ${TDENGINE_DATABASE:-lingniu_vehicle_ts}
|
||||
ports:
|
||||
- "${GO_REALTIME_HTTP_PORT:-20210}:20210"
|
||||
|
||||
networks:
|
||||
vehicle-ingest:
|
||||
name: vehicle-ingest
|
||||
@@ -1,216 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
# Default production stack: GB32960, JT808, Yutong MQTT, history, analytics.
|
||||
# Legacy and optional services stay outside this compose file.
|
||||
# Set LINGNIU_IMAGE_VERSION in Portainer Stack env, for example:
|
||||
# main-0.1.0-SNAPSHOT-22468e7
|
||||
# Override LINGNIU_IMAGE_REGISTRY / LINGNIU_IMAGE_NAMESPACE only when publishing to another registry.
|
||||
x-common-env: &common-env
|
||||
TZ: Asia/Shanghai
|
||||
JAVA_OPTS: ${JAVA_OPTS:---sun-misc-unsafe-memory-access=allow -Dio.netty.transport.noNative=true -XX:+UseZGC -XX:MaxRAMPercentage=75}
|
||||
NACOS_CONFIG_ENABLED: ${NACOS_CONFIG_ENABLED:-true}
|
||||
NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR:-127.0.0.1:8848}
|
||||
NACOS_NAMESPACE: ${NACOS_NAMESPACE:-}
|
||||
NACOS_GROUP: ${NACOS_GROUP:-DEFAULT_GROUP}
|
||||
NACOS_CONFIG_FILE_EXTENSION: ${NACOS_CONFIG_FILE_EXTENSION:-yml}
|
||||
NACOS_REFRESH_ENABLED: ${NACOS_REFRESH_ENABLED:-true}
|
||||
NACOS_USERNAME: ${NACOS_USERNAME:-}
|
||||
NACOS_PASSWORD: ${NACOS_PASSWORD:-}
|
||||
KAFKA_BROKERS: ${KAFKA_BROKERS:-172.17.111.56:9092}
|
||||
|
||||
x-identity-mysql-env: &identity-mysql-env
|
||||
VEHICLE_IDENTITY_MYSQL_JDBC_URL: ${MYSQL_JDBC_URL:-}
|
||||
VEHICLE_IDENTITY_MYSQL_USERNAME: ${MYSQL_USERNAME:-}
|
||||
VEHICLE_IDENTITY_MYSQL_PASSWORD: ${MYSQL_PASSWORD:-}
|
||||
|
||||
x-redis-session-env: &redis-session-env
|
||||
REDIS_HOST: ${REDIS_HOST:-r-bp1u741kij7e51i481.redis.rds.aliyuncs.com}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_DATABASE: ${REDIS_DATABASE:-50}
|
||||
REDIS_USERNAME: ${REDIS_USERNAME:-}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
|
||||
x-readiness-healthcheck: &readiness-healthcheck
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
services:
|
||||
gb32960-ingest-app:
|
||||
image: ${LINGNIU_IMAGE_REGISTRY:-crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com}/${LINGNIU_IMAGE_NAMESPACE:-oneos}/gb32960-ingest-app:${LINGNIU_IMAGE_VERSION:?set LINGNIU_IMAGE_VERSION}
|
||||
container_name: gb32960-ingest-app
|
||||
restart: unless-stopped
|
||||
mem_limit: ${GB32960_MEM_LIMIT:-768m}
|
||||
environment:
|
||||
<<:
|
||||
- *common-env
|
||||
- *identity-mysql-env
|
||||
- *redis-session-env
|
||||
HTTP_PORT: 20100
|
||||
GB32960_PORT: 32960
|
||||
KAFKA_NODE_ID: ${KAFKA_NODE_ID:-gb32960-ingest-portainer}
|
||||
KAFKA_TOPIC_GB32960_EVENT: ${KAFKA_TOPIC_GB32960_EVENT:-vehicle.event.gb32960.v1}
|
||||
KAFKA_TOPIC_GB32960_RAW: ${KAFKA_TOPIC_GB32960_RAW:-vehicle.raw.gb32960.v1}
|
||||
KAFKA_TOPIC_GB32960_DLQ: ${KAFKA_TOPIC_GB32960_DLQ:-vehicle.dlq.gb32960.v1}
|
||||
GB32960_AUTH_ENABLED: ${GB32960_AUTH_ENABLED:-false}
|
||||
GB32960_PLATFORM_USER_HYUNDAI: ${GB32960_PLATFORM_USER_HYUNDAI:-Hyundai}
|
||||
GB32960_PLATFORM_PWD_HYUNDAI: ${GB32960_PLATFORM_PWD_HYUNDAI:-}
|
||||
GB32960_PLATFORM_IP_HYUNDAI: ${GB32960_PLATFORM_IP_HYUNDAI:-}
|
||||
GB32960_PLATFORM_USER_YUEJIN: ${GB32960_PLATFORM_USER_YUEJIN:-YueJin}
|
||||
GB32960_PLATFORM_PWD_YUEJIN: ${GB32960_PLATFORM_PWD_YUEJIN:-}
|
||||
GB32960_PLATFORM_IP_YUEJIN: ${GB32960_PLATFORM_IP_YUEJIN:-}
|
||||
GB32960_TLS_ENABLED: ${GB32960_TLS_ENABLED:-false}
|
||||
SINK_ARCHIVE_PATH: ${GB32960_ARCHIVE_PATH:-/data/archive}
|
||||
ports:
|
||||
- "${GB32960_HTTP_PORT:-20100}:20100"
|
||||
- "${GB32960_TCP_PORT:-32960}:32960"
|
||||
volumes:
|
||||
- gb32960-ingest-data:/data
|
||||
networks:
|
||||
- vehicle-ingest
|
||||
healthcheck:
|
||||
<<: *readiness-healthcheck
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:20100/actuator/health/readiness >/dev/null"]
|
||||
|
||||
jt808-ingest-app:
|
||||
image: ${LINGNIU_IMAGE_REGISTRY:-crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com}/${LINGNIU_IMAGE_NAMESPACE:-oneos}/jt808-ingest-app:${LINGNIU_IMAGE_VERSION:?set LINGNIU_IMAGE_VERSION}
|
||||
container_name: jt808-ingest-app
|
||||
restart: unless-stopped
|
||||
mem_limit: ${JT808_MEM_LIMIT:-768m}
|
||||
environment:
|
||||
<<:
|
||||
- *common-env
|
||||
- *identity-mysql-env
|
||||
- *redis-session-env
|
||||
HTTP_PORT: 20400
|
||||
JT808_PORT: 808
|
||||
KAFKA_NODE_ID: ${KAFKA_NODE_ID_JT808:-jt808-ingest-portainer}
|
||||
KAFKA_TOPIC_JT808_EVENT: ${KAFKA_TOPIC_JT808_EVENT:-vehicle.event.jt808.v1}
|
||||
KAFKA_TOPIC_JT808_RAW: ${KAFKA_TOPIC_JT808_RAW:-vehicle.raw.jt808.v1}
|
||||
KAFKA_TOPIC_JT808_DLQ: ${KAFKA_TOPIC_JT808_DLQ:-vehicle.dlq.jt808.v1}
|
||||
SINK_ARCHIVE_PATH: ${JT808_ARCHIVE_PATH:-/data/archive}
|
||||
ports:
|
||||
- "${JT808_HTTP_PORT:-20400}:20400"
|
||||
- "${JT808_TCP_PORT:-808}:808"
|
||||
volumes:
|
||||
- jt808-ingest-data:/data
|
||||
networks:
|
||||
- vehicle-ingest
|
||||
healthcheck:
|
||||
<<: *readiness-healthcheck
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:20400/actuator/health/readiness >/dev/null"]
|
||||
|
||||
yutong-mqtt-app:
|
||||
image: ${LINGNIU_IMAGE_REGISTRY:-crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com}/${LINGNIU_IMAGE_NAMESPACE:-oneos}/yutong-mqtt-app:${LINGNIU_IMAGE_VERSION:?set LINGNIU_IMAGE_VERSION}
|
||||
container_name: yutong-mqtt-app
|
||||
restart: unless-stopped
|
||||
mem_limit: ${YUTONG_MQTT_MEM_LIMIT:-512m}
|
||||
environment:
|
||||
<<:
|
||||
- *common-env
|
||||
- *identity-mysql-env
|
||||
HTTP_PORT: 20500
|
||||
YUTONG_MQTT_ENABLED: ${YUTONG_MQTT_ENABLED:-false}
|
||||
YUTONG_MQTT_AUTO_STARTUP: ${YUTONG_MQTT_AUTO_STARTUP:-true}
|
||||
KAFKA_NODE_ID: ${KAFKA_NODE_ID_MQTT:-yutong-mqtt-portainer}
|
||||
KAFKA_TOPIC_YUTONG_MQTT_EVENT: ${KAFKA_TOPIC_YUTONG_MQTT_EVENT:-vehicle.event.mqtt-yutong.v1}
|
||||
KAFKA_TOPIC_YUTONG_MQTT_RAW: ${KAFKA_TOPIC_YUTONG_MQTT_RAW:-vehicle.raw.mqtt-yutong.v1}
|
||||
KAFKA_TOPIC_YUTONG_MQTT_DLQ: ${KAFKA_TOPIC_YUTONG_MQTT_DLQ:-vehicle.dlq.mqtt-yutong.v1}
|
||||
YUTONG_MQTT_ENDPOINT_NAME: ${YUTONG_MQTT_ENDPOINT_NAME:-yutong}
|
||||
YUTONG_MQTT_URI: ${YUTONG_MQTT_URI:-}
|
||||
YUTONG_MQTT_TOPIC: "${YUTONG_MQTT_TOPIC:-#}"
|
||||
YUTONG_MQTT_QOS: ${YUTONG_MQTT_QOS:-1}
|
||||
YUTONG_MQTT_CLIENT_ID: ${YUTONG_MQTT_CLIENT_ID:-lingniu-yutong-mqtt}
|
||||
YUTONG_MQTT_USERNAME: ${YUTONG_MQTT_USERNAME:-}
|
||||
YUTONG_MQTT_PASSWORD: ${YUTONG_MQTT_PASSWORD:-}
|
||||
YUTONG_MQTT_CLEAN_SESSION: ${YUTONG_MQTT_CLEAN_SESSION:-false}
|
||||
YUTONG_MQTT_KEEP_ALIVE_SECONDS: ${YUTONG_MQTT_KEEP_ALIVE_SECONDS:-20}
|
||||
YUTONG_MQTT_CONNECTION_TIMEOUT_SECONDS: ${YUTONG_MQTT_CONNECTION_TIMEOUT_SECONDS:-10}
|
||||
YUTONG_MQTT_TLS_CA_PEM: ${YUTONG_MQTT_TLS_CA_PEM:-}
|
||||
YUTONG_MQTT_TLS_CLIENT_PEM: ${YUTONG_MQTT_TLS_CLIENT_PEM:-}
|
||||
YUTONG_MQTT_TLS_CLIENT_KEY: ${YUTONG_MQTT_TLS_CLIENT_KEY:-}
|
||||
YUTONG_MQTT_TLS_HOSTNAME_VERIFICATION_ENABLED: ${YUTONG_MQTT_TLS_HOSTNAME_VERIFICATION_ENABLED:-true}
|
||||
SINK_ARCHIVE_PATH: ${YUTONG_MQTT_ARCHIVE_PATH:-/data/archive}
|
||||
ports:
|
||||
- "${YUTONG_MQTT_HTTP_PORT:-20500}:20500"
|
||||
volumes:
|
||||
- yutong-mqtt-data:/data
|
||||
networks:
|
||||
- vehicle-ingest
|
||||
healthcheck:
|
||||
<<: *readiness-healthcheck
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:20500/actuator/health/readiness >/dev/null"]
|
||||
|
||||
vehicle-history-app:
|
||||
image: ${LINGNIU_IMAGE_REGISTRY:-crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com}/${LINGNIU_IMAGE_NAMESPACE:-oneos}/vehicle-history-app:${LINGNIU_IMAGE_VERSION:?set LINGNIU_IMAGE_VERSION}
|
||||
container_name: vehicle-history-app
|
||||
restart: unless-stopped
|
||||
mem_limit: ${VEHICLE_HISTORY_MEM_LIMIT:-1536m}
|
||||
environment:
|
||||
<<: *common-env
|
||||
HTTP_PORT: 20200
|
||||
KAFKA_CONSUMER_ENABLED: ${KAFKA_CONSUMER_ENABLED_HISTORY:-true}
|
||||
KAFKA_CONSUMER_CLIENT_ID_PREFIX: ${KAFKA_CONSUMER_CLIENT_ID_PREFIX_HISTORY:-vehicle-history}
|
||||
KAFKA_CONSUMER_MAX_POLL_RECORDS: ${KAFKA_CONSUMER_MAX_POLL_RECORDS_HISTORY:-2000}
|
||||
KAFKA_CONSUMER_MAX_POLL_INTERVAL_MILLIS: ${KAFKA_CONSUMER_MAX_POLL_INTERVAL_MILLIS_HISTORY:-1800000}
|
||||
KAFKA_TOPIC_GB32960_EVENT: ${KAFKA_TOPIC_GB32960_EVENT:-vehicle.event.gb32960.v1}
|
||||
KAFKA_TOPIC_GB32960_RAW: ${KAFKA_TOPIC_GB32960_RAW:-vehicle.raw.gb32960.v1}
|
||||
KAFKA_TOPIC_JT808_EVENT: ${KAFKA_TOPIC_JT808_EVENT:-vehicle.event.jt808.v1}
|
||||
KAFKA_TOPIC_JT808_RAW: ${KAFKA_TOPIC_JT808_RAW:-vehicle.raw.jt808.v1}
|
||||
KAFKA_TOPIC_YUTONG_MQTT_EVENT: ${KAFKA_TOPIC_YUTONG_MQTT_EVENT:-vehicle.event.mqtt-yutong.v1}
|
||||
KAFKA_TOPIC_YUTONG_MQTT_RAW: ${KAFKA_TOPIC_YUTONG_MQTT_RAW:-vehicle.raw.mqtt-yutong.v1}
|
||||
KAFKA_TOPIC_HISTORY_DLQ: ${KAFKA_TOPIC_HISTORY_DLQ:-vehicle.dlq.history.v1}
|
||||
KAFKA_GROUP_HISTORY: ${KAFKA_GROUP_HISTORY:-vehicle-history}
|
||||
TDENGINE_HISTORY_ENABLED: ${TDENGINE_HISTORY_ENABLED:-true}
|
||||
TDENGINE_JDBC_URL: ${TDENGINE_JDBC_URL:-jdbc:TAOS-WS://172.17.111.57:6041/vehicle_ts}
|
||||
TDENGINE_HISTORY_DATABASE: ${TDENGINE_HISTORY_DATABASE:-vehicle_ts}
|
||||
TDENGINE_USERNAME: ${TDENGINE_USERNAME:-root}
|
||||
TDENGINE_PASSWORD: ${TDENGINE_PASSWORD:-taosdata}
|
||||
TDENGINE_CONNECTION_TIMEOUT_MILLIS: ${TDENGINE_CONNECTION_TIMEOUT_MILLIS:-30000}
|
||||
TDENGINE_MIN_IDLE: ${TDENGINE_MIN_IDLE:-1}
|
||||
TDENGINE_MAX_POOL_SIZE: ${TDENGINE_MAX_POOL_SIZE:-32}
|
||||
ports:
|
||||
- "${VEHICLE_HISTORY_HTTP_PORT:-20200}:20200"
|
||||
networks:
|
||||
- vehicle-ingest
|
||||
healthcheck:
|
||||
<<: *readiness-healthcheck
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:20200/actuator/health/readiness >/dev/null"]
|
||||
|
||||
vehicle-analytics-app:
|
||||
image: ${LINGNIU_IMAGE_REGISTRY:-crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com}/${LINGNIU_IMAGE_NAMESPACE:-oneos}/vehicle-analytics-app:${LINGNIU_IMAGE_VERSION:?set LINGNIU_IMAGE_VERSION}
|
||||
container_name: vehicle-analytics-app
|
||||
restart: unless-stopped
|
||||
mem_limit: ${VEHICLE_ANALYTICS_MEM_LIMIT:-1024m}
|
||||
environment:
|
||||
<<: *common-env
|
||||
HTTP_PORT: 20310
|
||||
KAFKA_CONSUMER_ENABLED: ${KAFKA_CONSUMER_ENABLED_ANALYTICS:-true}
|
||||
KAFKA_CONSUMER_CLIENT_ID_PREFIX: ${KAFKA_CONSUMER_CLIENT_ID_PREFIX_ANALYTICS:-vehicle-analytics}
|
||||
KAFKA_CONSUMER_MAX_POLL_INTERVAL_MILLIS: ${KAFKA_CONSUMER_MAX_POLL_INTERVAL_MILLIS_ANALYTICS:-900000}
|
||||
KAFKA_TOPIC_JT808_EVENT: ${KAFKA_TOPIC_JT808_EVENT:-vehicle.event.jt808.v1}
|
||||
KAFKA_TOPIC_JT808_DLQ: ${KAFKA_TOPIC_JT808_DLQ:-vehicle.dlq.jt808.v1}
|
||||
KAFKA_GROUP_STAT: ${KAFKA_GROUP_STAT:-vehicle-stat}
|
||||
VEHICLE_STAT_ENABLED: ${VEHICLE_STAT_ENABLED:-true}
|
||||
VEHICLE_STAT_ZONE_ID: ${VEHICLE_STAT_ZONE_ID:-Asia/Shanghai}
|
||||
VEHICLE_STAT_JT808_MILEAGE_ENABLED: ${VEHICLE_STAT_JT808_MILEAGE_ENABLED:-true}
|
||||
MYSQL_JDBC_URL: ${MYSQL_JDBC_URL:-}
|
||||
MYSQL_USERNAME: ${MYSQL_USERNAME:-}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-}
|
||||
ports:
|
||||
- "${VEHICLE_ANALYTICS_HTTP_PORT:-20310}:20310"
|
||||
networks:
|
||||
- vehicle-ingest
|
||||
healthcheck:
|
||||
<<: *readiness-healthcheck
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:20310/actuator/health/readiness >/dev/null"]
|
||||
|
||||
networks:
|
||||
vehicle-ingest:
|
||||
name: vehicle-ingest
|
||||
|
||||
volumes:
|
||||
gb32960-ingest-data:
|
||||
jt808-ingest-data:
|
||||
yutong-mqtt-data:
|
||||
@@ -1,66 +0,0 @@
|
||||
# Go Vehicle Gateway Native Deployment
|
||||
|
||||
本目录用于 ECS 原生部署。当前 goal 后续不再使用 Docker/Portainer 作为 Go 接入链路的部署方式。
|
||||
|
||||
## Runtime Layout
|
||||
|
||||
```text
|
||||
/opt/lingniu-go-native/
|
||||
current -> /opt/lingniu-go-native/releases/<git-short-sha>
|
||||
releases/<git-short-sha>/
|
||||
gateway
|
||||
history-writer
|
||||
stat-writer
|
||||
realtime-api
|
||||
env/
|
||||
gateway.env
|
||||
history-writer.env
|
||||
stat-writer.env
|
||||
realtime-api.env
|
||||
spool/gateway/
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
| systemd unit | Binary | Purpose |
|
||||
|---|---|---|
|
||||
| `lingniu-go-gateway.service` | `gateway` | GB32960 TCP `32960`、JT808 TCP `808`、宇通 MQTT 接入,写 Kafka RAW/unified |
|
||||
| `lingniu-go-history-writer.service` | `history-writer` | 消费 RAW topic,写 TDengine `raw_frames` 和核心时序表 |
|
||||
| `lingniu-go-stat-writer.service` | `stat-writer` | 消费 RAW topic,写 MySQL `vehicle_daily_metric` |
|
||||
| `lingniu-go-realtime-api.service` | `realtime-api` | 消费 unified topic,写 Redis,并提供 realtime/raw/stat 查询 API |
|
||||
|
||||
## Build
|
||||
|
||||
在开发机或 CI 上构建 Linux amd64 二进制:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-native/gateway ./cmd/gateway
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-native/history-writer ./cmd/history-writer
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-native/stat-writer ./cmd/stat-writer
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-native/realtime-api ./cmd/realtime-api
|
||||
```
|
||||
|
||||
## Cutover Notes
|
||||
|
||||
1. 先上传二进制到新 release 目录并更新 `current` symlink。
|
||||
2. 从现有生产 env 生成四个 systemd 专用 env 文件;不要把密钥写入仓库。
|
||||
3. 停止旧 Docker Go 容器或 Java 容器,释放 `808`、`32960`、`20210`。
|
||||
4. 执行:
|
||||
|
||||
```bash
|
||||
systemctl daemon-reload
|
||||
systemctl enable lingniu-go-gateway lingniu-go-history-writer lingniu-go-stat-writer lingniu-go-realtime-api
|
||||
systemctl restart lingniu-go-gateway lingniu-go-history-writer lingniu-go-stat-writer lingniu-go-realtime-api
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
systemctl is-active lingniu-go-gateway lingniu-go-history-writer lingniu-go-stat-writer lingniu-go-realtime-api
|
||||
ss -lntp | egrep ':(808|32960|20210)\b'
|
||||
journalctl -u lingniu-go-gateway --since '5 minutes ago' --no-pager
|
||||
curl -sS 'http://127.0.0.1:20210/api/history/raw-frames?protocol=JT808&limit=1'
|
||||
curl -sS 'http://127.0.0.1:20210/api/history/raw-frames?protocol=GB32960&limit=1'
|
||||
curl -sS 'http://127.0.0.1:20210/api/history/raw-frames?protocol=YUTONG_MQTT&limit=1'
|
||||
```
|
||||
13
deploy/systemd/lingniu-go-capacity-check.service
Normal file
13
deploy/systemd/lingniu-go-capacity-check.service
Normal file
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Lingniu Go Capacity Health Check
|
||||
Documentation=file:/opt/lingniu-go-native/current/docs/ops/go-service-observability.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=/opt/lingniu-go-native/current
|
||||
ExecStart=/opt/lingniu-go-native/current/capacity-check
|
||||
TimeoutStartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
12
deploy/systemd/lingniu-go-capacity-check.timer
Normal file
12
deploy/systemd/lingniu-go-capacity-check.timer
Normal file
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Run Lingniu Go Capacity Health Check every minute
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=1min
|
||||
AccuracySec=5s
|
||||
Persistent=true
|
||||
Unit=lingniu-go-capacity-check.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -1,18 +0,0 @@
|
||||
[Unit]
|
||||
Description=Lingniu Go Vehicle Gateway
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/lingniu-go-native/current
|
||||
EnvironmentFile=/opt/lingniu-go-native/env/gateway.env
|
||||
ExecStart=/opt/lingniu-go-native/current/gateway
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
LimitNOFILE=1048576
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,18 +0,0 @@
|
||||
[Unit]
|
||||
Description=Lingniu Go History Writer
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/lingniu-go-native/current
|
||||
EnvironmentFile=/opt/lingniu-go-native/env/history-writer.env
|
||||
ExecStart=/opt/lingniu-go-native/current/history-writer
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
LimitNOFILE=1048576
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,18 +0,0 @@
|
||||
[Unit]
|
||||
Description=Lingniu Go Realtime API
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/lingniu-go-native/current
|
||||
EnvironmentFile=/opt/lingniu-go-native/env/realtime-api.env
|
||||
ExecStart=/opt/lingniu-go-native/current/realtime-api
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
LimitNOFILE=1048576
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,18 +0,0 @@
|
||||
[Unit]
|
||||
Description=Lingniu Go Stat Writer
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/lingniu-go-native/current
|
||||
EnvironmentFile=/opt/lingniu-go-native/env/stat-writer.env
|
||||
ExecStart=/opt/lingniu-go-native/current/stat-writer
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
LimitNOFILE=1048576
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,86 +0,0 @@
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.stream.Collectors;
|
||||
import org.apache.kafka.clients.admin.AdminClient;
|
||||
import org.apache.kafka.clients.admin.AdminClientConfig;
|
||||
import org.apache.kafka.clients.admin.NewPartitions;
|
||||
import org.apache.kafka.clients.admin.NewTopic;
|
||||
import org.apache.kafka.clients.admin.TopicDescription;
|
||||
|
||||
public final class EnsureKafkaTopics {
|
||||
private static final short REPLICATION_FACTOR = 1;
|
||||
|
||||
private record TopicSpec(String name, int partitions) {}
|
||||
|
||||
private EnsureKafkaTopics() {}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
String brokers = args.length > 0 ? args[0] : "172.17.111.56:9092";
|
||||
List<TopicSpec> specs =
|
||||
List.of(
|
||||
topic("vehicle.event.gb32960.v1", 12),
|
||||
topic("vehicle.raw.gb32960.v1", 12),
|
||||
topic("vehicle.dlq.gb32960.v1", 3),
|
||||
topic("vehicle.event.jt808.v1", 12),
|
||||
topic("vehicle.raw.jt808.v1", 12),
|
||||
topic("vehicle.dlq.jt808.v1", 3),
|
||||
topic("vehicle.event.mqtt-yutong.v1", 12),
|
||||
topic("vehicle.raw.mqtt-yutong.v1", 12),
|
||||
topic("vehicle.dlq.mqtt-yutong.v1", 3));
|
||||
|
||||
Properties props = new Properties();
|
||||
props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, brokers);
|
||||
props.put(AdminClientConfig.REQUEST_TIMEOUT_MS_CONFIG, "15000");
|
||||
props.put(AdminClientConfig.DEFAULT_API_TIMEOUT_MS_CONFIG, "30000");
|
||||
|
||||
try (AdminClient admin = AdminClient.create(props)) {
|
||||
Set<String> existing = admin.listTopics().names().get();
|
||||
List<NewTopic> missing =
|
||||
specs.stream()
|
||||
.filter(spec -> !existing.contains(spec.name()))
|
||||
.map(spec -> new NewTopic(spec.name(), spec.partitions(), REPLICATION_FACTOR))
|
||||
.toList();
|
||||
if (!missing.isEmpty()) {
|
||||
try {
|
||||
admin.createTopics(missing).all().get();
|
||||
} catch (ExecutionException e) {
|
||||
System.out.println("createTopics result=" + e.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, TopicSpec> byName =
|
||||
specs.stream().collect(Collectors.toMap(TopicSpec::name, spec -> spec));
|
||||
Map<String, TopicDescription> descriptions =
|
||||
admin.describeTopics(byName.keySet()).allTopicNames().get();
|
||||
|
||||
Map<String, NewPartitions> increases =
|
||||
descriptions.entrySet().stream()
|
||||
.filter(entry -> entry.getValue().partitions().size() < byName.get(entry.getKey()).partitions())
|
||||
.collect(
|
||||
Collectors.toMap(
|
||||
Map.Entry::getKey,
|
||||
entry -> NewPartitions.increaseTo(byName.get(entry.getKey()).partitions())));
|
||||
if (!increases.isEmpty()) {
|
||||
admin.createPartitions(increases).all().get();
|
||||
Thread.sleep(Duration.ofSeconds(2).toMillis());
|
||||
descriptions = admin.describeTopics(byName.keySet()).allTopicNames().get();
|
||||
}
|
||||
|
||||
List<String> lines = new ArrayList<>();
|
||||
descriptions.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.forEach(entry -> lines.add(entry.getKey() + " partitions=" + entry.getValue().partitions().size()));
|
||||
System.out.println("bootstrap=" + brokers);
|
||||
lines.forEach(System.out::println);
|
||||
}
|
||||
}
|
||||
|
||||
private static TopicSpec topic(String name, int partitions) {
|
||||
return new TopicSpec(name, partitions);
|
||||
}
|
||||
}
|
||||
101
docs/architecture/iot-data-platform-principles.md
Normal file
101
docs/architecture/iot-data-platform-principles.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Vehicle IoT Data Platform Principles
|
||||
|
||||
## Goal
|
||||
|
||||
Build a production vehicle data platform that can receive GB32960, JT808, and Yutong MQTT data reliably, keep raw evidence traceable, and expose only simple business tables for realtime, history, identity, and metrics.
|
||||
|
||||
## First Principles
|
||||
|
||||
1. Raw data is the source of truth.
|
||||
2. Kafka is the durable replay log.
|
||||
3. Redis is realtime cache, not permanent storage.
|
||||
4. MySQL stores identity, light realtime business snapshots, and low-cardinality metrics.
|
||||
5. TDengine stores time-series raw and location history.
|
||||
6. Protocol-specific fields stay in raw parsed JSON unless they become a stable query or metric requirement.
|
||||
7. Upper business tables must not duplicate full parsed payloads.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
GB["GB32960 TCP"] --> GW["Go Gateway"]
|
||||
JT["JT808 TCP"] --> GW
|
||||
YM["Yutong MQTT"] --> GW
|
||||
|
||||
GW --> NATS["NATS JetStream ingress"]
|
||||
NATS --> BR["NATS Kafka Bridge"]
|
||||
BR --> KRAW["Kafka raw topics"]
|
||||
GW -. fallback .-> KRAW
|
||||
|
||||
KRAW --> HW["history-writer"]
|
||||
KRAW --> SW["stat-writer"]
|
||||
KRAW --> RA["realtime-api projector"]
|
||||
|
||||
HW --> TDRAW["TDengine raw_frames"]
|
||||
HW --> TDLOC["TDengine vehicle_locations"]
|
||||
SW --> MYMET["MySQL vehicle_daily_mileage"]
|
||||
RA --> REDIS["Redis realtime-raw"]
|
||||
RA --> MYSNAP["MySQL vehicle_realtime_snapshot"]
|
||||
RA --> MYLOC["MySQL vehicle_realtime_location"]
|
||||
```
|
||||
|
||||
## Minimal Storage Contract
|
||||
|
||||
详细的落库边界见 [车辆数据最小落库合约](storage-minimal-contract.md),当前生产数据面见 [生产数据面清单](production-data-plane-inventory.md)。
|
||||
|
||||
| Store | Table or key | Purpose | Keep | Avoid |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| TDengine | `raw_frames` | Replay and audit evidence | raw hex/text, full parsed JSON, parse status, protocol tags | business-only duplicated columns |
|
||||
| TDengine | `vehicle_locations` | High-volume location history | time, vin, protocol, longitude, latitude, speed, direction, mileage | full parsed JSON |
|
||||
| MySQL | `vehicle_realtime_snapshot` | Latest per-protocol vehicle heartbeat | protocol, vin, plate, event time, received time | phone, device, message sequence, parsed JSON |
|
||||
| MySQL | `vehicle_realtime_location` | Latest business location cache | vin, plate, location, speed, mileage, SOC, event time | full raw payload and protocol internals |
|
||||
| MySQL | `vehicle_daily_mileage` | Queryable daily mileage | vin, date, protocol, daily mileage, source mileage | temporary vehicle keys, generic metric key/value rows, per-frame raw details |
|
||||
| MySQL | `vehicle_identity_binding` | Manual identity mapping | vin, plate, phone, oem | registration history, device id |
|
||||
| MySQL | `jt808_registration` | JT808 registration and auth trace | phone, device id, plate, auth code, vin match state, first/latest seen | GB32960 or MQTT records |
|
||||
| Redis | `vehicle:latest:{vin}` | Latest merged realtime state | cross-protocol latest fields only for VIN-bound vehicles | full parsed payloads, temporary identities, and historical data |
|
||||
| Redis | `vehicle:realtime-raw:{protocol}:{vin}` | Latest full realtime protocol state | latest protocol parsed payload for VIN-bound vehicles | historical data |
|
||||
|
||||
## Event Envelope Rules
|
||||
|
||||
Every received frame should become one `FrameEnvelope`.
|
||||
|
||||
- `protocol`, `message_id`, `event_time_ms`, `received_at_ms`, and `parse_status` are mandatory.
|
||||
- `vin` is preferred as the vehicle identity.
|
||||
- JT808 can use `phone` as a temporary key before VIN binding is resolved.
|
||||
- `raw_hex` or `raw_text` must be retained for replay and parser correction.
|
||||
- `parsed` stores protocol-specific fields.
|
||||
- `fields` stores only stable cross-protocol core fields: speed, total mileage, longitude, latitude, SOC.
|
||||
|
||||
## Optimization Order
|
||||
|
||||
1. Stabilize ingress: connection lifecycle, protocol parser correctness, bounded backpressure, durable publish.
|
||||
2. Stabilize event log: NATS to Kafka bridge, topic names, partition keys, retry and replay.
|
||||
3. Simplify storage: keep only the minimal tables above, remove duplicated payloads from business tables.
|
||||
4. Stabilize realtime: Redis full latest raw state, MySQL lightweight snapshot and location. Realtime consumers start from latest when no committed offset exists; historical rebuilds must be explicit replay jobs, not accidental backlog scans.
|
||||
5. Stabilize history: raw and location query pagination, clear time zone behavior.
|
||||
6. Stabilize metrics: idempotent daily metrics from mileage differences and raw replay.
|
||||
7. Add operations: health, readiness, metrics, lag, connection counts, deploy notes.
|
||||
|
||||
## Current Next Steps
|
||||
|
||||
1. Keep production inventory current whenever a service, topic, table, or Redis key family changes.
|
||||
2. Add parser correctness tests before changing GB32960, JT808, or Yutong MQTT field extraction.
|
||||
3. Add lag and write-failure alerts from the existing `/metrics` endpoints.
|
||||
4. Isolate or delete legacy Kafka topics only after confirming no external consumers still depend on them.
|
||||
5. Keep new business tables narrow by default. Add a column only when a query or index proves it is needed.
|
||||
|
||||
## Runtime Metrics Baseline
|
||||
|
||||
Runtime metrics are exposed as Prometheus text from `/metrics`. They are operational signals and must not create new business tables by default.
|
||||
|
||||
Core counters:
|
||||
|
||||
- `vehicle_gateway_frames_total`: received protocol frames by protocol and parse status.
|
||||
- `vehicle_gateway_publish_total`: raw publish results by protocol. Unified publish metrics appear only when the explicit compatibility stream is enabled.
|
||||
- `vehicle_realtime_kafka_messages_total`: realtime consumer messages by topic and status.
|
||||
- `vehicle_realtime_updates_total`: Redis/MySQL realtime projector updates by topic and status.
|
||||
- `vehicle_history_writes_total`: TDengine history writes by topic and status.
|
||||
- `vehicle_stat_writes_total`: MySQL metric writes by topic and status.
|
||||
- `vehicle_bridge_kafka_writes_total`: NATS to Kafka bridge writes by Kafka topic and status.
|
||||
|
||||
If a metric becomes a product requirement, derive a narrow metric table from Kafka replay instead of widening raw or realtime tables.
|
||||
106
docs/architecture/production-data-plane-inventory.md
Normal file
106
docs/architecture/production-data-plane-inventory.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 生产数据面清单
|
||||
|
||||
审计时间:2026-07-02
|
||||
|
||||
本文记录当前 ECS 上 Go 版本车辆接入链路的真实数据面,用来约束后续重构:新增表、topic、key 之前先对照这里,避免把已经删除的历史设计重新带回来。
|
||||
|
||||
## 运行服务
|
||||
|
||||
生产接入 ECS:`115.29.187.205`
|
||||
|
||||
| 服务 | systemd 单元 | 端口 | 职责 |
|
||||
| --- | --- | --- | --- |
|
||||
| Gateway | `lingniu-go-gateway.service` | `0.0.0.0:32960`、`0.0.0.0:808`、`127.0.0.1:20211` | GB32960、JT808、宇通 MQTT 接入和协议解析 |
|
||||
| NATS Kafka bridge | `lingniu-go-nats-kafka-bridge.service` | `127.0.0.1:20214` | NATS JetStream 到 Kafka 的可靠桥接 |
|
||||
| History writer | `lingniu-go-history-writer.service` | `127.0.0.1:20212` | Kafka raw 消费,写 TDengine |
|
||||
| Stat writer | `lingniu-go-stat-writer.service` | `127.0.0.1:20213` | Kafka raw 消费,写每日里程 |
|
||||
| Realtime API/projector | `lingniu-go-realtime-api.service` | `0.0.0.0:20200` | Kafka raw 消费,写 Redis/MySQL 实时表,并提供 API |
|
||||
|
||||
## 总线
|
||||
|
||||
### NATS JetStream
|
||||
|
||||
NATS 部署在 Kafka ECS 内网 `172.17.111.56:4222`。
|
||||
|
||||
| 项 | 当前值 |
|
||||
| --- | --- |
|
||||
| Stream | `VEHICLE_INGEST` |
|
||||
| Subjects | `vehicle.raw.go.gb32960.v1`、`vehicle.raw.go.jt808.v1`、`vehicle.raw.go.yutong-mqtt.v1` |
|
||||
| Durable consumer | `vehicle-kafka-bridge` |
|
||||
| 语义 | Gateway 先写 NATS raw subject;bridge 写 Kafka 成功后才 ACK NATS |
|
||||
| 保留策略 | `NATS_STREAM_MAX_BYTES=21474836480`,`NATS_STREAM_MAX_AGE_HOURS=24`,`NATS_STREAM_ENSURE_TIMEOUT_SECONDS=60`,NATS 仅做入口缓冲 |
|
||||
|
||||
### Kafka
|
||||
|
||||
当前 Go 链路正在使用的 topic:
|
||||
|
||||
| Topic | 分区 | 写入方 | 主要消费方 |
|
||||
| --- | --- | --- | --- |
|
||||
| `vehicle.raw.go.gb32960.v1` | 12 | NATS Kafka bridge | history、stat、realtime |
|
||||
| `vehicle.raw.go.jt808.v1` | 12 | NATS Kafka bridge | history、stat、realtime |
|
||||
| `vehicle.raw.go.yutong-mqtt.v1` | 12 | NATS Kafka bridge | history、stat、realtime |
|
||||
| `vehicle.event.go.unified.v1` | 12 | 显式打开 `PUBLISH_UNIFIED_ENABLED=true` 后才写入 | 兼容旧统一事件消费;当前最小链路不依赖 |
|
||||
|
||||
审计时 Kafka 上仍存在旧 Java/Xinda/telemetry topic,例如 `vehicle.raw.gb32960.v1`、`vehicle.raw.jt808.v1`、`vehicle.event.xinda.v1`、`vehicle.raw.telemetry-input.v1`。这些不属于当前 Go 最小链路,后续如果确认没有消费者依赖,应单独做 Kafka topic 下线清单,不在业务代码里继续引用。
|
||||
|
||||
## TDengine
|
||||
|
||||
数据库:`lingniu_vehicle_ts`
|
||||
|
||||
| Stable | 主列 | Tags | 职责 |
|
||||
| --- | --- | --- | --- |
|
||||
| `raw_frames` | `ts`、`frame_id`、`event_id`、`message_id`、`event_time`、`received_at`、`raw_size_bytes`、`raw_hex`、`raw_text`、`parsed_json`、`parse_status`、`parse_error`、`source_endpoint` | `protocol`、`vehicle_key`、`vin`、`phone`、`device_id` | RAW 证据层,保存完整解析 JSON |
|
||||
| `raw_frame_payload_chunks` | 分片 payload 列 | 协议和车辆标识 tags | 保存超长 raw/parsed payload |
|
||||
| `vehicle_locations` | `ts`、`event_id`、`frame_id`、`received_at`、`longitude`、`latitude`、`altitude_m`、`speed_kmh`、`direction_deg`、`alarm_flag`、`status_flag`、`total_mileage_km` | `protocol`、`vin` | 高频历史位置和总里程查询;只写 VIN 非空车辆 |
|
||||
|
||||
不应重新出现:
|
||||
|
||||
- `vehicle_mileage_points`
|
||||
- `raw_frames.fields_json`
|
||||
|
||||
## MySQL
|
||||
|
||||
数据库:`lingniu_vehicle_data`
|
||||
|
||||
| 表 | 核心字段 | 写入方 | 职责 |
|
||||
| --- | --- | --- | --- |
|
||||
| `vehicle_identity_binding` | `vin`、`plate`、`phone`、`oem` | 导入/人工维护,Gateway 只读 | 通过车牌、手机号反查 VIN |
|
||||
| `jt808_registration` | `phone`、`device_id`、`plate`、`vin`、`manufacturer`、`auth_token`、`source_endpoint`、首次/最新注册鉴权时间 | Gateway | JT808 注册、鉴权和 VIN 匹配状态 |
|
||||
| `vehicle_realtime_snapshot` | `protocol`、`vin`、`plate`、`event_time`、`received_at`、`event_id` | Realtime API/projector | 每协议每 VIN 最新事件轻量快照 |
|
||||
| `vehicle_realtime_location` | `protocol`、`vin`、`plate`、经纬度、速度、总里程、SOC、事件时间 | Realtime API/projector | 每协议每 VIN 最新位置业务缓存 |
|
||||
| `vehicle_daily_mileage` | `vin`、`stat_date`、`protocol`、`daily_mileage_km`、首末总里程、样本数 | Stat writer | 基于总里程差值的每日里程;无 VIN 数据不进入正式统计表 |
|
||||
|
||||
身份表使用业务主键:`vehicle_identity_binding` 以 `vin` 为主键,`jt808_registration` 以 `phone` 为主键;两者不保留代理自增主键和 `created_at`。`vehicle_identity_binding` 是外部维护的事实表,服务运行时不回写。
|
||||
|
||||
日里程表使用 `(vin, stat_date, protocol)` 作为业务主键,不保留自增 `id` 和 `created_at`;首末总里程和样本数保留用于解释差值计算。
|
||||
|
||||
两张实时当前态表均使用 `(protocol, vin)` 作为业务主键,不保留自增 `id` 和 `created_at`;最新更新时间使用 `updated_at`。
|
||||
|
||||
不应重新出现:
|
||||
|
||||
- `vehicle_daily_metric`
|
||||
- `vehicle_identity_binding_registration`
|
||||
- `vehicle_identity_bindings`
|
||||
|
||||
## Redis
|
||||
|
||||
Redis 使用 DB 50,定位为实时缓存,不作为历史事实来源。
|
||||
|
||||
| Key 族 | 用途 |
|
||||
| --- | --- |
|
||||
| `vehicle:latest:{vin}` | 跨协议合并后的最新核心字段快照,不重复保存完整 parsed;只写 VIN 非空车辆 |
|
||||
| `vehicle:latest:{vin}:{protocol}` | 单协议最新轻量快照,保留核心 fields 和时间信息,不重复保存完整 parsed |
|
||||
| `vehicle:realtime-raw:{protocol}:{vin}` | 单协议最新完整 parsed 状态,是实时完整协议字段的唯一 Redis 副本 |
|
||||
| `vehicle:online:{vin}` | 在线状态和 TTL |
|
||||
| `vehicle:protocols:{vin}` | 当前车辆最近出现过的协议集合 |
|
||||
| `vehicle:last_seen` | 最近活跃车辆排序集合 |
|
||||
|
||||
审计时活跃 key 族主要是 `vehicle:latest:*`、`vehicle:realtime-raw:*`、`vehicle:online:*`、`vehicle:protocols:*`、`vehicle:last_seen`。旧文档中的 `vehicle:realtime:*`、`vehicle:merged:*` 不是当前活跃 key 族。
|
||||
|
||||
## 后续优化约束
|
||||
|
||||
1. 接入层先写 NATS,Kafka 是持久回放层,下游消费者不能反向依赖 Gateway 内存状态。
|
||||
2. TDengine 只放高写入时序数据:RAW 证据和位置历史。
|
||||
3. MySQL 只放低基数业务状态:身份、实时轻量快照、每日里程。
|
||||
4. Redis 只放当前态,所有 key 都必须允许 TTL 过期后从 Kafka/TDengine/MySQL 重建。
|
||||
5. 协议新增字段默认进入 `raw_frames.parsed_json`;VIN 非空车辆同步进入 Redis realtime-raw。只有稳定查询需求出现后才提升为 TDengine/MySQL 列。
|
||||
107
docs/architecture/storage-minimal-contract.md
Normal file
107
docs/architecture/storage-minimal-contract.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# 车辆数据最小落库合约
|
||||
|
||||
## 目标
|
||||
|
||||
车辆接入系统只持久化能回答业务问题、能回放纠错、能支撑高频查询的数据。协议细节默认保存在 raw,不向上层业务表扩散。
|
||||
|
||||
## 第一性原则
|
||||
|
||||
1. raw 是证据层,必须能证明收到过什么、解析成什么。
|
||||
2. Kafka 是回放层,消费者失败后优先靠 Kafka lag 追平。
|
||||
3. Redis 是当前态缓存,不是历史库。
|
||||
4. TDengine 只承担高写入时间序列:raw、位置历史。
|
||||
5. MySQL 只承担低基数业务状态:身份映射、实时快照、日指标。
|
||||
6. 同一个事实只落一个主表,其他表只存查询必要的投影。
|
||||
7. 新字段先进入 `parsed_json`,只有稳定查询需求出现后才提升为列。
|
||||
|
||||
## 目标表边界
|
||||
|
||||
| 存储 | 表/Key | 保留内容 | 不保留内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| TDengine | `raw_frames` | raw hex/text、完整 `parsed_json`、解析状态、协议标签、车辆标识标签 | `fields_json` 这类可从 `parsed_json`/业务表再得到的重复字段 |
|
||||
| TDengine | `raw_frame_payload_chunks` | 超长 raw/parsed payload 分片 | 业务查询字段 |
|
||||
| TDengine | `vehicle_locations` | 时间、VIN/协议标签、经纬度、速度、方向、SOC、总里程等位置核心字段 | 完整协议 JSON、注册鉴权信息、phone/device_id/vehicle_key 兜底标识 |
|
||||
| MySQL | `vehicle_realtime_snapshot` | 每个协议+VIN 的最新事件时间、接收时间、车牌、事件 ID | phone、device、source endpoint、完整 JSON |
|
||||
| MySQL | `vehicle_realtime_location` | 每个协议+VIN 的最新位置核心字段 | raw、parsed JSON、消息头内部字段 |
|
||||
| MySQL | `vehicle_daily_mileage` | 日期、VIN、协议、日里程、首末总里程、样本数 | 临时 vehicle key、泛化 metric key/value、自增 id、created_at、每帧细节、位置点列表 |
|
||||
| MySQL | `vehicle_identity_binding` | 人工维护或导入的 VIN、车牌、phone、oem 映射 | 注册历史、协议状态、device_id、自增 id、created_at |
|
||||
| MySQL | `jt808_registration` | JT808 phone 主键下的注册、鉴权、VIN 匹配状态、来源端点 | GB32960/MQTT 注册信息、位置历史、created_at |
|
||||
| Redis | `vehicle:realtime-raw:{protocol}:{vin}` | 每协议每 VIN 最新完整 parsed 状态 | 无 VIN 临时身份、历史数据、统计结果 |
|
||||
|
||||
## 当前应收敛的重复点
|
||||
|
||||
1. `vehicle_mileage_points` 与 `vehicle_locations.total_mileage_km` 重复。
|
||||
- 状态:Go 写入链路已停止创建和写入 `vehicle_mileage_points`。
|
||||
- 查询:不再暴露单独 `/api/history/mileage-points`,里程点直接从 `/api/history/locations` 的 `total_mileage_km` 获取。
|
||||
- 生产:ECS TDengine 历史库已在上线前重建,`vehicle_mileage_points` 不再存在。
|
||||
|
||||
2. `raw_frames.fields_json` 与 `raw_frames.parsed_json`、`vehicle_locations` 重复。
|
||||
- 状态:新建 raw schema 和 raw 写入已停止使用 `fields_json`。
|
||||
- 兼容:raw 查询只选择公共列,不依赖 `fields_json`;旧表保留该列也不影响查询。
|
||||
- 生产:ECS TDengine 历史库已在上线前重建,`raw_frames` 不再包含 `fields_json`。
|
||||
|
||||
3. `vehicle_daily_mileage` 不再接收临时 `vehicle_key`。
|
||||
- 原因:日统计是正式业务指标,应只统计已经定位到 VIN 的车辆。
|
||||
- 无 VIN 的 JT808 等数据保留在 raw/Redis/session 中用于排查和待绑定,不进入正式车辆指标。
|
||||
- 状态:schema 使用 `PRIMARY KEY (vin, stat_date, protocol)`,不保留自增 `id` 和 `created_at`;首末总里程和样本数保留为日里程计算状态。
|
||||
- 索引:主键已覆盖按 VIN 查询,不再额外维护 `idx_vin`;只保留按日期、协议日期查询需要的索引。
|
||||
- 查询:`/api/stats/daily-metrics` 默认不执行 `COUNT(*)`,`total` 表示本页返回条数;只有报表总页数等场景传 `includeTotal=true`。
|
||||
- 生产:RDS 已在上线前删除泛化 `vehicle_daily_metric`,改为专用 `vehicle_daily_mileage`。
|
||||
|
||||
4. `vehicle_locations` 不再接收临时身份兜底字段。
|
||||
- 原因:位置历史是正式车辆时间序列,只按 `protocol + vin` 分表和查询。
|
||||
- 无 VIN 的位置帧仍保留在 `raw_frames.parsed_json`,待 identity/binding 修复后可从 raw 重放补写。
|
||||
- 查询:`/api/history/locations` 默认不执行大表 `COUNT(*)`,`total` 表示本页返回条数;只有需要精确总数时传 `includeTotal=true`。
|
||||
- 生产:ECS TDengine `vehicle_locations` 已在上线前重建为 `protocol`、`vin` 两个 tag。
|
||||
|
||||
5. 历史 raw 查询默认不做精确总数。
|
||||
- 原因:`raw_frames` 是最高写入量证据层,分页/排查通常只需要最新一页数据。
|
||||
- 查询:`/api/history/raw-frames` 默认 `total` 为本页返回条数;只有导出前预估、后台管理需要总页数时才传 `includeTotal=true`。
|
||||
|
||||
6. MySQL realtime 当前态表不保留代理主键和创建时间。
|
||||
- 原因:`vehicle_realtime_snapshot`、`vehicle_realtime_location` 都是每协议每 VIN 一行的当前态投影,业务主键就是 `(protocol, vin)`。
|
||||
- 状态:schema 使用 `PRIMARY KEY (protocol, vin)`,不保留自增 `id` 和 `created_at`,对外只暴露最新 `updated_at`。
|
||||
- 索引:不在 `vehicle_realtime_location` 维护经纬度组合索引;实时表只回答当前态,地理范围和轨迹类查询走 TDengine 历史位置。
|
||||
- 查询:`/api/realtime/snapshots`、`/api/realtime/locations` 默认不执行 `COUNT(*)`,`total` 表示本页返回条数;只有需要精确总数时传 `includeTotal=true`。
|
||||
- 写入:车牌从 `vehicle_identity_binding` 按 VIN 主键直查,并使用 VIN 级短 TTL 内存缓存,避免实时帧每条都打 MySQL;缓存不作为事实来源。
|
||||
- `vehicle_realtime_snapshot.parsed_json` 使用与 Redis KV 同口径的扁平字段对象,例如 `gb32960.vehicle.soc_percent`、`jt808.location.total_mileage_km`。
|
||||
- 不写 MySQL KV 投影表;实时全量字段保留在 Redis KV,历史全量字段走 TDengine raw fields。
|
||||
- 生产:项目上线前可直接重建这两张 MySQL 表,实时数据会从 Kafka 新消息继续投影。
|
||||
|
||||
7. Redis realtime 只服务已定位 VIN 的正式车辆。
|
||||
- 原因:在线状态和实时数据是业务当前态,不是身份排查队列。
|
||||
- 无 VIN 的 808 等帧只进入 raw 证据层和注册/绑定排查链路,不写 `vehicle:latest:*`、`vehicle:online:*`、`vehicle:realtime-raw:*`。
|
||||
- 绑定补齐 VIN 后,后续新帧自然进入实时态;历史补偿需要从 raw/Kafka 回放。
|
||||
|
||||
8. identity 层只保留两张表。
|
||||
- `vehicle_identity_binding`:VIN 与 plate/phone/oem 的映射,供 808 等协议反查 VIN。该表只允许外部导入或人工维护,服务运行时只读。
|
||||
- `jt808_registration`:808 注册、鉴权、最新活跃和 VIN 匹配状态。
|
||||
- 状态:`vehicle_identity_binding` 使用 VIN 主键;`jt808_registration` 使用 phone 主键;两张表都不保留代理自增主键和 `created_at`。
|
||||
- 查询:`vehicle_identity_binding` 的 phone/plate 都是唯一键,接入侧按唯一键直查 VIN,不做无意义排序。
|
||||
- 写入链路:gateway 对 binding 查 VIN 使用短 TTL 内存缓存,包含未命中缓存,避免无 VIN 高频位置帧每条都打 MySQL。
|
||||
- 生产:RDS 已在上线前删除旧 `vehicle_identity_binding_registration`、`vehicle_identity_bindings`,gateway 启动会自动创建最小 schema。
|
||||
|
||||
## 字段提升规则
|
||||
|
||||
协议字段进入系统后分三层:
|
||||
|
||||
1. `parsed_json`:默认入口,保存完整结构化解析。
|
||||
2. 核心列:只有跨协议稳定查询需要时才提升,例如经纬度、速度、SOC、总里程。
|
||||
3. 指标表:只有聚合口径稳定、产品需要分页/排序/报表时才持久化。
|
||||
|
||||
反例:
|
||||
|
||||
- 不因为某个协议字段存在就增加 MySQL 列。
|
||||
- 不为了方便调试在 snapshot/location 放完整 JSON。
|
||||
- 不为 telemetry field 配置服务提前复制一份字段表;字段配置服务应从 raw/Kafka 回放生成自己的结果。
|
||||
|
||||
## 删除或迁移顺序
|
||||
|
||||
1. 先写测试证明旧 API 能从新主表读到同等结果。
|
||||
2. 改代码停止写重复表或重复列。
|
||||
3. 部署后观察 Kafka lag、writer 成功计数、API 结果。
|
||||
4. 查询生产库确认旧表不再新增。
|
||||
5. 做一次备份或导出。
|
||||
6. 删除旧表或旧列。
|
||||
|
||||
任何一步无法证明安全,就停在兼容状态,不直接删生产数据。
|
||||
89
docs/architecture/tdengine-batch-writer-design.md
Normal file
89
docs/architecture/tdengine-batch-writer-design.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# TDengine Batch Writer Design
|
||||
|
||||
更新时间:2026-07-03
|
||||
|
||||
## Problem
|
||||
|
||||
`history-writer` 当前按 Kafka 消息逐条处理,并对每个 raw frame/location 调用 TDengine 写入。这个路径简单、可恢复,但在 10,000+ FPS 后会优先暴露三个瓶颈:
|
||||
|
||||
- 每帧一次 SQL/网络往返,吞吐上限低。
|
||||
- Kafka offset 逐条提交,commit 开销随帧率线性增长。
|
||||
- burst 流量下无法用批量 flush 平滑 TDengine 写入压力。
|
||||
|
||||
## Target
|
||||
|
||||
按 topic/protocol/目标表分组批量写 TDengine,按数量或时间触发 flush。
|
||||
|
||||
初始建议:
|
||||
|
||||
| 参数 | 初始值 |
|
||||
| --- | --- |
|
||||
| raw frame batch size | 200 |
|
||||
| location batch size | 500 |
|
||||
| flush interval | 100ms |
|
||||
| operation timeout | 5s |
|
||||
| max SQL payload | 8MB guardrail |
|
||||
| Kafka commit | TDengine batch 成功后提交本批最大 offset |
|
||||
|
||||
## Write Semantics
|
||||
|
||||
1. Kafka consumer 拉取消息后解析一次 envelope。
|
||||
2. envelope 已携带 `parsed_fields` 时,TDengine 写入直接复用,不重新从 raw JSON 解析。
|
||||
3. raw frame 和 location 可以分批写,但 offset 只能在本消息涉及的所有写入成功后提交。
|
||||
4. TDengine 写入失败时不提交 offset,让 Kafka 自动重放。
|
||||
5. 依赖 `event_id` 保持幂等,避免失败重放造成重复业务记录。
|
||||
|
||||
## Non-goals
|
||||
|
||||
- 第一阶段不改变 raw frame 表结构。
|
||||
- 第一阶段不合并不同 raw frame。
|
||||
- 不丢弃 parse error frame。
|
||||
- 不把 MySQL snapshot 或 Redis 当前态塞进 history-writer。
|
||||
|
||||
## Risks
|
||||
|
||||
- 批次越大,失败重试的重复写风险越高,必须依赖 `event_id` 或等价键。
|
||||
- `parsed_json` 较大时容易让单条 SQL 超限,需要按 payload size 提前切批。
|
||||
- batch flush 需要暴露 pending、flush duration、write error metrics,否则问题只会表现为 Kafka lag。
|
||||
|
||||
## Metrics Required
|
||||
|
||||
| 指标 | 含义 |
|
||||
| --- | --- |
|
||||
| `vehicle_history_batch_flush_total{status}` | 批写成功/失败次数 |
|
||||
| `vehicle_history_batch_rows_total{status}` | 批写行数 |
|
||||
| `vehicle_history_batch_flush_duration_ms{status}` | 批写耗时 |
|
||||
| `vehicle_history_kafka_lag` | 下游是否追得上 Kafka |
|
||||
|
||||
## Rollout
|
||||
|
||||
1. 保留当前逐条写实现作为 fallback。
|
||||
2. 增加批写器单元测试,覆盖 count flush、interval flush、失败不 commit。
|
||||
3. 在测试环境打开 batch writer。
|
||||
4. 生产先用小批次 `100/100ms`,观察 TDengine latency 和 Kafka lag。
|
||||
5. 逐步提升到 `200-500/100ms`。
|
||||
|
||||
## Implementation Status
|
||||
|
||||
2026-07-03 已完成第一阶段实现:
|
||||
|
||||
- `history.Writer.AppendAllBatch` 按 raw child table 和 location child table 生成多行 `INSERT ... VALUES (...),(...)`。
|
||||
- oversized payload chunks 仍写入 `raw_frame_payload_chunks`,同样按 child table 批量写。
|
||||
- `history-writer` Kafka 消费默认按 `HISTORY_BATCH_SIZE=200`、`HISTORY_BATCH_WAIT_MS=100` 收集消息。
|
||||
- TDengine batch 成功后才批量提交 Kafka messages;batch 失败不提交 offset,让 Kafka 保留可重放语义。
|
||||
- 保留 `processHistoryMessage` 和 `AppendAll` 单条路径,便于回退和测试。
|
||||
|
||||
新增指标:
|
||||
|
||||
```text
|
||||
vehicle_history_batch_flush_total{status}
|
||||
vehicle_history_batch_rows_total{status}
|
||||
vehicle_history_batch_flush_duration_ms{status}
|
||||
```
|
||||
|
||||
生产 rollout 建议:
|
||||
|
||||
1. 先部署默认 `200/100ms`。
|
||||
2. 观察 `vehicle_history_batch_*` 和 `vehicle_history_kafka_lag`。
|
||||
3. 如果 TDengine latency 上升或 Kafka lag 不下降,将 `HISTORY_BATCH_SIZE` 降到 `100`。
|
||||
4. 如果稳定且有 backlog,再逐步提升到 `500`。
|
||||
751
docs/go-version-data-flow.html
Normal file
751
docs/go-version-data-flow.html
Normal file
@@ -0,0 +1,751 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Lingniu Vehicle Ingest Go Version Data Flow</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--ink: #17202a;
|
||||
--muted: #5f6b7a;
|
||||
--line: #ccd3dd;
|
||||
--panel: #ffffff;
|
||||
--blue: #1f6feb;
|
||||
--green: #16803c;
|
||||
--amber: #a15c00;
|
||||
--red: #c23131;
|
||||
--purple: #7c3aed;
|
||||
--teal: #087f8c;
|
||||
--shadow: 0 10px 30px rgba(25, 33, 45, .08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 28px 34px 22px;
|
||||
background: #111827;
|
||||
color: white;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
max-width: 1180px;
|
||||
color: #d1d5db;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(1480px, calc(100vw - 36px));
|
||||
margin: 22px auto 48px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 20px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin: 18px 0;
|
||||
padding: 20px;
|
||||
background: var(--panel);
|
||||
border: 1px solid #e4e8ef;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d8dee8;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--blue);
|
||||
}
|
||||
|
||||
.dot.source { background: var(--amber); }
|
||||
.dot.gateway { background: var(--blue); }
|
||||
.dot.bus { background: var(--purple); }
|
||||
.dot.store { background: var(--green); }
|
||||
.dot.api { background: var(--teal); }
|
||||
.dot.warning { background: var(--red); }
|
||||
|
||||
.flow {
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 1.35fr 1.15fr 1.2fr 1.4fr 1.1fr;
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.lane {
|
||||
min-width: 190px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lane-title {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.node {
|
||||
position: relative;
|
||||
min-height: 88px;
|
||||
padding: 13px 13px 12px;
|
||||
border: 1px solid #d9e0ea;
|
||||
border-left: 5px solid var(--blue);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.node.source { border-left-color: var(--amber); }
|
||||
.node.gateway { border-left-color: var(--blue); }
|
||||
.node.bus { border-left-color: var(--purple); }
|
||||
.node.store { border-left-color: var(--green); }
|
||||
.node.api { border-left-color: var(--teal); }
|
||||
.node.warning { border-left-color: var(--red); }
|
||||
|
||||
.node strong {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.node p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.arrow:before, .arrow:after {
|
||||
content: "";
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
flex: 1;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.arrow:after {
|
||||
max-width: 16px;
|
||||
height: 0;
|
||||
border-top: 5px solid transparent;
|
||||
border-bottom: 5px solid transparent;
|
||||
border-left: 8px solid var(--line);
|
||||
background: transparent;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e1e6ef;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px 11px;
|
||||
border-bottom: 1px solid #edf1f6;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f0f4f8;
|
||||
color: #263241;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
|
||||
code {
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: #eef2f7;
|
||||
color: #1f2937;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
margin: 2px 4px 2px 0;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 11px 12px;
|
||||
border-left: 4px solid var(--amber);
|
||||
background: #fff7ed;
|
||||
color: #61410b;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ok {
|
||||
padding: 11px 12px;
|
||||
border-left: 4px solid var(--green);
|
||||
background: #effaf1;
|
||||
color: #164b25;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.mini-flow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(128px, 1fr));
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.mini-step {
|
||||
min-height: 82px;
|
||||
padding: 10px;
|
||||
border: 1px solid #dfe6f1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mini-step b {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mini-step span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.grid-2, .grid-3 { grid-template-columns: 1fr; }
|
||||
header { padding: 22px 18px; }
|
||||
main { width: calc(100vw - 20px); }
|
||||
.section { padding: 14px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Go 版本车辆数据处理与流转全图</h1>
|
||||
<div class="subtitle">
|
||||
当前 Go 架构以 gateway 做高性能协议接入,NATS JetStream 做入口缓冲和解耦,nats-kafka-bridge 保持现有 Kafka 下游兼容,最终进入 TDengine、Redis、MySQL,并由 realtime-api 对外提供查询能力。
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span class="tag"><span class="dot source"></span>外部数据源</span>
|
||||
<span class="tag"><span class="dot gateway"></span>Go 接入与解析</span>
|
||||
<span class="tag"><span class="dot bus"></span>NATS / Kafka 解耦</span>
|
||||
<span class="tag"><span class="dot store"></span>存储</span>
|
||||
<span class="tag"><span class="dot api"></span>查询 API</span>
|
||||
<span class="tag"><span class="dot warning"></span>可靠性边界</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="section">
|
||||
<h2>1. 总览图:从车辆平台到 API</h2>
|
||||
<div class="flow">
|
||||
<div class="lane">
|
||||
<div class="lane-title">外部输入</div>
|
||||
<div class="node source">
|
||||
<strong>GB/T 32960 平台</strong>
|
||||
<p>TCP 连接到 ECS <code>:32960</code>,包含登录、登出、实时信息、补发等帧。</p>
|
||||
</div>
|
||||
<div class="node source">
|
||||
<strong>JT/T 808 平台</strong>
|
||||
<p>TCP 连接到 ECS <code>:808</code>,包含注册、鉴权、位置上报 <code>0x0200</code> 等。</p>
|
||||
</div>
|
||||
<div class="node source">
|
||||
<strong>宇通 MQTT</strong>
|
||||
<p>gateway 作为 MQTT client 订阅 <code>/ytforward/shln/+</code>。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lane">
|
||||
<div class="lane-title">gateway 接入层</div>
|
||||
<div class="node gateway">
|
||||
<strong>TCPServer / MQTTClient</strong>
|
||||
<p>读取连接、拆包、处理半包/粘包、按协议路由到解析器。</p>
|
||||
</div>
|
||||
<div class="node gateway">
|
||||
<strong>协议解析器</strong>
|
||||
<p><code>gb32960</code>、<code>jt808</code>、<code>yutongmqtt</code> 解析 RAW,抽取核心 fields。</p>
|
||||
</div>
|
||||
<div class="node gateway">
|
||||
<strong>身份解析</strong>
|
||||
<p>通过 MySQL <code>vehicle_identity_binding</code> 将 phone/device/plate 尽量映射到 VIN。</p>
|
||||
</div>
|
||||
<div class="node gateway">
|
||||
<strong>协议响应</strong>
|
||||
<p>32960/808 需要应答时,在成功发布后写 ACK。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lane">
|
||||
<div class="lane-title">统一消息</div>
|
||||
<div class="node gateway">
|
||||
<strong>FrameEnvelope</strong>
|
||||
<p>统一封装:protocol、message_id、vin、phone、device_id、source_endpoint、raw、parsed、fields。</p>
|
||||
</div>
|
||||
<div class="node warning">
|
||||
<strong>StableEventID</strong>
|
||||
<p>基于协议、消息、车辆键、序号、时间、raw 生成稳定 ID,用于去重和追踪。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lane">
|
||||
<div class="lane-title">入口解耦</div>
|
||||
<div class="node bus">
|
||||
<strong>NATS JetStream</strong>
|
||||
<p>gateway 发布到 <code>VEHICLE_INGEST</code> stream,文件存储,24 小时保留。</p>
|
||||
</div>
|
||||
<div class="node bus">
|
||||
<strong>Subjects</strong>
|
||||
<p><code>vehicle.raw.go.gb32960.v1</code><br><code>vehicle.raw.go.jt808.v1</code><br><code>vehicle.raw.go.yutong-mqtt.v1</code></p>
|
||||
</div>
|
||||
<div class="node warning">
|
||||
<strong>Async + Retry</strong>
|
||||
<p>gateway 内部异步队列发布 NATS,降低协议连接被 Kafka 慢写拖住的风险。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lane">
|
||||
<div class="lane-title">Kafka 兼容层</div>
|
||||
<div class="node bus">
|
||||
<strong>nats-kafka-bridge</strong>
|
||||
<p>durable pull consumer 批量拉 NATS,写 Kafka 成功后才 ACK NATS。</p>
|
||||
</div>
|
||||
<div class="node bus">
|
||||
<strong>Kafka Topics</strong>
|
||||
<p>与 NATS subject 同名:三个协议 RAW topic。<code>vehicle.event.go.unified.v1</code> 仅作为显式兼容开关保留。</p>
|
||||
</div>
|
||||
<div class="node warning">
|
||||
<strong>失败语义</strong>
|
||||
<p>Kafka 写失败不 ACK,NATS 保留消息等待重试;未知 subject 不 ACK,防止静默丢数据。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lane">
|
||||
<div class="lane-title">落库与查询</div>
|
||||
<div class="node store">
|
||||
<strong>history-writer</strong>
|
||||
<p>消费 RAW topic,写 TDengine:raw_frames、payload_chunks、locations。</p>
|
||||
</div>
|
||||
<div class="node store">
|
||||
<strong>stat-writer</strong>
|
||||
<p>消费 RAW topic,根据总里程差值写 MySQL 每日指标。</p>
|
||||
</div>
|
||||
<div class="node store">
|
||||
<strong>realtime-api 消费器</strong>
|
||||
<p>消费三个 RAW topic,写 Redis 在线状态、实时合并快照、各协议 realtime-raw。</p>
|
||||
</div>
|
||||
<div class="node api">
|
||||
<strong>HTTP API</strong>
|
||||
<p>realtime-api 暴露实时、历史、统计查询接口,端口 <code>:20200</code>。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>2. 单条报文的完整生命周期</h2>
|
||||
<div class="mini-flow">
|
||||
<div class="mini-step"><b>1. 到达</b><span>32960/808 TCP 报文或 MQTT 消息到 gateway。</span></div>
|
||||
<div class="mini-step"><b>2. 拆包</b><span>TCP 按协议提取完整帧;MQTT 直接按消息处理。</span></div>
|
||||
<div class="mini-step"><b>3. 解析</b><span>生成 parsed 全量结构化数据和 fields 核心字段。</span></div>
|
||||
<div class="mini-step"><b>4. 绑定 VIN</b><span>根据 VIN/phone/device_id/plate 生成 vehicle_key。</span></div>
|
||||
<div class="mini-step"><b>5. 发 NATS</b><span>写协议 RAW subject;unified 只在兼容开关打开时写。</span></div>
|
||||
<div class="mini-step"><b>6. 桥接 Kafka</b><span>bridge 写 Kafka 成功后 ACK NATS。</span></div>
|
||||
<div class="mini-step"><b>7. 多路消费</b><span>历史、实时、统计各自消费 Kafka,互不阻塞。</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>3. FrameEnvelope:Go 版本内部统一数据格式</h2>
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>字段</th><th>含义</th><th>来源</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>event_id</code></td><td>稳定事件 ID</td><td>已有则保留,否则按协议/消息/车辆/时间/raw 计算</td></tr>
|
||||
<tr><td><code>protocol</code></td><td>协议</td><td><code>GB32960</code>、<code>JT808</code>、<code>YUTONG_MQTT</code></td></tr>
|
||||
<tr><td><code>message_id</code></td><td>协议消息类型</td><td>如 808 <code>0x0200</code>,32960 命令标识等</td></tr>
|
||||
<tr><td><code>vin</code></td><td>VIN</td><td>协议原始字段或 MySQL binding 反查</td></tr>
|
||||
<tr><td><code>phone</code></td><td>808 终端手机号</td><td>808 消息头 BCD 解析后去前导 0</td></tr>
|
||||
<tr><td><code>device_id</code></td><td>设备标识</td><td>协议内设备号、MQTT 设备字段等</td></tr>
|
||||
<tr><td><code>plate</code></td><td>车牌</td><td>注册帧或 binding 表</td></tr>
|
||||
<tr><td><code>source_endpoint</code></td><td>来源地址</td><td>TCP remote ip:port 或 MQTT endpoint/topic</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>字段</th><th>含义</th><th>去向</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>raw_hex</code> / <code>raw_text</code></td><td>原始报文</td><td>TDengine <code>raw_frames</code>,超长进入 chunks</td></tr>
|
||||
<tr><td><code>parsed</code></td><td>全量解析 JSON</td><td>TDengine <code>parsed_json</code>;Redis <code>realtime-raw</code></td></tr>
|
||||
<tr><td><code>fields</code></td><td>最小核心字段</td><td>TDengine 位置/里程点;Redis 合并快照;统计计算</td></tr>
|
||||
<tr><td><code>event_time_ms</code></td><td>车端事件时间</td><td>TDengine 主时间;Redis field time</td></tr>
|
||||
<tr><td><code>received_at_ms</code></td><td>平台接收时间</td><td>延迟分析、在线状态 last_seen</td></tr>
|
||||
<tr><td><code>parse_status</code></td><td>解析状态</td><td><code>OK</code>、<code>PARTIAL</code>、<code>BAD_FRAME</code></td></tr>
|
||||
<tr><td><code>parse_error</code></td><td>解析错误</td><td>RAW 查询排查使用</td></tr>
|
||||
<tr><td><code>vehicle_key</code></td><td>车辆主键</td><td>优先 VIN;否则 <code>PROTOCOL:phone/device</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>4. 三个协议的处理差异</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>协议</th>
|
||||
<th>接入方式</th>
|
||||
<th>身份主线</th>
|
||||
<th>RAW 数据</th>
|
||||
<th>核心 fields</th>
|
||||
<th>特殊动作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>GB32960</strong></td>
|
||||
<td>gateway TCP <code>:32960</code></td>
|
||||
<td>报文本身通常有 VIN;平台账号用于登录鉴权</td>
|
||||
<td>完整 parsed JSON 写 <code>raw_frames.parsed_json</code></td>
|
||||
<td>速度、经纬度、SOC、累计里程等按帧类型抽取</td>
|
||||
<td>登录/数据帧需要平台应答,成功发布后 ACK</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>JT808</strong></td>
|
||||
<td>gateway TCP <code>:808</code></td>
|
||||
<td>消息头 phone 为主;注册/鉴权/位置帧结合 binding 找 VIN</td>
|
||||
<td>完整 parsed JSON 写 <code>raw_frames.parsed_json</code></td>
|
||||
<td>位置、速度、方向、状态、报警、GPS 总里程</td>
|
||||
<td>注册/鉴权/通用应答;每日里程按总里程差值统计</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>YUTONG_MQTT</strong></td>
|
||||
<td>gateway MQTT client 订阅</td>
|
||||
<td>MQTT payload 中 VIN/设备字段;source endpoint 为 MQTT endpoint/topic</td>
|
||||
<td>完整 parsed JSON 写 <code>raw_frames.parsed_json</code></td>
|
||||
<td>速度、经纬度、总里程、SOC 等</td>
|
||||
<td>不需要 TCP ACK;实时多帧在 Redis 按协议合并</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>5. NATS + Kafka 的职责分工</h2>
|
||||
<div class="grid-3">
|
||||
<div class="node bus">
|
||||
<strong>NATS JetStream:入口缓冲层</strong>
|
||||
<p>部署在 Kafka ECS,Docker Compose 管理;监听内网 <code>172.17.111.56:4222</code>,监控 <code>172.17.111.56:8222</code>。gateway 只需要把事件快速、可靠地写入 NATS。</p>
|
||||
</div>
|
||||
<div class="node bus">
|
||||
<strong>nats-kafka-bridge:可靠桥接层</strong>
|
||||
<p>使用 durable pull consumer;批量写 Kafka;只有 Kafka 写入成功才 ACK NATS。Kafka 短暂异常时,NATS 消息保留并重试。</p>
|
||||
</div>
|
||||
<div class="node bus">
|
||||
<strong>Kafka:业务消费层</strong>
|
||||
<p>保持现有 topic 和消费者模型,history/realtime/stat 不直接依赖 gateway,也不直接依赖 NATS API。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note" style="margin-top:14px;">
|
||||
当前阶段没有让下游直接消费 NATS,这是为了最小化切换风险:入口换成 NATS 后,下游仍沿用 Kafka。后续如果要进一步实时化,可以让实时缓存或控制命令直接使用 NATS subject。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>6. 存储模型:哪些数据进入哪里</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>存储</th>
|
||||
<th>表/Key</th>
|
||||
<th>写入方</th>
|
||||
<th>内容</th>
|
||||
<th>用途</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>TDengine</strong></td>
|
||||
<td><code>raw_frames</code></td>
|
||||
<td>history-writer</td>
|
||||
<td>原始报文、完整 parsed JSON、parse status、source endpoint</td>
|
||||
<td>RAW 查询、追溯、排查解析问题</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>TDengine</strong></td>
|
||||
<td><code>raw_frame_payload_chunks</code></td>
|
||||
<td>history-writer</td>
|
||||
<td>超长 raw/parsed payload 分片</td>
|
||||
<td>避免 BINARY 长度限制导致完整 JSON 丢失</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>TDengine</strong></td>
|
||||
<td><code>vehicle_locations</code></td>
|
||||
<td>history-writer</td>
|
||||
<td>经纬度、速度、方向、状态、报警、总里程等核心字段</td>
|
||||
<td>高频历史位置分页查询</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Redis</strong></td>
|
||||
<td><code>vehicle:latest:{vin}</code></td>
|
||||
<td>realtime-api Kafka consumer</td>
|
||||
<td>跨协议合并后的实时字段快照</td>
|
||||
<td>查 VIN 是否在线、查 VIN 实时数据</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Redis</strong></td>
|
||||
<td><code>vehicle:latest:{vin}:{protocol}</code></td>
|
||||
<td>realtime-api Kafka consumer</td>
|
||||
<td>单协议实时轻量快照</td>
|
||||
<td>区分 32960/808/MQTT 的实时状态</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Redis</strong></td>
|
||||
<td><code>vehicle:realtime-raw:{protocol}:{vin}</code></td>
|
||||
<td>realtime-api Kafka consumer</td>
|
||||
<td>单协议最新 parsed 全量字段</td>
|
||||
<td>实时 RAW 字段查看,不走 TDengine 历史扫描</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Redis</strong></td>
|
||||
<td><code>vehicle:online:{vin}</code>、<code>vehicle:last_seen</code></td>
|
||||
<td>realtime-api Kafka consumer</td>
|
||||
<td>在线状态、最后接收时间、协议列表</td>
|
||||
<td>在线判断和近期车辆列表</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>MySQL</strong></td>
|
||||
<td><code>vehicle_identity_binding</code></td>
|
||||
<td>人工/导入维护,gateway 读取</td>
|
||||
<td>phone/device/plate 到 VIN 的映射</td>
|
||||
<td>808 等缺 VIN 协议反查正确 VIN</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>MySQL</strong></td>
|
||||
<td><code>jt808_registration</code></td>
|
||||
<td>gateway</td>
|
||||
<td>808 注册、鉴权、首次/最新上报、phone、device、plate、vin 解析结果</td>
|
||||
<td>定位哪些手机号没有映射 VIN,排查注册鉴权</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>MySQL</strong></td>
|
||||
<td><code>vehicle_daily_mileage</code></td>
|
||||
<td>stat-writer</td>
|
||||
<td>每日里程,按首末总里程差值计算,保留样本数</td>
|
||||
<td>统计查询</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>7. 查询入口</h2>
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<h3>实时查询</h3>
|
||||
<table>
|
||||
<thead><tr><th>接口类型</th><th>数据源</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>VIN 是否在线</td><td>Redis online key</td><td>TTL 内有数据即在线,默认 TTL 600 秒。</td></tr>
|
||||
<tr><td>VIN 实时数据</td><td>Redis latest snapshot</td><td>跨协议合并核心 fields;完整协议字段通过 realtime-raw 查询。</td></tr>
|
||||
<tr><td>单协议实时 RAW</td><td>Redis realtime-raw</td><td>查看某 VIN 在某协议下最新 parsed 全量字段。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<h3>历史查询</h3>
|
||||
<table>
|
||||
<thead><tr><th>接口类型</th><th>数据源</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>RAW 帧查询</td><td>TDengine raw_frames + chunks</td><td>按协议、VIN/phone、时间、消息类型分页。</td></tr>
|
||||
<tr><td>位置/总里程历史</td><td>TDengine vehicle_locations</td><td>高频位置分页查询,包含 total_mileage_km,避免重复里程点接口。</td></tr>
|
||||
<tr><td>每日里程</td><td>MySQL vehicle_daily_mileage</td><td>按日期、协议查询首末总里程差值结果。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>8. 生产部署视图</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>节点</th><th>组件</th><th>地址/端口</th><th>说明</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Gateway ECS</td>
|
||||
<td><code>lingniu-go-gateway</code></td>
|
||||
<td>公网 <code>115.29.187.205</code>;生产端口 <code>32960</code>、<code>808</code></td>
|
||||
<td>承接外部平台真实连接,发布到 NATS。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Gateway ECS</td>
|
||||
<td><code>lingniu-go-nats-kafka-bridge</code></td>
|
||||
<td>连接 NATS <code>172.17.111.56:4222</code>;Kafka <code>172.17.111.56:9092</code></td>
|
||||
<td>当前部署在 gateway ECS,负责 NATS 到 Kafka 桥接。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Gateway ECS</td>
|
||||
<td><code>history-writer</code>、<code>stat-writer</code>、<code>realtime-api</code></td>
|
||||
<td>API <code>115.29.187.205:20200</code></td>
|
||||
<td>消费 Kafka,写 TDengine/Redis/MySQL,并提供查询。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Kafka ECS</td>
|
||||
<td>Kafka + NATS</td>
|
||||
<td>公网 <code>114.55.58.251</code>;内网 <code>172.17.111.56</code></td>
|
||||
<td>NATS 使用 Docker Compose,端口只绑定内网 <code>4222</code>/<code>8222</code>。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>TDengine ECS</td>
|
||||
<td>TDengine</td>
|
||||
<td>内网 <code>172.17.111.57:6041</code></td>
|
||||
<td>历史 RAW、位置时序存储。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>云服务</td>
|
||||
<td>MySQL / Redis</td>
|
||||
<td>RDS 内网、Redis 内网</td>
|
||||
<td>身份绑定、808 注册、每日指标、实时缓存。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>9. 关键可靠性设计</h2>
|
||||
<div class="grid-3">
|
||||
<div class="ok">
|
||||
<strong>接入不直接等待 Kafka</strong><br>
|
||||
gateway 先写 NATS,Kafka 慢写由 bridge 消化,降低 32960/808 连接积压风险。
|
||||
</div>
|
||||
<div class="ok">
|
||||
<strong>NATS ACK 在 Kafka 成功之后</strong><br>
|
||||
bridge 写 Kafka 失败时不 ACK,消息仍在 JetStream,恢复后继续拉取。
|
||||
</div>
|
||||
<div class="ok">
|
||||
<strong>RAW 完整 JSON 保留</strong><br>
|
||||
<code>parsed</code> 全量写 RAW,核心表只存查询常用字段,避免位置查询每次扫完整 JSON。
|
||||
</div>
|
||||
<div class="ok">
|
||||
<strong>实时多协议合并</strong><br>
|
||||
Redis latest snapshot 只保留统一实时核心字段;完整协议 parsed 只放 realtime-raw,避免重复缓存大 JSON。
|
||||
</div>
|
||||
<div class="ok">
|
||||
<strong>身份解析边界</strong><br>
|
||||
实时缓存只写 VIN 非空车辆;无 VIN 帧保留在 RAW 和注册/绑定排查链路,补齐绑定后通过新帧或回放进入实时态。
|
||||
</div>
|
||||
<div class="ok">
|
||||
<strong>数据分层查询</strong><br>
|
||||
高频查询走 TDengine 核心字段表和 Redis;完整追溯走 RAW JSON,性能和完整性分开处理。
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>10. 当前运行链路一句话</h2>
|
||||
<p>
|
||||
外部 32960/808/MQTT 数据进入 Go gateway 后,被解析成 <code>FrameEnvelope</code>,先写入 NATS JetStream;
|
||||
<code>nats-kafka-bridge</code> 把 NATS 消息可靠转写到 Kafka;Kafka 再分发给 <code>history-writer</code> 写 TDengine、
|
||||
<code>stat-writer</code> 写 MySQL 每日指标、<code>realtime-api</code> 写 Redis 实时缓存并提供 HTTP 查询。
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="footer">
|
||||
Generated for lingniu-vehicle-ingest Go architecture. Update this file when gateway subjects, storage tables, or deployment topology changes.
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,749 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>lingniu-vehicle-ingest 模块与数据流</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f7f8fa;
|
||||
--panel: #ffffff;
|
||||
--ink: #172033;
|
||||
--muted: #667085;
|
||||
--line: #d8dee8;
|
||||
--blue: #2563eb;
|
||||
--green: #0f766e;
|
||||
--amber: #b45309;
|
||||
--red: #b42318;
|
||||
--purple: #6d28d9;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 32px 40px 24px;
|
||||
background: #101828;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 30px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 0;
|
||||
max-width: 980px;
|
||||
color: #d0d5dd;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1420px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 28px 44px;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 24px;
|
||||
padding: 24px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 21px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 22px 0 10px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-left: 4px solid var(--blue);
|
||||
border-radius: 6px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.summary-card span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.diagram {
|
||||
overflow-x: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 14px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dot.entry { background: var(--blue); }
|
||||
.dot.core { background: var(--green); }
|
||||
.dot.sink { background: var(--amber); }
|
||||
.dot.safety { background: var(--red); }
|
||||
.dot.command { background: var(--purple); }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f2f4f7;
|
||||
color: #344054;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
width: 190px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 13px;
|
||||
color: #101828;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
margin: 0 6px 6px 0;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag.red {
|
||||
background: #fee4e2;
|
||||
color: #912018;
|
||||
}
|
||||
|
||||
.tag.green {
|
||||
background: #ccfbef;
|
||||
color: #134e48;
|
||||
}
|
||||
|
||||
.tag.amber {
|
||||
background: #fef0c7;
|
||||
color: #93370d;
|
||||
}
|
||||
|
||||
.callout {
|
||||
margin-top: 14px;
|
||||
padding: 14px 16px;
|
||||
border-left: 4px solid var(--red);
|
||||
border-radius: 6px;
|
||||
background: #fff7f5;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
background: #eef2f6;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
header {
|
||||
padding: 24px 20px 20px;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>lingniu-vehicle-ingest 模块与数据流</h1>
|
||||
<p>
|
||||
本图基于当前 Maven 多模块项目整理,用于后续讨论协议接入、内部字段、氢能车辆运营统计、安全告警和下游消费边界。
|
||||
当前架构将接入层、历史明细、Redis 热状态和日统计拆成独立模块,通过统一 Kafka Envelope 解耦。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section>
|
||||
<h2>一、系统定位</h2>
|
||||
<p>
|
||||
当前项目不是业务库写入服务。它的核心职责是把 GB/T 32960、JT/T 808、Yutong MQTT
|
||||
等默认生产来源的车辆数据统一转换为 <code>VehicleEvent</code> 和全字段 <code>TelemetrySnapshot</code>;
|
||||
JT/T 1078、JSATL12 作为显式 profile 的可选能力保留,
|
||||
再通过 Kafka、原始报文归档、TDengine 历史索引、Redis 热状态和独立统计模块交给下游业务。
|
||||
</p>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card">
|
||||
<strong>接入方式</strong>
|
||||
<span>Netty TCP、MQTT、后续可扩展更多 Inbound Adapter;信达 Push 源码已删除。</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<strong>统一模型</strong>
|
||||
<span>所有协议归一到 <code>VehicleEvent</code> 和全字段内部 <code>TelemetrySnapshot</code>。</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<strong>输出方式</strong>
|
||||
<span>Kafka Protobuf Envelope + 原始 bytes 冷存 + TDengine raw_frames/locations + Redis 热状态。</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<strong>业务边界</strong>
|
||||
<span>日统计由 <code>vehicle-stat-service</code> 消费 Kafka 实现,告警工单、资产业务继续由下游消费。</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>二、模块分层图</h2>
|
||||
<div class="diagram">
|
||||
<pre class="mermaid">
|
||||
flowchart TB
|
||||
subgraph external["外部来源"]
|
||||
vehicle["车辆终端<br/>GB/T 32960 / JT808 / JT1078"]
|
||||
mqttSource["车企或平台 MQTT"]
|
||||
commandClient["业务系统 / 运维平台<br/>HTTP 下行命令"]
|
||||
end
|
||||
|
||||
subgraph protocolModules["协议模块 modules/protocols"]
|
||||
gb["protocol-gb32960<br/>32960 Netty 接入、鉴权、ACK、解析"]
|
||||
jt808["protocol-jt808<br/>808 Netty 接入、会话、位置/批量位置、媒体、透传、下行"]
|
||||
jt1078["protocol-jt1078<br/>1078 信令 + TCP/UDP RTP 媒体流归档"]
|
||||
jsatl12["protocol-jsatl12<br/>主动安全附件流接入"]
|
||||
end
|
||||
|
||||
subgraph inbound["入口适配 modules/inbound"]
|
||||
mqtt["inbound-mqtt<br/>MQTT endpoint 生命周期、PEM TLS"]
|
||||
mqttProfile["MqttProfileRegistry<br/>按 endpoint profile 解析/映射"]
|
||||
end
|
||||
|
||||
subgraph protocolBase["核心与公共能力 modules/core"]
|
||||
codec["ingest-codec-common<br/>BCD / CRC / BCC / bit 工具"]
|
||||
session["session-core<br/>设备会话、命令分发接口、会话存储"]
|
||||
identity["vehicle-identity<br/>跨协议身份解析、MySQL 绑定表"]
|
||||
obs["observability<br/>metrics / health"]
|
||||
api["ingest-api<br/>ProtocolId / RawFrame / VehicleEvent / 注解 SPI"]
|
||||
registry["HandlerRegistry<br/>按协议、命令、infoType 路由"]
|
||||
dispatcher["Dispatcher<br/>RawFrame 到 Handler 到 EventBus"]
|
||||
bus["DisruptorEventBus<br/>高吞吐事件发布"]
|
||||
end
|
||||
|
||||
subgraph sink["输出与明细存储 modules/sinks"]
|
||||
kafka["sink-kafka<br/>VehicleEvent 到 Protobuf Envelope 到 Kafka"]
|
||||
archive["sink-archive<br/>RawArchive 到本地文件系统冷存"]
|
||||
tdengineStore["tdengine-history-store<br/>TDengine raw_frames + locations"]
|
||||
end
|
||||
|
||||
subgraph services["消费服务 modules/services"]
|
||||
history["event-history-service<br/>Kafka event/raw 到 TDengine 历史查询"]
|
||||
state["vehicle-state-service<br/>可选 Redis 热状态<br/>optional-latest-state"]
|
||||
stat["vehicle-stat-service<br/>Kafka 全字段事件到指标表"]
|
||||
end
|
||||
|
||||
subgraph apps["应用入口 modules/apps"]
|
||||
gbApp["gb32960-ingest-app<br/>GB32960 TCP 接入"]
|
||||
jtApp["jt808-ingest-app<br/>JT808 TCP 接入"]
|
||||
yutongApp["yutong-mqtt-app<br/>宇通 MQTT 接入"]
|
||||
historyApp["vehicle-history-app<br/>历史查询与 TDengine 写入"]
|
||||
analyticsApp["vehicle-analytics-app<br/>808 日里程指标消费"]
|
||||
gateway["command-gateway<br/>可选 HTTP 设备命令入口"]
|
||||
end
|
||||
|
||||
vehicle --> gbApp
|
||||
vehicle --> jtApp
|
||||
vehicle --> jt1078
|
||||
vehicle --> jsatl12
|
||||
mqttSource --> yutongApp
|
||||
gbApp --> gb
|
||||
jtApp --> jt808
|
||||
yutongApp --> mqtt
|
||||
mqtt --> mqttProfile
|
||||
mqttProfile --> api
|
||||
commandClient --> gateway
|
||||
|
||||
gb --> codec
|
||||
jt808 --> codec
|
||||
jt1078 --> codec
|
||||
jsatl12 --> codec
|
||||
|
||||
gb --> api
|
||||
jt808 --> api
|
||||
jsatl12 --> archive
|
||||
jsatl12 --> jt808
|
||||
|
||||
api --> registry
|
||||
registry --> dispatcher
|
||||
dispatcher --> bus
|
||||
bus --> kafka
|
||||
bus --> archive
|
||||
kafka --> historyApp
|
||||
kafka --> analyticsApp
|
||||
historyApp --> history
|
||||
analyticsApp --> stat
|
||||
kafka -.可选热状态.-> state
|
||||
history --> tdengineStore
|
||||
|
||||
gateway --> session
|
||||
session --> jt808
|
||||
session --> gb
|
||||
identity --> jt808
|
||||
identity --> mqttProfile
|
||||
|
||||
obs -.监控.-> dispatcher
|
||||
obs -.监控.-> bus
|
||||
obs -.监控.-> kafka
|
||||
|
||||
classDef entry fill:#eff6ff,stroke:#2563eb,color:#1e3a8a;
|
||||
classDef core fill:#ecfdf3,stroke:#0f766e,color:#134e48;
|
||||
classDef sink fill:#fffbeb,stroke:#b45309,color:#7c2d12;
|
||||
classDef command fill:#f5f3ff,stroke:#6d28d9,color:#4c1d95;
|
||||
classDef support fill:#f8fafc,stroke:#64748b,color:#334155;
|
||||
|
||||
class gb,jt808,jt1078,jsatl12,mqtt,mqttProfile,gbApp,jtApp,yutongApp,historyApp,analyticsApp entry;
|
||||
class api,registry,dispatcher,bus,identity core;
|
||||
class kafka,archive,tdengineStore sink;
|
||||
class gateway,session command;
|
||||
class codec,obs support;
|
||||
</pre>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span><i class="dot entry"></i>入口/协议模块</span>
|
||||
<span><i class="dot core"></i>核心管线</span>
|
||||
<span><i class="dot sink"></i>输出层</span>
|
||||
<span><i class="dot command"></i>下行命令/会话</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>三、上行数据流转</h2>
|
||||
<div class="diagram">
|
||||
<pre class="mermaid">
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Device as 车辆/平台
|
||||
participant Inbound as Inbound Adapter<br/>Netty/MQTT
|
||||
participant Decoder as FrameDecoder<br/>MessageDecoder
|
||||
participant Dispatcher as Dispatcher
|
||||
participant Handler as Protocol Handler<br/>Mapper
|
||||
participant EventBus as DisruptorEventBus
|
||||
participant Kafka as sink-kafka<br/>Kafka Envelope
|
||||
participant Archive as sink-archive<br/>ArchiveStore
|
||||
participant History as vehicle-history-app
|
||||
participant TDengine as tdengine-history-store<br/>raw_frames + locations
|
||||
participant Consumer as 下游业务消费者
|
||||
|
||||
Device->>Inbound: 原始报文 bytes / MQTT message
|
||||
Inbound->>Decoder: 粘包拆帧、校验、协议体解析
|
||||
Inbound->>Inbound: MQTT 按 endpoint profile 选择厂商解析器
|
||||
Decoder->>Dispatcher: RawFrame(protocol, command, infoType, payload, rawBytes)
|
||||
Dispatcher->>EventBus: 先发布 RawArchive 事件
|
||||
EventBus->>Archive: 保存原始 bytes,生成可回放材料
|
||||
Dispatcher->>Handler: 根据 protocol + command + infoType 路由
|
||||
Handler->>Handler: 协议字段映射为内部字段
|
||||
Dispatcher->>Handler: 绑定 rawArchiveKey / rawArchiveUri
|
||||
Handler->>EventBus: VehicleEvent(Realtime / Location / Alarm / Login ...)
|
||||
EventBus->>Kafka: 投递规整后的业务事件
|
||||
Kafka->>Kafka: EnvelopeMapper 转 Protobuf
|
||||
Kafka->>History: event/raw topic,key=vin,单车有序
|
||||
History->>TDengine: 写 raw_frames、locations 和 raw parsed JSON
|
||||
TDengine->>Consumer: 分页历史查询、原始帧追溯、位置查询
|
||||
Kafka->>Consumer: Redis 热状态、指标统计、告警工单等下游消费
|
||||
</pre>
|
||||
</div>
|
||||
<div class="callout">
|
||||
<strong>关键边界:</strong>
|
||||
协议字段只在协议模块内解释,出了 Mapper 以后都应该使用内部字段。统计每日里程、每日用电量、每日用氢量、储氢安全和氢泄露告警时,应消费全字段 TelemetrySnapshot,不要直接依赖某个协议的原始字段名。
|
||||
</div>
|
||||
<div class="callout">
|
||||
<strong>原始追溯:</strong>
|
||||
Dispatcher 为所有带 rawBytes 的 RawFrame 生成统一的 <code>rawArchiveKey</code>,业务事件 metadata 使用 <code>rawArchiveUri=archive://...</code> 指向同一份原始 bytes。归档文件的真实本地路径只属于 ArchiveStore,查询、导出和 Kafka Envelope 使用逻辑 URI 解耦存储实现。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>四、32960 细化链路</h2>
|
||||
<div class="diagram">
|
||||
<pre class="mermaid">
|
||||
flowchart LR
|
||||
terminal["氢能车 TBOX<br/>GB/T 32960"] --> netty["Gb32960NettyServer<br/>TCP / TLS / Idle 检测"]
|
||||
netty --> access["Gb32960AccessService<br/>VIN 白名单 / 平台登录鉴权"]
|
||||
netty --> frame["Gb32960FrameDecoder<br/>拆帧、转义、校验"]
|
||||
frame --> decoder["Gb32960MessageDecoder<br/>Header + Body 解析"]
|
||||
decoder --> diag["Gb32960FrameDiagnostics<br/>首帧、Raw 块、异常诊断"]
|
||||
decoder --> handler["Gb32960ChannelHandler<br/>ACK / NACK / dispatch"]
|
||||
handler --> ack["Gb32960AckService<br/>登录应答、失败断开"]
|
||||
handler --> dispatcher["Dispatcher"]
|
||||
dispatcher --> rtHandler["Gb32960RealtimeHandler"]
|
||||
rtHandler --> mapper["Gb32960EventMapper<br/>协议字段到内部字段"]
|
||||
mapper --> realtime["RealtimePayload<br/>速度、里程、电量、氢量、压力、温度"]
|
||||
mapper --> alarm["AlarmPayload<br/>安全分类、氢泄露、告警等级"]
|
||||
mapper --> login["Login / Logout / Heartbeat"]
|
||||
realtime --> bus["DisruptorEventBus"]
|
||||
alarm --> bus
|
||||
login --> bus
|
||||
bus --> kafka["Kafka Envelope"]
|
||||
bus --> archive["RawArchive 冷存"]
|
||||
|
||||
classDef safety fill:#fff1f3,stroke:#b42318,color:#7a271a;
|
||||
classDef core fill:#ecfdf3,stroke:#0f766e,color:#134e48;
|
||||
classDef entry fill:#eff6ff,stroke:#2563eb,color:#1e3a8a;
|
||||
classDef sink fill:#fffbeb,stroke:#b45309,color:#7c2d12;
|
||||
|
||||
class terminal,netty,frame,decoder,handler entry;
|
||||
class access,diag,ack,dispatcher,rtHandler,mapper,bus core;
|
||||
class alarm safety;
|
||||
class kafka,archive sink;
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>五、模块职责表</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>模块</th>
|
||||
<th>职责</th>
|
||||
<th>主要输入</th>
|
||||
<th>主要输出</th>
|
||||
<th>业务开发关注点</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>modules/core/ingest-api</td>
|
||||
<td>定义系统边界:协议 ID、RawFrame、IngestContext、VehicleEvent、Payload、Handler 注解、Sink SPI;ProtocolId 提供 UNKNOWN 隔离桶,供消费侧兼容未来协议或脏数据,不作为正式接入协议使用;consumer 包定义 EnvelopeIngestor、EnvelopeConsumerProcessor 和 EnvelopeDeadLetterSink,统一 Kafka 消费结果与死信边界。</td>
|
||||
<td>无直接外部输入。</td>
|
||||
<td>全项目共享的接口和内部事件模型。</td>
|
||||
<td><span class="tag red">内部字段定义</span><span class="tag">新增事件类型</span><span class="tag">字段兼容性</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/core/ingest-codec-common</td>
|
||||
<td>公共编解码工具,承载 BCD、CRC、BCC、bit 操作等协议底层能力。</td>
|
||||
<td>协议模块传入的 byte、bit、校验材料。</td>
|
||||
<td>解析辅助结果。</td>
|
||||
<td><span class="tag">协议基础能力</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/core/ingest-core</td>
|
||||
<td>核心管线。扫描 Handler,按协议和命令路由,执行拦截器,发布 VehicleEvent 到 Disruptor。</td>
|
||||
<td>RawFrame。</td>
|
||||
<td>VehicleEvent、RawArchive。</td>
|
||||
<td><span class="tag green">吞吐</span><span class="tag green">路由</span><span class="tag amber">原始可回放</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/core/session-core</td>
|
||||
<td>设备会话、命令下发抽象、SessionStore、CommandDispatcher 默认实现。SessionStore 仅保留 Redis 后端;协议进程本地只保存真实 Netty Channel,Redis 保存 <code>sessionId</code>、VIN 和 phone 三索引及 TTL,协议模块只依赖 SPI,不感知存储细节。</td>
|
||||
<td>设备连接、命令请求。</td>
|
||||
<td>会话状态、命令分发结果、Redis 会话索引。</td>
|
||||
<td><span class="tag">在线状态</span><span class="tag">下行控制</span><span class="tag green">多实例会话索引</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/core/vehicle-identity</td>
|
||||
<td>跨协议车辆身份解析,维护 phone、deviceId、plate 到 VIN 的绑定关系;生产运行使用 MySQL 绑定表,避免重启丢失绑定并保证多实例一致。</td>
|
||||
<td>协议模块传入的外部标识。</td>
|
||||
<td>稳定 VIN、解析来源、是否已命中绑定、MySQL 绑定记录。</td>
|
||||
<td><span class="tag red">统计主键</span><span class="tag">多协议归一</span><span class="tag green">持久化绑定</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/core/observability</td>
|
||||
<td>监控、指标、健康检查等横切能力。</td>
|
||||
<td>核心管线和 Sink 运行状态。</td>
|
||||
<td>Micrometer 指标、健康信息。</td>
|
||||
<td><span class="tag green">可用性</span><span class="tag">延迟监控</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/protocols/protocol-gb32960</td>
|
||||
<td>GB/T 32960 接入、鉴权、ACK、报文解析、实时数据和告警映射。</td>
|
||||
<td>32960 TCP 报文。</td>
|
||||
<td>Realtime、Location、Alarm、Login、Logout、Heartbeat、RawFrame。</td>
|
||||
<td><span class="tag red">氢泄露</span><span class="tag red">储罐安全</span><span class="tag">电量/氢量</span><span class="tag">里程</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/protocols/protocol-jt808</td>
|
||||
<td>JT/T 808 接入、注册/鉴权/心跳/注销、位置/位置附加项、批量位置、参数/属性、多媒体、透传、未知上行兜底透传、分帧边界异常和协议解析异常坏帧兜底、下行命令和超长下行分包能力;0x0200 位置附加项 0x01 总里程按 0.1km 解码为内部字段 <code>total_mileage_km</code>,后续每日里程统计不依赖 808 原始字段;0x0102 鉴权帧解析会保留完整 token,并尽量提取 IMEI 和软件版本;注册和鉴权会话均使用共享身份解析后的内部 VIN,避免 deviceId/IMEI 污染后续会话和下行边界;终端连接断开时同步解绑 Channel 并清理 <code>SessionStore</code> 会话,避免 command-gateway 误判离线车辆仍可下行;正常上行 RawArchive 和业务事件 metadata 均使用共享身份解析后的内部 vin,并保留 phone、identityResolved、identitySource 便于冷存和业务事件按同一车辆查询;无法解析终端身份的坏帧 RawArchive 和 Passthrough 均按 vin=unknown、identityResolved=false、identitySource=UNKNOWN 标记;0x0900 透传事件按原始消息 ID 归类,透传类型写入 passthroughType metadata。</td>
|
||||
<td>808 TCP 报文。</td>
|
||||
<td>Location、Login、Logout、Heartbeat、MediaMeta、Passthrough、未知上行 Raw 兜底、坏帧 Passthrough、会话状态、下行响应。</td>
|
||||
<td><span class="tag">GPS 位置</span><span class="tag">在线状态</span><span class="tag">媒体证据</span><span class="tag">命令链路</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/protocols/protocol-jt1078</td>
|
||||
<td>JT/T 1078 音视频能力。信令在 JT808 mapper 存在时桥接复用 JT808 连接和包头;0x1005 乘客流量保留原始 body,同时结构化 channelId、startTime、endTime、passengerGetOn、passengerGetOff metadata 便于查询/导出;0x1205 文件列表保留原始 body,同时结构化 responseSerialNo、fileCount 摘要;下行信令编码由本模块提供,覆盖 0x1003 音视频属性查询、0x9101 实时预览、0x9102 实时控制、0x9201 历史回放、0x9202 回放控制、0x9205 资源列表查询、0x9206 文件上传和 0x9207 文件上传控制,命令发送仍由 command-gateway 经 CommandDispatcher 复用 JT808 在线通道;TCP/UDP RTP 媒体流独立端口接入,生产默认端口按旧接收服务对齐为 <code>11078</code>,按 VIN/通道/时间分段写 ArchiveStore,Kafka 只发送 MediaMeta 引用,事件 metadata 暴露内部 vin、sim、channelId、dataType、packetType、segment、sequence、segmentSizeBytes、archiveKey 和 archiveRef,MediaMeta.sizeBytes 同步当前片段大小,MediaMeta 按 archiveKey 去重,避免同一 SIM 运行中从 fallback 身份切换到内部 VIN 时漏发新归档引用,便于文件明细库追踪、展示和导出;超长/短包/坏魔数等 RTP 入口异常和媒体归档失败都会兜底产出 Passthrough 错误事件,坏 RTP 统一走 Dispatcher 产出 RawArchive 和 Passthrough,统一标记 parseError 和 parseErrorMessage,并保留内部 vin、原始 bytes、peer、长度、原因、RawArchive 引用、segment 和 archiveKey 便于排障;坏 RTP 头部可读时会提取 SIM 并通过共享身份服务映射 VIN。</td>
|
||||
<td>1078 信令报文、TCP/UDP RTP 媒体流。</td>
|
||||
<td>MediaMeta、乘客流量 Passthrough、文件列表 Passthrough、坏 RTP/归档失败 Passthrough、归档媒体分段。</td>
|
||||
<td><span class="tag">视频扩展</span><span class="tag green">信令事件化</span><span class="tag amber">大流冷存</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/protocols/protocol-jsatl12</td>
|
||||
<td>苏标主动安全报警附件接入(仅 <code>optional-attachments</code> profile 显式构建,不属于默认生产面)。依赖 ArchiveStore 和 JT808 decoder;端口按旧接收服务对齐为 <code>7612</code>;附件 DataPacket 按旧服务真实布局 <code>01cd + 50B文件名 + offset + length + data</code> 分帧解析,不再把文件名前 4 字节误判为帧长;附件数据写 ArchiveStore 后通过 Dispatcher 产出 MediaMeta 引用事件,事件 metadata 保留 fileName、fileOffset、declaredChunkSizeBytes、archiveKey 和 archiveRef,便于附件明细查询、导出和补传诊断;内置附件状态机处理 T1210 文件清单、DataPacket 分块区间和 T1212 上传完成消息,数据分块会按 T1210 文件清单中的文件名继承手机号并通过共享身份解析器得到内部 VIN,入口 RawFrame、ArchivedChunk 和 MediaMeta 均保留 phone、vin、identityResolved、identitySource,只有找不到文件归属时才标记 UNKNOWN;按文件名合并无身份数据分块并返回 0x9212 完成/补传应答,补传应答包含缺失 offset/length 区间;附件归档失败会产出 JSATL12 Passthrough 错误事件并保留原始分块;JT_MESSAGE 信令复用 JT808 decoder 后进入 Dispatcher,桥接 RawFrame 使用共享身份解析后的内部 VIN并保留 phone、identityResolved、identitySource,坏 JT 信令入口 RawFrame 同样标记 UNKNOWN 身份;超长/畸形附件帧、未闭合超长 JT 信令和坏 JT 信令都以 JSATL12 Passthrough 兜底,附件帧异常同时标记 frameError 与统一 parseError/parseErrorMessage,保留原始字节、peer、长度和错误元数据,帧长和 worker 线程支持部署配置。</td>
|
||||
<td>主动安全附件流、JT808 信令帧。</td>
|
||||
<td>归档文件、MediaMeta、JT808 统一事件、坏帧/坏信令/归档失败 Passthrough。</td>
|
||||
<td><span class="tag amber">附件归档</span><span class="tag">报警证据</span><span class="tag">信令复用</span><span class="tag">错误隔离</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/inbound/inbound-mqtt</td>
|
||||
<td>MQTT 多 endpoint 生命周期管理,支持 PEM CA、客户端证书/私钥双向 TLS,按 endpoint profile 路由到厂商解析/映射实现,默认提供宇通 profile;生产默认接入口径参考旧接收服务:<code>ssl://cpxlm.axxc.cn:38883</code>、topic <code>/ytforward/shln/+</code>、QoS 2、cleanSession=false、keepAlive=20s、connectTimeout=10s,用户名和密码仍通过环境变量注入;入站 RawFrame 和事件映射均接入统一车辆身份解析,解析器保留 externalVin、phone、mqttDeviceId、plateNo,优先按设备号/终端 ID/IMEI 绑定解析内部 VIN,未绑定且字段形态为 VIN 时再作为显式 VIN 兜底,冷存和事件 metadata 均保留这些外部标识、identityResolved、identitySource;未知 profile、解析失败、profile 运行时异常、endpoint 初始化/连接/订阅失败均兜底为 Passthrough,解析失败诊断和 profile 异常诊断都会同步写入 RawArchive metadata,并在最终 Passthrough 事件保留 parseErrorMessage 或 operational reason,无法解析设备身份的兜底事件和运行类归档都按 vin=unknown、identityResolved=false、identitySource=UNKNOWN 标记,保留原始 payload 或 operational 错误信息便于排障和归档追溯。</td>
|
||||
<td>MQTT topic/message、endpoint profile、TLS PEM 配置。</td>
|
||||
<td>统一 RawFrame、Realtime、Location、Passthrough。</td>
|
||||
<td><span class="tag">车企平台接入</span><span class="tag green">profile 扩展</span><span class="tag green">身份映射</span><span class="tag green">双向 TLS</span><span class="tag green">错误隔离</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/sinks/sink-kafka</td>
|
||||
<td>Kafka Sink。把 VehicleEvent 转成 Protobuf Envelope,按 VIN 分区投递;业务事件携带全字段 TelemetrySnapshot 和 rawArchiveUri,显式 RawArchive Envelope 会填充 archive:// 逻辑 URI 与 size,默认 Kafka Sink 仍不发送原始 bytes 本体;同时提供 KafkaEnvelopeConsumerFactory、KafkaEnvelopeConsumerRunner、KafkaEnvelopeConsumerWorker 和 KafkaEnvelopeDeadLetterSink,统一服务侧 Kafka 消费启动、独立 group 绑定、死信发布和 topic 到 EnvelopeConsumerProcessor 的分发。</td>
|
||||
<td>VehicleEvent。</td>
|
||||
<td>Kafka Protobuf 消息、消费侧 DLQ 记录。</td>
|
||||
<td><span class="tag green">单车有序</span><span class="tag">Schema 演进</span><span class="tag red">安全字段下发给消费者</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/sinks/sink-archive</td>
|
||||
<td>原始报文冷存,当前为本地文件系统实现;写入键与业务事件 metadata 中的 <code>rawArchiveKey</code> 保持一致。</td>
|
||||
<td>RawArchive 事件、附件流。</td>
|
||||
<td>可回放原始文件、<code>archive://...</code> 逻辑引用。</td>
|
||||
<td><span class="tag amber">问题排查</span><span class="tag amber">合规留痕</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/sinks/tdengine-history-store</td>
|
||||
<td>生产历史库边界,负责 TDengine schema、raw_frames/location 行映射、批量写入和分页查询语句;raw_frames 保留完整 payloadJson.parsed,位置表保持轻量字段并通过 rawUri 关联原始帧。</td>
|
||||
<td>Kafka Protobuf Envelope、Raw Envelope。</td>
|
||||
<td>TDengine <code>raw_frames</code> 和位置表。</td>
|
||||
<td><span class="tag green">生产历史</span><span class="tag green">分页查询</span><span class="tag amber">raw 追溯</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/services/event-history-service</td>
|
||||
<td>消费 32960、808、宇通 MQTT 的 Kafka event/raw topic,生产默认写入 TDengine raw_frames 和位置表,并提供 raw 帧、位置历史等分页查询边界;Raw 查询返回完整 parsed JSON,位置历史只保留核心字段并通过 rawUri 关联原始帧;生产消费可使用 <code>tryIngest</code> 和自动装配的 <code>EnvelopeConsumerProcessor</code> 将坏 protobuf、缺快照和存储异常收敛为结构化结果,并通过 <code>EnvelopeDeadLetterSink</code> 发布死信,避免阻塞 Kafka 分区。</td>
|
||||
<td>Kafka Protobuf Envelope。</td>
|
||||
<td>TDengine raw_frames、位置分页结果、raw archive 逻辑引用。</td>
|
||||
<td><span class="tag green">历史明细</span><span class="tag green">分页查询</span><span class="tag amber">raw JSON</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/services/vehicle-state-service</td>
|
||||
<td>消费 Kafka 全字段事件,更新车辆最新状态、位置、安全和最后事件到 Redis;仅通过 <code>optional-latest-state</code> profile 显式构建,不属于默认生产 reactor;生产消费可使用 <code>tryIngest</code> 和自动装配的 <code>EnvelopeConsumerProcessor</code> 隔离坏 protobuf 和缺快照 Envelope,并把失败记录交给死信出口。</td>
|
||||
<td>Kafka Protobuf Envelope。</td>
|
||||
<td><code>vehicle:state:{vin}</code>、<code>vehicle:location:{vin}</code>、<code>vehicle:safety:{vin}</code>。</td>
|
||||
<td><span class="tag green">毫秒级热查询</span><span class="tag red">氢泄露安全</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/services/vehicle-stat-service</td>
|
||||
<td>消费 Kafka 全字段事件,808 每日里程只使用 0x0200 附加项 0x01 上报的总里程做当日首末差值;生产消费可使用 <code>tryIngest</code> 和自动装配的 <code>EnvelopeConsumerProcessor</code> 隔离坏 protobuf,缺少 telemetry_snapshot 的消息会标记为 SKIPPED 并进入死信出口。</td>
|
||||
<td>Kafka Protobuf Envelope、808 <code>total_mileage_km</code>。</td>
|
||||
<td><code>vehicle_stat_metric</code>:<code>metric_key=daily_mileage_km</code>、<code>calculation_method=JT808_TOTAL_MILEAGE_DIFF</code>。</td>
|
||||
<td><span class="tag green">每日里程</span><span class="tag">指标表</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/apps/command-gateway</td>
|
||||
<td>可选 HTTP 到设备下行命令入口。支持会话查询、808 位置查询、参数查询/设置、终端控制、平台通用应答、终端属性查询 0x8107、区域删除 0x8601、人工报警确认 0x8203,以及 1078 音视频属性查询、实时预览/控制、历史回放/控制、资源列表查询、文件上传/控制;通过 CommandDispatcher 复用 protocol-jt808 的在线通道、流水号和同步应答等待能力。</td>
|
||||
<td>业务系统命令请求。</td>
|
||||
<td>CommandDispatcher 调用、设备应答摘要。</td>
|
||||
<td><span class="tag">远程控制</span><span class="tag">参数设置</span><span class="tag">属性查询</span><span class="tag">区域删除</span><span class="tag">报警确认</span><span class="tag">视频控制</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/apps/gb32960-ingest-app</td>
|
||||
<td>生产 GB32960 TCP 接入应用,只负责接收、鉴权、解析、冷存引用和 Kafka 投递。</td>
|
||||
<td>GB/T 32960 TCP 32960。</td>
|
||||
<td><code>vehicle.raw.gb32960.v1</code>、<code>vehicle.event.gb32960.v1</code>。</td>
|
||||
<td><span class="tag green">生产接入</span><span class="tag">32960</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/apps/jt808-ingest-app</td>
|
||||
<td>生产 JT808 TCP 接入应用,只负责 808 注册/鉴权/位置等上行解析、冷存引用和 Kafka 投递。</td>
|
||||
<td>JT/T 808 TCP 808。</td>
|
||||
<td><code>vehicle.raw.jt808.v1</code>、<code>vehicle.event.jt808.v1</code>。</td>
|
||||
<td><span class="tag green">生产接入</span><span class="tag">808</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/apps/yutong-mqtt-app</td>
|
||||
<td>生产宇通 MQTT 接入应用,按 endpoint profile 解析 MQTT 报文并投递 Kafka。</td>
|
||||
<td>宇通 MQTT topic。</td>
|
||||
<td><code>vehicle.raw.mqtt-yutong.v1</code>、<code>vehicle.event.mqtt-yutong.v1</code>。</td>
|
||||
<td><span class="tag green">生产接入</span><span class="tag">MQTT</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/apps/vehicle-history-app</td>
|
||||
<td>生产历史应用,消费 32960、808、宇通 MQTT 的 raw/event Kafka Envelope,写 TDengine 并提供历史查询。</td>
|
||||
<td>Kafka Protobuf Envelope。</td>
|
||||
<td>TDengine <code>raw_frames</code>、位置表和历史查询 API。</td>
|
||||
<td><span class="tag green">历史查询</span><span class="tag amber">raw JSON</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>modules/apps/vehicle-analytics-app</td>
|
||||
<td>生产指标应用,当前只消费 JT808 事件并用 GPS 总里程首末差值写每日里程指标。</td>
|
||||
<td><code>vehicle.event.jt808.v1</code>。</td>
|
||||
<td>MySQL <code>vehicle_stat_metric</code>。</td>
|
||||
<td><span class="tag green">每日里程</span><span class="tag">指标表</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>六、氢能业务开发落点</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>业务主题</th>
|
||||
<th>当前建议落点</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>车辆状态、速度、位置</td>
|
||||
<td><code>RealtimePayload</code>、<code>LocationPayload</code></td>
|
||||
<td>32960 和 808 都可能提供位置。统计侧需要明确优先级,例如优先 32960,缺失时用 808 补点。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>每日里程</td>
|
||||
<td>Kafka 下游统计服务</td>
|
||||
<td>接入层只投递总里程和实时点位;日增里程建议下游按 VIN、自然日、事件时间聚合,处理回补和乱序。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>每日用电量</td>
|
||||
<td>内部电池字段 + 下游统计</td>
|
||||
<td>接入层保留 SOC、电压、电流等瞬时字段;若有累计电耗字段,可加入内部字段模型后统一投递。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>每日用氢量</td>
|
||||
<td><code>hydrogenRemainingKg</code> + 下游统计</td>
|
||||
<td>优先使用累计氢耗字段;没有累计值时用氢余量差值估算,并在统计结果里标记估算口径。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>储氢安全</td>
|
||||
<td><code>RealtimePayload</code> 压力/温度 + <code>AlarmPayload.safetyCategory</code></td>
|
||||
<td>高压、低压、温度、异常 bit 都应统一归入储罐安全域,下游可做趋势、阈值和连续异常判断。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>氢气泄露</td>
|
||||
<td><code>AlarmPayload.hydrogenLeakDetected</code></td>
|
||||
<td>当前已作为高优先级安全事件。32960 中出现 <code>HYDROGEN_LEAK</code> 时强制映射为 <code>CRITICAL</code>。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>原始报文追溯</td>
|
||||
<td><code>RawArchive</code> + <code>rawArchiveUri</code> + <code>sink-archive</code></td>
|
||||
<td>业务统计出现争议时,用事件 metadata 中的 <code>archive://...</code> 逻辑 URI 回查原始 bytes,结合 eventId、traceId、VIN、时间复现解析链路。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>明细展示和导出</td>
|
||||
<td><code>vehicle-history-app</code> + <code>tdengine-history-store</code></td>
|
||||
<td>生产查询通过 TDengine raw_frames、位置表和 rawUri 关联完成;不再维护文件型事件索引旁路。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="callout">
|
||||
<strong>后续开发建议:</strong>
|
||||
当前 Kafka Protobuf 已加入全字段 <code>TelemetrySnapshot</code>。后续业务统计服务继续只依赖内部字段,不依赖 32960 原始字段名,这样接入 808、MQTT、车企私有协议时不会重写统计口径。
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module">
|
||||
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: "base",
|
||||
securityLevel: "strict",
|
||||
flowchart: {
|
||||
htmlLabels: true,
|
||||
curve: "basis"
|
||||
},
|
||||
themeVariables: {
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Microsoft YaHei, sans-serif",
|
||||
primaryColor: "#eff6ff",
|
||||
primaryTextColor: "#172033",
|
||||
primaryBorderColor: "#2563eb",
|
||||
lineColor: "#667085",
|
||||
secondaryColor: "#ecfdf3",
|
||||
tertiaryColor: "#fffbeb"
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,227 +0,0 @@
|
||||
# 当前 ECS Go 原生部署说明
|
||||
|
||||
更新时间:2026-07-02 01:30 CST
|
||||
|
||||
本文档记录当前 `lingniu-vehicle-ingest` 的生产运行面。当前 goal 的接入链路已经切到 Go 原生 systemd 部署,不再以 Docker/Portainer 作为生产运行方式。密钥只维护在 ECS 环境文件或受控凭据中,不写入 Git。
|
||||
|
||||
## 部署范围
|
||||
|
||||
| systemd 服务 | 二进制 | 说明 | 对外端口 |
|
||||
| --- | --- | --- | --- |
|
||||
| `lingniu-go-gateway.service` | `gateway` | GB32960 TCP、JT808 TCP、宇通 MQTT 接入,解析后写 Kafka RAW 和统一事件 | TCP `32960`、TCP `808` |
|
||||
| `lingniu-go-history-writer.service` | `history-writer` | 消费 Kafka RAW,写 TDengine RAW、位置点、里程点核心表 | 无 HTTP |
|
||||
| `lingniu-go-stat-writer.service` | `stat-writer` | 消费 Kafka RAW,按总里程差值法写 MySQL 每日指标 | 无 HTTP |
|
||||
| `lingniu-go-realtime-api.service` | `realtime-api` | 消费统一事件写 Redis,并提供实时、历史、统计查询 API | HTTP `20210` |
|
||||
|
||||
信达 Push 已废弃,不参与当前 Go 生产链路。旧 Java/Docker 服务不应占用 `808`、`32960`、`20210`。
|
||||
|
||||
## 应用 ECS
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| 公网 IP | `115.29.187.205` |
|
||||
| 登录用户 | `root` |
|
||||
| 部署目录 | `/opt/lingniu-go-native` |
|
||||
| 当前 release | `/opt/lingniu-go-native/current` |
|
||||
| 环境文件目录 | `/opt/lingniu-go-native/env` |
|
||||
| systemd unit 目录 | `/etc/systemd/system` |
|
||||
| 对外服务 | `32960`、`808`、`20210` |
|
||||
|
||||
运行目录结构:
|
||||
|
||||
```text
|
||||
/opt/lingniu-go-native/
|
||||
current -> /opt/lingniu-go-native/releases/<git-short-sha>
|
||||
releases/<git-short-sha>/
|
||||
gateway
|
||||
history-writer
|
||||
stat-writer
|
||||
realtime-api
|
||||
env/
|
||||
gateway.env
|
||||
history-writer.env
|
||||
stat-writer.env
|
||||
realtime-api.env
|
||||
spool/gateway/
|
||||
```
|
||||
|
||||
## 中间件
|
||||
|
||||
| 组件 | 内网地址 | 公网地址 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| Kafka | `172.17.111.56:9092` | `114.55.58.251:9092` | RAW 和统一事件消息总线 |
|
||||
| TDengine | `172.17.111.57:6041` | `115.29.185.82:6041` | RAW、位置、里程点时序热存储 |
|
||||
| MySQL RDS | `rm-bp179zbv481rnw3e2.mysql.rds.aliyuncs.com:3306` | `rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com:3306` | 身份映射、JT808 注册、每日指标 |
|
||||
| Redis RDS | `r-bp1u741kij7e51i481.redis.rds.aliyuncs.com:6379` | 无 | 准实时车辆状态缓存 |
|
||||
|
||||
生产应用之间访问中间件优先使用内网地址。RDS 白名单需要允许应用 ECS 私网访问。
|
||||
|
||||
## Kafka Topic
|
||||
|
||||
| Topic | 生产者 | 消费者 | 内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| `vehicle.raw.go.gb32960.v1` | `gateway` | `history-writer`、`stat-writer` | GB32960 完整 RAW 记录 |
|
||||
| `vehicle.raw.go.jt808.v1` | `gateway` | `history-writer`、`stat-writer` | JT808 完整 RAW 记录 |
|
||||
| `vehicle.raw.go.yutong-mqtt.v1` | `gateway` | `history-writer` | 宇通 MQTT 完整 RAW 记录 |
|
||||
| `vehicle.event.go.unified.v1` | `gateway` | `realtime-api` | 三类协议统一事件,用于 Redis 实时状态 |
|
||||
|
||||
`gateway` 配置了 Kafka retry;生产 env 中启用 `KAFKA_SPOOL_DIR` 时,本地 spool 可在 Kafka 短暂不可用后回放。
|
||||
|
||||
Kafka 消费组:
|
||||
|
||||
| Consumer Group | Topic | 当前验证 |
|
||||
| --- | --- | --- |
|
||||
| `go-history-writer` | 三个 `vehicle.raw.go.*` topic | 2026-07-02 验证 lag 为 `0` |
|
||||
| `go-stat-writer` | `vehicle.raw.go.gb32960.v1`、`vehicle.raw.go.jt808.v1` | 2026-07-02 验证 lag 为 `0` |
|
||||
| `go-realtime-api` | `vehicle.event.go.unified.v1` | 2026-07-02 验证 lag 为 `0` |
|
||||
|
||||
可重复 smoke:
|
||||
|
||||
```bash
|
||||
python3 tools/go_kafka_prod_smoke.py --host 114.55.58.251 --user root --max-lag 100
|
||||
```
|
||||
|
||||
该脚本通过 SSH 登录 Kafka ECS,检查 Go 生产 topic 是否存在,并校验 `go-history-writer`、`go-stat-writer`、`go-realtime-api` 的消费组 lag。运行环境需要已配置 SSH 免密或已建立可用的 SSH 认证方式。
|
||||
|
||||
## TDengine 数据层
|
||||
|
||||
默认数据库:`lingniu_vehicle_ts`
|
||||
|
||||
| 表 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `raw_frames` | stable | 完整 RAW 帧,包含 `raw_hex`、`parsed_json`、`fields_json`、`source_endpoint` 和协议/车辆 tags |
|
||||
| `vehicle_locations` | stable | 最小化位置历史,保留 `frame_id` 回链 RAW |
|
||||
| `vehicle_mileage_points` | stable | 最小化总里程点,供历史查询和统计复核 |
|
||||
|
||||
设计原则:
|
||||
|
||||
- 完整结构化 payload 只落在 `raw_frames.parsed_json` / `fields_json`。
|
||||
- `vehicle_locations` 和 `vehicle_mileage_points` 只保留核心查询字段,不重复保存完整 JSON。
|
||||
- `telemetry_fields` 不由当前服务维护,字段配置解析由独立子服务处理。
|
||||
- API 传入东八区时间时,服务会转换为 TDengine 当前存储使用的 UTC 字面量再查询。
|
||||
|
||||
## MySQL 数据层
|
||||
|
||||
默认业务库:`lingniu_vehicle_data`
|
||||
|
||||
| 表 | 维护方 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `vehicle_identity_binding` | 人工/外部主数据 | VIN 映射主表,用 `phone`、`device_id`、`plate` 降级定位 VIN |
|
||||
| `jt808_registration` | `gateway` 自动写入 | 仅 JT808 注册和鉴权记录,以 `phone` 作为主键,记录设备、车牌、厂家、鉴权、来源端点 |
|
||||
| `vehicle_daily_metric` | `stat-writer` 自动写入 | 每日指标表,保存 `daily_mileage_km` 和 `daily_total_mileage_km` |
|
||||
|
||||
每日里程算法:
|
||||
|
||||
```text
|
||||
daily_mileage_km = 当日最大 total_mileage_km - 当日最小 total_mileage_km
|
||||
daily_total_mileage_km = 当日最大 total_mileage_km
|
||||
```
|
||||
|
||||
统计按 `Asia/Shanghai` 自然日归档,当前支持 GB32960、JT808、宇通 MQTT 中能解析出 `total_mileage_km` 的数据。JT808 的总里程来自位置附加信息 `0x01`,单位按协议转换为 km;宇通 MQTT 的 `TOTAL_MILEAGE` 原始单位为米,入库前转换为 km。
|
||||
|
||||
## Redis 实时层
|
||||
|
||||
`realtime-api` 消费 `vehicle.event.go.unified.v1`,按 VIN/vehicle key 维护准实时快照。主要能力:
|
||||
|
||||
| API | 说明 |
|
||||
| --- | --- |
|
||||
| `/api/realtime/vehicles/{vin}` | 查询车辆合并实时快照 |
|
||||
| `/api/realtime/vehicles/{vin}/online` | 查询车辆是否在线 |
|
||||
| `/api/realtime/vehicles/{vin}/protocols/{protocol}` | 查询指定协议的实时快照 |
|
||||
|
||||
Redis 只作为实时缓存;历史和统计以 TDengine/MySQL 为准。
|
||||
|
||||
## API 入口
|
||||
|
||||
公网基础地址:
|
||||
|
||||
```text
|
||||
http://115.29.187.205:20210
|
||||
```
|
||||
|
||||
常用查询:
|
||||
|
||||
```bash
|
||||
curl -sS 'http://115.29.187.205:20210/api/history/raw-frames?protocol=GB32960&dateFrom=2026-07-02T00:00:00%2B08:00&dateTo=2026-07-03T00:00:00%2B08:00&limit=1'
|
||||
curl -sS 'http://115.29.187.205:20210/api/history/raw-frames?protocol=JT808&dateFrom=2026-07-02T00:00:00%2B08:00&dateTo=2026-07-03T00:00:00%2B08:00&limit=1'
|
||||
curl -sS 'http://115.29.187.205:20210/api/history/raw-frames?protocol=YUTONG_MQTT&dateFrom=2026-07-02T00:00:00%2B08:00&dateTo=2026-07-03T00:00:00%2B08:00&limit=1'
|
||||
curl -sS 'http://115.29.187.205:20210/api/history/locations?protocol=JT808&dateFrom=2026-07-02T00:00:00%2B08:00&dateTo=2026-07-03T00:00:00%2B08:00&limit=1'
|
||||
curl -sS 'http://115.29.187.205:20210/api/history/mileage-points?protocol=JT808&dateFrom=2026-07-02T00:00:00%2B08:00&dateTo=2026-07-03T00:00:00%2B08:00&limit=1'
|
||||
curl -sS 'http://115.29.187.205:20210/api/stats/daily-metrics?dateFrom=2026-07-02&dateTo=2026-07-02&limit=20'
|
||||
```
|
||||
|
||||
分页参数统一使用 `limit` 和 `offset`。
|
||||
|
||||
部署后推荐直接运行 Go 原生生产 smoke:
|
||||
|
||||
```bash
|
||||
python3 tools/go_prod_acceptance.py --date 2026-07-02
|
||||
```
|
||||
|
||||
该聚合脚本会串行执行 systemd/端口检查、Kafka topic/consumer lag 检查、HTTP 数据链路检查,任一子检查失败时整体退出码为非 0。需要 SSH 可登录应用 ECS 和 Kafka ECS。
|
||||
|
||||
也可以单独运行子检查:
|
||||
|
||||
```bash
|
||||
python3 tools/go_systemd_prod_smoke.py --host 115.29.187.205 --user root
|
||||
python3 tools/go_native_prod_smoke.py --date 2026-07-02 --timeout 8
|
||||
```
|
||||
|
||||
`go_systemd_prod_smoke.py` 通过 SSH 检查四个 Go systemd 服务是否 `active` 且 `enabled`,并确认 `808`、`32960` 由 `gateway` 监听、`20210` 由 `realtime-api` 监听,同时要求 `/opt/lingniu-go-native/current` 指向有效 release、四个生产二进制均可执行、`/opt/lingniu-go-native/spool/gateway` 没有待回放文件,避免旧 Java/Docker 进程占用生产端口、部署目录错乱或 Kafka spool 静默堆积。运行环境需要已配置 SSH 免密或已建立可用的 SSH 认证方式。
|
||||
|
||||
`go_native_prod_smoke.py` 通过 `20210` HTTP API 验证 GB32960、JT808、宇通 MQTT 的 RAW 查询及结构化 `parsed_json`,三类协议的位置和里程点查询及 `frame_id` RAW 回链,GB32960/JT808 的 `daily_mileage_km`、`daily_total_mileage_km` 统计公式,以及三类协议的 Redis realtime snapshot、online、protocol 查询。实时 snapshot/protocol 查询会校验 `updated_at_ms` 新鲜度;默认检查今天东八区数据时,还会要求三类 RAW 最新样本不超过 15 分钟,避免旧数据误判为接收正常;任一检查不达标时退出码为非 0。
|
||||
|
||||
## 部署命令
|
||||
|
||||
推荐使用仓库脚本发布完整 Go 原生 release。脚本会在本机/CI 构建 Linux amd64 四个二进制,打包上传到应用 ECS,切换 `/opt/lingniu-go-native/current`,逐个重启四个 systemd 服务,等待 gateway Kafka spool 清空,并在发布后运行聚合生产验收:
|
||||
|
||||
```bash
|
||||
python3 tools/go_native_deploy.py --date 2026-07-02
|
||||
```
|
||||
|
||||
脚本不会保存 SSH 或数据库密钥;认证仍使用当前操作环境可用的 SSH 凭据。需要跳过发布后验收时可显式追加 `--skip-acceptance`,但生产发布默认应保留验收。发布后 spool 默认最多等待 `900` 秒,可用 `--spool-drain-timeout` 和 `--spool-poll-interval` 调整。
|
||||
|
||||
脚本执行的发布动作等价于:
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' ./cmd/gateway
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' ./cmd/history-writer
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' ./cmd/stat-writer
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' ./cmd/realtime-api
|
||||
scp release.tar.gz root@115.29.187.205:/tmp/
|
||||
ln -sfn /opt/lingniu-go-native/releases/<git-short-sha> /opt/lingniu-go-native/current
|
||||
systemctl daemon-reload
|
||||
systemctl restart lingniu-go-gateway lingniu-go-history-writer lingniu-go-stat-writer lingniu-go-realtime-api
|
||||
```
|
||||
|
||||
只更新查询 API 时,可以仅替换 `/opt/lingniu-go-native/current/realtime-api` 并重启:
|
||||
|
||||
```bash
|
||||
systemctl restart lingniu-go-realtime-api.service
|
||||
```
|
||||
|
||||
## 运行检查
|
||||
|
||||
```bash
|
||||
python3 tools/go_systemd_prod_smoke.py --host 115.29.187.205 --user root
|
||||
|
||||
systemctl is-active lingniu-go-gateway lingniu-go-history-writer lingniu-go-stat-writer lingniu-go-realtime-api
|
||||
ss -lntp | egrep ':(808|32960|20210)\b'
|
||||
journalctl -u lingniu-go-gateway --since '5 minutes ago' --no-pager
|
||||
journalctl -u lingniu-go-history-writer --since '5 minutes ago' --no-pager
|
||||
journalctl -u lingniu-go-stat-writer --since '5 minutes ago' --no-pager
|
||||
journalctl -u lingniu-go-realtime-api --since '5 minutes ago' --no-pager
|
||||
```
|
||||
|
||||
2026-07-02 01:30 CST 已验证:
|
||||
|
||||
- 四个 Go systemd 服务均为 `active` 且 `enabled`。
|
||||
- `/opt/lingniu-go-native/current` 指向有效 release,四个生产二进制均存在且可执行。
|
||||
- `gateway` 正在监听 `32960` 和 `808`。
|
||||
- `realtime-api` 正在监听 `20210`。
|
||||
- gateway Kafka spool 当前无待回放文件。
|
||||
- GB32960、JT808、YUTONG_MQTT 的东八区 RAW 查询均可命中生产数据,且最新 RAW 样本包含结构化 `parsed_json`。
|
||||
- GB32960、JT808、YUTONG_MQTT 的最新 RAW 样本均在 15 分钟内。
|
||||
- GB32960、JT808、YUTONG_MQTT 的 `vehicle_locations`、`vehicle_mileage_points` 查询均可命中生产数据,且样本包含 `frame_id` 回链 RAW。
|
||||
- `vehicle_daily_metric` 可查到 `daily_mileage_km` 和 `daily_total_mileage_km`,且公式满足 `daily_mileage_km = latest_total_mileage_km - first_total_mileage_km`、`daily_total_mileage_km = latest_total_mileage_km`。
|
||||
- Redis realtime 可查到 GB32960、JT808、YUTONG_MQTT 的在线状态和协议快照,且 snapshot/protocol 快照最近刷新。
|
||||
@@ -1,273 +0,0 @@
|
||||
# GB32960 Split Service Runbook
|
||||
|
||||
This runbook covers the default production runtimes: GB32960 ingest, JT808 ingest, Yutong MQTT ingest, history, and analytics. Kafka is the durability boundary between protocol ingest services and downstream business services.
|
||||
|
||||
## Service Roles
|
||||
|
||||
| Service | Module | Role | Production ports |
|
||||
| --- | --- | --- | --- |
|
||||
| GB32960 ingest | `:gb32960-ingest-app` | Accept GB32960 TCP connections, decode/authenticate frames, produce raw and normalized records to Kafka, and send protocol ACKs after required Kafka production succeeds. | TCP `32960`, HTTP `20100` |
|
||||
| JT808 ingest | `:jt808-ingest-app` | Accept JT/T 808 TCP connections on the production receive port, parse raw frames, archive frame bytes, and produce raw/event envelopes to Kafka. | TCP `808`, HTTP `20400` |
|
||||
| Yutong MQTT ingest | `:yutong-mqtt-app` | Connect to Yutong MQTT, parse subscribed messages, archive payloads, and produce raw/event envelopes to Kafka. | HTTP `20500` |
|
||||
| Vehicle history | `:vehicle-history-app` | Consume GB32960/JT808/Yutong MQTT raw/event Kafka records, store compact history rows plus TDengine `raw_frames` `parsedJson`/`metadataJson`/`rawUri`, and expose TDengine-backed history APIs. | HTTP `20200` |
|
||||
| Vehicle analytics | `:vehicle-analytics-app` | Consume JT808 event Kafka records, calculate daily mileage from reported GPS total mileage, and write metrics to `vehicle_stat_metric`. | HTTP `20310` |
|
||||
|
||||
## Kafka Contract
|
||||
|
||||
| Topic | Producer | Consumers | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `vehicle.raw.gb32960.v1` | `gb32960-ingest-app` | `vehicle-history-app` | Raw frame records. History writes the parsed JSON, metadata JSON, and raw URI to TDengine `raw_frames`. |
|
||||
| `vehicle.event.gb32960.v1` | `gb32960-ingest-app` | `vehicle-history-app` | Normalized vehicle events keyed by VIN when available. |
|
||||
| `vehicle.dlq.gb32960.v1` | Kafka producer/consumer error paths | Operators/replay tooling | Dead-letter records for failed production or consumer processing. |
|
||||
| `vehicle.raw.jt808.v1` | `jt808-ingest-app` | `vehicle-history-app` | JT808 raw frame records for TDengine `raw_frames` and raw-frame troubleshooting APIs. |
|
||||
| `vehicle.event.jt808.v1` | `jt808-ingest-app` | `vehicle-history-app`, `vehicle-analytics-app` | JT808 parsed location/session/alarm events keyed by phone or mapped VIN; analytics uses location total mileage for daily mileage metrics. |
|
||||
| `vehicle.dlq.jt808.v1` | Kafka producer/consumer error paths | Operators/replay tooling | Dead-letter records for failed JT808 production or consumer processing. |
|
||||
| `vehicle.raw.mqtt-yutong.v1` | `yutong-mqtt-app` | `vehicle-history-app` | Yutong MQTT raw payload records for TDengine `raw_frames` and troubleshooting APIs. |
|
||||
| `vehicle.event.mqtt-yutong.v1` | `yutong-mqtt-app` | `vehicle-history-app` | Yutong MQTT parsed telemetry events keyed by mapped VIN or device identity. |
|
||||
| `vehicle.dlq.mqtt-yutong.v1` | Kafka producer/consumer error paths | Operators/replay tooling | Dead-letter records for failed Yutong MQTT production or consumer processing. |
|
||||
| `vehicle.dlq.history.v1` | History app consumer error paths | Operators/replay tooling | Dead-letter records for failed history storage processing. |
|
||||
|
||||
Default consumer groups:
|
||||
|
||||
- History: `vehicle-history`
|
||||
- Analytics statistics: `vehicle-stat`
|
||||
|
||||
## ACK Semantics
|
||||
|
||||
GB32960 success ACKs are sent only after the ingest service completes the required Kafka production boundary for the accepted frame. If required Kafka production fails, the ingest service must not claim a successful ACK for that frame.
|
||||
|
||||
History and analytics failures do not block GB32960 ACKs. They are downstream Kafka consumer failures and should be handled through retry, DLQ inspection, offset replay, and service-specific recovery.
|
||||
|
||||
Authorization failures are rejected before the Kafka durability boundary and can be ACKed/rejected immediately according to protocol handling.
|
||||
|
||||
## Build Prerequisites
|
||||
|
||||
- Java 25 and Maven available on PATH.
|
||||
- Kafka CLI tools such as `kafka-topics` and optionally `kafka-console-consumer` available on the ECS/operator host.
|
||||
- Session state requires Redis. GB32960/JT808 ingest services keep live Netty
|
||||
channels in-process, but session indexes and TTL metadata use Redis only.
|
||||
- Redis is configured through Portainer environment variables such as `REDIS_HOST` and `REDIS_DATABASE=50`; there is no memory session-store fallback.
|
||||
|
||||
If your shell needs an explicit JDK:
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=/opt/homebrew/opt/openjdk@25/libexec/openjdk.jdk/Contents/Home
|
||||
export PATH="$JAVA_HOME/bin:/opt/homebrew/bin:$PATH"
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
mvn -pl :gb32960-ingest-app,:jt808-ingest-app,:yutong-mqtt-app,:vehicle-history-app,:vehicle-analytics-app -am package -Dmaven.test.skip=true
|
||||
```
|
||||
|
||||
Expected result: `BUILD SUCCESS` and these runnable jars:
|
||||
|
||||
- `modules/apps/gb32960-ingest-app/target/gb32960-ingest-app.jar`
|
||||
- `modules/apps/jt808-ingest-app/target/jt808-ingest-app.jar`
|
||||
- `modules/apps/yutong-mqtt-app/target/yutong-mqtt-app.jar`
|
||||
- `modules/apps/vehicle-history-app/target/vehicle-history-app.jar`
|
||||
- `modules/apps/vehicle-analytics-app/target/vehicle-analytics-app.jar`
|
||||
|
||||
## Create Topics
|
||||
|
||||
Run these only on the ECS/operator host after Kafka is reachable. The default bootstrap address is the ECS private Kafka address used by the production stack:
|
||||
|
||||
```bash
|
||||
KAFKA_BOOTSTRAP="${KAFKA_BOOTSTRAP:-172.17.111.56:9092}"
|
||||
kafka-topics --bootstrap-server "$KAFKA_BOOTSTRAP" --create --if-not-exists --topic vehicle.raw.gb32960.v1 --partitions 12 --replication-factor 1
|
||||
kafka-topics --bootstrap-server "$KAFKA_BOOTSTRAP" --create --if-not-exists --topic vehicle.event.gb32960.v1 --partitions 12 --replication-factor 1
|
||||
kafka-topics --bootstrap-server "$KAFKA_BOOTSTRAP" --create --if-not-exists --topic vehicle.dlq.gb32960.v1 --partitions 3 --replication-factor 1
|
||||
kafka-topics --bootstrap-server "$KAFKA_BOOTSTRAP" --create --if-not-exists --topic vehicle.raw.jt808.v1 --partitions 12 --replication-factor 1
|
||||
kafka-topics --bootstrap-server "$KAFKA_BOOTSTRAP" --create --if-not-exists --topic vehicle.event.jt808.v1 --partitions 12 --replication-factor 1
|
||||
kafka-topics --bootstrap-server "$KAFKA_BOOTSTRAP" --create --if-not-exists --topic vehicle.dlq.jt808.v1 --partitions 3 --replication-factor 1
|
||||
kafka-topics --bootstrap-server "$KAFKA_BOOTSTRAP" --create --if-not-exists --topic vehicle.raw.mqtt-yutong.v1 --partitions 12 --replication-factor 1
|
||||
kafka-topics --bootstrap-server "$KAFKA_BOOTSTRAP" --create --if-not-exists --topic vehicle.event.mqtt-yutong.v1 --partitions 12 --replication-factor 1
|
||||
kafka-topics --bootstrap-server "$KAFKA_BOOTSTRAP" --create --if-not-exists --topic vehicle.dlq.mqtt-yutong.v1 --partitions 3 --replication-factor 1
|
||||
kafka-topics --bootstrap-server "$KAFKA_BOOTSTRAP" --create --if-not-exists --topic vehicle.dlq.history.v1 --partitions 3 --replication-factor 1
|
||||
```
|
||||
|
||||
Optional sanity check:
|
||||
|
||||
```bash
|
||||
kafka-topics --bootstrap-server "$KAFKA_BOOTSTRAP" --list | grep -E 'vehicle\.(raw|event|dlq)\.(gb32960|jt808|mqtt-yutong|history)\.v1'
|
||||
```
|
||||
|
||||
## Production Runtime
|
||||
|
||||
Production services run on ECS through Portainer/Docker. Do not start GB32960, JT808, Yutong MQTT, history, or analytics services on local developer machines; local machines are for code build, unit tests, and source inspection only.
|
||||
|
||||
Use `deploy/portainer/docker-compose.yml` as the production runtime manifest. The default stack is:
|
||||
|
||||
- `gb32960-ingest-app`
|
||||
- `jt808-ingest-app`
|
||||
- `yutong-mqtt-app`
|
||||
- `vehicle-history-app`
|
||||
- `vehicle-analytics-app`
|
||||
|
||||
`vehicle-history-app` intentionally has no raw archive volume. The history hot path is Kafka to TDengine: raw envelopes are stored in TDengine `raw_frames` with `parsedJson`, `metadataJson`, and `rawUri`. Ingest services may still write original `.bin` files as cold backup, but history API queries must not depend on a shared local archive mount.
|
||||
|
||||
Keep `TDENGINE_HISTORY_ENABLED=true` in the `vehicle-history-app` environment so history reads and writes use TDengine in production.
|
||||
|
||||
`vehicle-analytics-app` is intentionally a metric runtime only. Latest-state APIs belong to `vehicle-state-service`, which stays outside the default reactor and should be built with `-Poptional-latest-state` and deployed as a separate consumer if that surface is needed.
|
||||
|
||||
## Health Verification
|
||||
|
||||
Run these from the ECS host or inside the matching container:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:20100/actuator/health
|
||||
curl -sS http://127.0.0.1:20100/actuator/health/liveness
|
||||
curl -sS http://127.0.0.1:20100/actuator/health/readiness
|
||||
curl -sS http://127.0.0.1:20400/actuator/health
|
||||
curl -sS http://127.0.0.1:20400/actuator/health/liveness
|
||||
curl -sS http://127.0.0.1:20400/actuator/health/readiness
|
||||
curl -sS http://127.0.0.1:20500/actuator/health
|
||||
curl -sS http://127.0.0.1:20500/actuator/health/liveness
|
||||
curl -sS http://127.0.0.1:20500/actuator/health/readiness
|
||||
curl -sS http://127.0.0.1:20200/actuator/health
|
||||
curl -sS http://127.0.0.1:20200/actuator/health/liveness
|
||||
curl -sS http://127.0.0.1:20200/actuator/health/readiness
|
||||
curl -sS http://127.0.0.1:20310/actuator/health
|
||||
curl -sS http://127.0.0.1:20310/actuator/health/liveness
|
||||
curl -sS http://127.0.0.1:20310/actuator/health/readiness
|
||||
```
|
||||
|
||||
Expected: each aggregate, liveness, and readiness endpoint returns `{"status":"UP"}` or an equivalent Spring Boot health JSON with top-level status `UP`.
|
||||
|
||||
## End-to-End Verification
|
||||
|
||||
Use a known-valid GB32960 fixture, for example one of the hex samples under `modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/`.
|
||||
|
||||
Example send command:
|
||||
|
||||
```bash
|
||||
xxd -r -p modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_001.hex | nc 127.0.0.1 32960
|
||||
```
|
||||
|
||||
To capture and inspect the binary ACK, write the response to a file:
|
||||
|
||||
```bash
|
||||
rm -f /tmp/gb32960-ack.bin
|
||||
xxd -r -p modules/protocols/protocol-gb32960/src/test/resources/samples/gb32960/realtime_001.hex \
|
||||
| nc -w 3 127.0.0.1 32960 > /tmp/gb32960-ack.bin
|
||||
xxd -p /tmp/gb32960-ack.bin
|
||||
```
|
||||
|
||||
Expected: the ACK file is non-empty and the decoded bytes begin with the GB32960 frame marker `2323`. Treat the ACK as verified only after checking the response bytes from your run.
|
||||
|
||||
Verify only what was actually run in your environment:
|
||||
|
||||
```bash
|
||||
kafka-console-consumer --bootstrap-server "$KAFKA_BOOTSTRAP" --topic vehicle.raw.gb32960.v1 --from-beginning --max-messages 1 --timeout-ms 10000
|
||||
kafka-console-consumer --bootstrap-server "$KAFKA_BOOTSTRAP" --topic vehicle.event.gb32960.v1 --from-beginning --max-messages 1 --timeout-ms 10000
|
||||
kafka-console-consumer --bootstrap-server "$KAFKA_BOOTSTRAP" --topic vehicle.raw.mqtt-yutong.v1 --from-beginning --max-messages 1 --timeout-ms 10000
|
||||
kafka-console-consumer --bootstrap-server "$KAFKA_BOOTSTRAP" --topic vehicle.event.mqtt-yutong.v1 --from-beginning --max-messages 1 --timeout-ms 10000
|
||||
```
|
||||
|
||||
Use TDengine CLI or a JDBC client to verify history writes:
|
||||
|
||||
```sql
|
||||
USE vehicle_ts;
|
||||
SELECT COUNT(*) FROM raw_frames WHERE protocol = 'GB32960';
|
||||
SELECT COUNT(*) FROM vehicle_locations WHERE protocol = 'GB32960';
|
||||
SELECT COUNT(*) FROM raw_frames WHERE protocol = 'JT808';
|
||||
SELECT COUNT(*) FROM jt808_locations WHERE protocol = 'JT808';
|
||||
SELECT COUNT(*) FROM raw_frames WHERE protocol = 'MQTT_YUTONG';
|
||||
SELECT COUNT(*) FROM vehicle_locations WHERE protocol = 'MQTT_YUTONG';
|
||||
```
|
||||
|
||||
Useful HTTP query checks after events are consumed. For `realtime_001.hex`, the fixture VIN is `LTEST000000000001`; replace `<date-from>`, `<date-to>`, and `<stat-date>` with values that cover the consumed record's event time:
|
||||
|
||||
```bash
|
||||
curl -sS 'http://127.0.0.1:20200/api/event-history/locations?protocol=GB32960&dateFrom=<date-from>&dateTo=<date-to>&vin=LTEST000000000001&limit=10'
|
||||
curl -sS 'http://127.0.0.1:20200/api/event-history/raw-frames?protocol=GB32960&dateFrom=<date-from>&dateTo=<date-to>&vin=LTEST000000000001&limit=10'
|
||||
curl -sS 'http://127.0.0.1:20200/api/event-history/gb32960/dictionary'
|
||||
curl -sS 'http://127.0.0.1:20310/api/vehicle-stat/LTEST000000000001/daily?date=<stat-date>'
|
||||
```
|
||||
|
||||
Expected E2E result when Kafka and all services are running:
|
||||
|
||||
- A GB32960 client receives a binary success ACK only after required Kafka production succeeds, and the captured ACK bytes are inspected.
|
||||
- `vehicle.raw.gb32960.v1` receives a raw frame envelope.
|
||||
- `vehicle.event.gb32960.v1` receives one or more normalized records.
|
||||
- TDengine `raw_frames` receives GB32960/JT808 RAW rows with parsed JSON and metadata.
|
||||
- TDengine `raw_frames` receives Yutong MQTT RAW rows with parsed JSON and metadata when MQTT payloads are consumed.
|
||||
- TDengine location tables receive compact GB32960/JT808/Yutong MQTT location rows when applicable telemetry is present.
|
||||
- JT808 analytics writes daily mileage rows to MySQL `vehicle_stat_metric` after consuming applicable `vehicle.event.jt808.v1` records.
|
||||
|
||||
Do not expect `vehicle-history-app` to create raw `.bin` archive files from Kafka raw records in the current implementation. `RawArchiveEventSink` can write archive files only when it receives `VehicleEvent.RawArchive.rawBytes()` inside the same JVM; the Kafka envelope carries only `RawArchiveRef` metadata.
|
||||
|
||||
Do not mark any of these as verified unless the matching command was run and the output was inspected.
|
||||
|
||||
## Runtime Location Policy
|
||||
|
||||
Production services run on ECS through Portainer/Docker.
|
||||
|
||||
- Do not create local service runners for GB32960, JT808, Yutong MQTT, history, or analytics.
|
||||
- Do not run the service jars directly on developer machines for production verification.
|
||||
- Keep local work limited to source edits, builds, and automated tests.
|
||||
|
||||
For Portainer deployments, keep history without an archive volume: history consumes raw/event Kafka topics and writes TDengine history rows.
|
||||
|
||||
## Rollback Guidance
|
||||
|
||||
If the split deployment is unhealthy:
|
||||
|
||||
1. Stop `gb32960-ingest-app` first so new GB32960 ACKs are not issued against an unhealthy Kafka boundary.
|
||||
2. Keep Kafka topics intact for replay unless storage or privacy policy requires deletion.
|
||||
3. Restart or roll back `vehicle-history-app` and `vehicle-analytics-app` independently; their failures do not require protocol ACK rollback because they consume from Kafka offsets.
|
||||
4. If Kafka itself is unavailable, stop `gb32960-ingest-app` or route GB32960 traffic to a previously verified release that preserves the Kafka durability boundary.
|
||||
5. After rollback, compare Kafka consumer group lag and DLQ contents before resuming the split services.
|
||||
|
||||
Rollback commands depend on the deployment environment. For the default production stack, roll back the Portainer stack image version and let Docker restart the affected containers.
|
||||
|
||||
## Task 8 Verification Notes
|
||||
|
||||
Observed on 2026-06-23 in worktree `.worktrees/gb32960-service-split`:
|
||||
|
||||
- Package build should use the current active-app command shown in the Build section.
|
||||
- Repository-local Kafka setup inspection found no Kafka script, no Docker Compose file, and no compose YAML within the searched repository paths.
|
||||
- `nc -z -w 2 127.0.0.1 9092` exited `1`, so no local Kafka broker was reachable at `127.0.0.1:9092`.
|
||||
- `kafka-topics`, `kafka-topics.sh`, `docker`, and `docker-compose` were not found on PATH.
|
||||
- Kafka topic creation, service startup, health checks, Kafka record checks, archive checks, TDengine checks, stat output checks, and ACK observation were not run because the local Kafka prerequisite was absent.
|
||||
|
||||
## Latest Build Verification
|
||||
|
||||
Observed on 2026-06-23 in worktree `.worktrees/gb32960-service-split`:
|
||||
|
||||
- Targeted module tests: `mvn -pl :sink-kafka,:ingest-core,:protocol-gb32960,:event-history-service,:vehicle-state-service,:vehicle-stat-service test` ended with `BUILD SUCCESS`; Maven reported 55 protocol/sink/core tests, 30 event-history tests, 14 vehicle-state tests, and 19 vehicle-stat tests with 0 failures/errors/skips.
|
||||
- Split app tests should cover the current active apps listed in the Service Roles section.
|
||||
- Package verification should use the active-app package command from the Build section.
|
||||
- Split app startup: not run in this verification pass because an ECS/Portainer runtime was outside that build-only check.
|
||||
- Kafka raw records: not verified in that build-only check.
|
||||
- Kafka event records: not verified in that build-only check.
|
||||
- TDengine history rows: not verified; downstream services were not started without Kafka.
|
||||
- Analytics output: not verified; downstream services were not started without Kafka.
|
||||
- ACK behavior: not verified against a live broker in this pass. Unit tests cover the GB32960 ACK boundary and ordering, but an operator should still run the ACK capture command above on ECS or staging.
|
||||
|
||||
## Remote Kafka Live Verification
|
||||
|
||||
Observed on 2026-06-23 with Kafka `114.55.58.251:9092`:
|
||||
|
||||
- `gb32960-ingest-app` handled TCP `32960` and HTTP `20100`.
|
||||
- Ingest app health check `curl -sS http://127.0.0.1:20100/actuator/health` returned `{"status":"UP"}`.
|
||||
- Kafka AdminClient confirmed topics `vehicle.raw.gb32960.v1` and `vehicle.event.gb32960.v1` existed, and created `vehicle.dlq.gb32960.v1`.
|
||||
- The app accepted the live Hyundai platform connection from `8.134.95.166`, authenticated platform login, and received real GB32960 realtime/report/login/logout frames.
|
||||
- Probe consumer with a temporary group read records from `vehicle.event.gb32960.v1`, including VIN keys such as `LB9A32A2XR0LS1575` and `LNXNEGRR3SR319485`.
|
||||
- Probe consumer with a temporary group read records from `vehicle.raw.gb32960.v1`, including VIN keys such as `LNXNEGRR0SR319444`, `LNXNEGRR2SR318201`, and `LB9A32A23R0LS1532`.
|
||||
- Runtime logs showed successful GB32960 ACK flushes for platform login, vehicle login, and vehicle logout after the Kafka-backed split ingest service was running.
|
||||
|
||||
This verifies the live path `GB32960 TCP 32960 -> gb32960-ingest-app -> Kafka raw/event topics` against the remote Kafka broker. Downstream `vehicle-history-app` and `vehicle-analytics-app` were not started in this pass.
|
||||
|
||||
## 2026-06-29 Superseded Local Verification Record
|
||||
|
||||
Observed on 2026-06-29 in worktree `.worktrees/vehicle-ingest-redesign-phase1`. This record is retained only as historical evidence of parser/storage behavior; do not repeat it as a deployment procedure because production services now run on ECS through Portainer/Docker.
|
||||
|
||||
- External JT808 traffic on TCP `808` was parsed and written through Kafka into TDengine. Direct TDengine checks showed more than `6000` JT808 `raw_frames` and more than `1900` JT808 `vehicle_locations`; current production history does not maintain a `telemetry_fields` table.
|
||||
- A GB32960 synthetic realtime frame sent to TCP `32960` received a binary ACK beginning with `2323`. The same VIN `LTEST202606290001` was queryable from TDengine-backed history APIs with speed, mileage, longitude, and latitude fields.
|
||||
- History raw and event Kafka consumption used separate bindings so raw-frame writes remained current while location/field event ingestion continued.
|
||||
|
||||
The verified behavior was `GB32960/JT808 TCP -> protocol app -> Kafka raw/event topics -> vehicle-history-app -> TDengine raw_frames/location tables -> Swagger/API query`; the deployment method from that historical run is superseded.
|
||||
@@ -1,888 +0,0 @@
|
||||
# Go Vehicle Gateway 旁路验证记录
|
||||
|
||||
验证时间:2026-07-01 22:00-22:10 CST
|
||||
|
||||
## 部署版本
|
||||
|
||||
- Git commit:`60ca42e`
|
||||
- 镜像:`crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com/oneos/vehicle-gateway-go:go-60ca42e-20260701220622`
|
||||
- 部署方式:ECS `115.29.187.205` 上 Docker Compose 旁路部署
|
||||
- Compose 目录:`/opt/lingniu-go`
|
||||
|
||||
## 旁路端口和 topic
|
||||
|
||||
为了不影响现有 Java 生产服务,本次没有占用生产端口和生产 topic。
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| GB32960 旁路 TCP | `115.29.187.205:23296 -> container :32960` |
|
||||
| JT808 旁路 TCP | `115.29.187.205:18080 -> container :808` |
|
||||
| Realtime API | `115.29.187.205:20210` |
|
||||
| GB32960 RAW topic | `vehicle.raw.go.gb32960.v1` |
|
||||
| JT808 RAW topic | `vehicle.raw.go.jt808.v1` |
|
||||
| Yutong MQTT RAW topic | `vehicle.raw.go.yutong-mqtt.v1` |
|
||||
| Unified topic | `vehicle.event.go.unified.v1` |
|
||||
|
||||
## 容器状态
|
||||
|
||||
ECS 上四个 Go 容器均已启动:
|
||||
|
||||
```text
|
||||
go-vehicle-gateway Up
|
||||
go-history-writer Up
|
||||
go-stat-writer Up
|
||||
go-realtime-api Up
|
||||
```
|
||||
|
||||
## Kafka 验证
|
||||
|
||||
向 `18080` 注入 JT808 样例帧后,`vehicle.raw.go.jt808.v1` 和
|
||||
`vehicle.event.go.unified.v1` 均消费到统一 JSON envelope。
|
||||
|
||||
样例解析结果包含:
|
||||
|
||||
- `protocol=JT808`
|
||||
- `message_id=0x0200`
|
||||
- `phone=013307795425`
|
||||
- `longitude=121.069881`
|
||||
- `latitude=30.590151`
|
||||
- `speed_kmh=23`
|
||||
- `total_mileage_km=10241.2`
|
||||
- 表 27 附加项 `0x01` 原始值 `0001900C`
|
||||
|
||||
## TDengine 验证
|
||||
|
||||
目标库:`lingniu_vehicle_ts`
|
||||
|
||||
```text
|
||||
raw_frames count = 3
|
||||
vehicle_locations count = 2
|
||||
vehicle_mileage_points count = 2
|
||||
```
|
||||
|
||||
最新 RAW 标识:
|
||||
|
||||
```text
|
||||
GB32960 | LNBSCB3D4R1234567 | LNBSCB3D4R1234567 |
|
||||
JT808 | JT808:013307795425 | | 013307795425
|
||||
JT808 | JT808:013307795425 | | 013307795425
|
||||
```
|
||||
|
||||
说明:JT808 样例 phone 当前没有在 `vehicle_identity_binding` 中解析到 VIN,因此
|
||||
TDengine tag 使用 `vehicle_key=JT808:013307795425`。
|
||||
|
||||
## Redis 验证
|
||||
|
||||
向 `23296` 注入带 VIN 的 GB32960 合成帧后,Realtime API 可查询:
|
||||
|
||||
```text
|
||||
GET /api/realtime/vehicles/LNBSCB3D4R1234567
|
||||
```
|
||||
|
||||
返回字段包含:
|
||||
|
||||
- `vin=LNBSCB3D4R1234567`
|
||||
- `protocols=["GB32960"]`
|
||||
- `online=true`
|
||||
- `speed_kmh=30`
|
||||
- `total_mileage_km=10000`
|
||||
- `soc_percent=85`
|
||||
- `longitude=121`
|
||||
- `latitude=30.56`
|
||||
|
||||
## MySQL 统计验证
|
||||
|
||||
向 `23296` 注入带 VIN 的 GB32960 合成帧后,`vehicle_daily_metric` 写入:
|
||||
|
||||
```text
|
||||
LNBSCB3D4R1234567 | 2020-07-01 | GB32960 | daily_mileage_km | 0.000 | 10000.000 | 10000.000 | 1
|
||||
LNBSCB3D4R1234567 | 2020-07-01 | GB32960 | daily_total_mileage_km | 10000.000 | 10000.000 | 10000.000 | 1
|
||||
```
|
||||
|
||||
说明:本次 GB32960 合成帧的设备时间为测试值,因此统计日期为 `2020-07-01`。
|
||||
|
||||
## 已修复问题
|
||||
|
||||
- TDengine WebSocket 普通 `INSERT` 使用参数占位符时,字符串未按预期加引号,导致语法错误。已在 `60ca42e` 中改为显式 SQL literal 转义。
|
||||
- MySQL Go DSN 中 `charset=utf8mb4%2Cutf8` 会被 driver 传给 MySQL,导致语法错误;ECS 旁路 env 已改为 `charset=utf8mb4`。
|
||||
- TDengine ECS 当前可用 root 密码与早先记录不一致,本次旁路 env 已按实际可认证值修正。
|
||||
|
||||
## 未完成
|
||||
|
||||
- 未切换真实生产端口 `32960/808`。
|
||||
- 未将真实外部 32960/808 流量转发到 Go 旁路端口。
|
||||
- 未开启宇通 MQTT 正式订阅。
|
||||
- JT808 样例未解析到 VIN,需维护 `vehicle_identity_binding` 后再验证 808 每日统计。
|
||||
|
||||
## 2026-07-01 22:22 真实归档帧复验
|
||||
|
||||
部署版本已更新:
|
||||
|
||||
- Git commit:`5f4f4fd`
|
||||
- 镜像:`crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com/oneos/vehicle-gateway-go:go-5f4f4fd-20260701222128`
|
||||
|
||||
本轮补充验证内容:
|
||||
|
||||
- JT808 支持 2019 版 versioned header,真实帧 `0x0200` 可解析出 `phone=14894135060`、`longitude=117.178187`、`latitude=30.570155`、`speed_kmh=33.4`、`total_mileage_km=7868.9`。
|
||||
- GB32960 真实燃料电池帧 `VIN=LB9A32A21R0LS1707` 可解析 `0x01` 整车、`0x02` 驱动电机、`0x03` 燃料电池、`0x04` 发动机、`0x05` 位置、`0x06` 极值、`0x07` 报警、`0x08` 电压、`0x09` 温度;后续 `0x30` 广东燃料电池扩展暂以 unknown raw 保留。
|
||||
- GB32960 真实帧字段包含 `fuel_cell_hydrogen_consumption_kg_per_100km=1.8`、`longitude=120.800326`、`latitude=31.634907`、`total_mileage_km=53490.9`。
|
||||
- Realtime API `GET /api/realtime/vehicles/LB9A32A21R0LS1707` 已返回上述经纬度和氢耗字段。
|
||||
- TDengine `vehicle_locations` 和 `vehicle_mileage_points` 已写入真实 GB32960 位置和里程点:
|
||||
|
||||
```text
|
||||
2020-07-01 08:09:22 | GB32960 | LB9A32A21R0LS1707 | lon=120.800326 | lat=31.634907 | mileage=53490.9
|
||||
```
|
||||
|
||||
后续仍需补齐:
|
||||
|
||||
- JT808 真实 phone `14894135060` 仍未解析到 VIN,需维护身份绑定后再验证 VIN 维度实时和统计。
|
||||
|
||||
## 2026-07-01 22:28 广东燃料电池扩展复验
|
||||
|
||||
部署版本已更新:
|
||||
|
||||
- Git commit:`a15de91`
|
||||
- 镜像:`crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com/oneos/vehicle-gateway-go:go-a15de91-20260701222743`
|
||||
|
||||
本轮补充验证内容:
|
||||
|
||||
- GB32960 真实燃料电池帧 `VIN=LB9A32A21R0LS1707` 的 vendor 尾部已从 `unknown_unit` 补为结构化块:
|
||||
`0x30 gd_fc_stack`、`0x31 gd_fc_auxiliary`、`0x32 gd_fc_dcdc`、`0x33 gd_fc_air_conditioner`、
|
||||
`0x34 gd_fc_vehicle_info`、`0x80 gd_fc_demo_extension`、`0x83 gd_fc_vendor_tlv`。
|
||||
- Kafka `vehicle.raw.go.gb32960.v1` 最新消息包含上述 block,并输出关键扁平字段:
|
||||
`gd_fc_stack_hydrogen_inlet_pressure_kpa=97`、`gd_fc_stack_cell_count=108`、
|
||||
`gd_fc_vehicle_hydrogen_mass_kg=4.3`、`gd_fc_demo_stack_temp_c=65`。
|
||||
- Realtime API `GET /api/realtime/vehicles/LB9A32A21R0LS1707` 已返回 vendor 扁平字段:
|
||||
|
||||
```text
|
||||
gd_fc_dcdc_controller_temp_c=47
|
||||
gd_fc_demo_stack_temp_c=65
|
||||
gd_fc_stack_cell_count=108
|
||||
gd_fc_stack_frame_cell_count=108
|
||||
gd_fc_stack_hydrogen_inlet_pressure_kpa=97
|
||||
gd_fc_stack_water_outlet_temp_c=65
|
||||
gd_fc_vehicle_hydrogen_mass_kg=4.3
|
||||
```
|
||||
|
||||
- TDengine `raw_frames` 中 GB32960 行数增加到 `4`,最新 RAW 标识:
|
||||
|
||||
```text
|
||||
2026-07-01 14:28:24.869 | 172.20.0.1:47082 | GB32960 | LB9A32A21R0LS1707 | OK
|
||||
```
|
||||
|
||||
后续仍需补齐:
|
||||
|
||||
- JT808 真实 phone `14894135060` 到 VIN 的绑定数据维护与统计复验。
|
||||
- Go 旁路尚未切换到生产 `32960/808` 端口;仍运行在 `23296/18080`。
|
||||
|
||||
## 2026-07-01 22:43 JT808 前导零 phone 身份解析复验
|
||||
|
||||
部署版本已更新:
|
||||
|
||||
- Git commit:`6b55694`
|
||||
- 镜像:`crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com/oneos/vehicle-gateway-go:go-6b55694-20260701224300`
|
||||
|
||||
本轮修复内容:
|
||||
|
||||
- JT808 2011 老版包头的终端手机号按 BCD[6] 解析会保留前导 `0`,例如包头为 `013079963379`。
|
||||
- `vehicle_identity_binding.phone` 中实际维护的是去前导零后的手机号 `13079963379`。
|
||||
- 身份解析候选键已调整为先查原始 phone,再查去前导零 phone,之后再按 `device_id`、`plate` 降级。
|
||||
|
||||
真实帧复验样本:
|
||||
|
||||
- 归档帧:`/opt/lingniu/vehicle-ingest/gb32960/archive/2026/07/01/JT808/unknown/1782903942845000.bin`
|
||||
- 消息:`0x0200` 位置信息汇报
|
||||
- 包头 phone:`013079963379`
|
||||
- 绑定表 phone:`13079963379`
|
||||
- 解析 VIN:`LKLG7C4E3NA774736`
|
||||
|
||||
Kafka `vehicle.raw.go.jt808.v1` 最新消息确认:
|
||||
|
||||
```text
|
||||
vin=LKLG7C4E3NA774736
|
||||
phone=013079963379
|
||||
parsed.header.phone_trim_zero=13079963379
|
||||
parsed.identity.resolved=true
|
||||
parsed.identity.source=phone
|
||||
parsed.identity.value=13079963379
|
||||
```
|
||||
|
||||
Realtime API 确认:
|
||||
|
||||
```text
|
||||
GET /api/realtime/vehicles/LKLG7C4E3NA774736
|
||||
|
||||
longitude=119.557997
|
||||
latitude=29.049092
|
||||
speed_kmh=0
|
||||
total_mileage_km=0
|
||||
device_time=2026-07-01T19:05:40+08:00
|
||||
```
|
||||
|
||||
TDengine 确认:
|
||||
|
||||
```text
|
||||
raw_frames:
|
||||
2026-07-01 14:49:10.913 | JT808 | LKLG7C4E3NA774736 | phone=013079963379 | message_id=512
|
||||
|
||||
vehicle_locations:
|
||||
2026-07-01 11:05:40.000 | JT808 | LKLG7C4E3NA774736 | lon=119.557997 | lat=29.049092 | speed=0 | mileage=0
|
||||
|
||||
vehicle_mileage_points:
|
||||
2026-07-01 11:05:40.000 | JT808 | LKLG7C4E3NA774736 | mileage=0
|
||||
```
|
||||
|
||||
MySQL `vehicle_daily_metric` 确认:
|
||||
|
||||
```text
|
||||
LKLG7C4E3NA774736 | JT808 | 2026-07-01 | daily_mileage_km | 0.000 | sample_count=1 | TOTAL_MILEAGE_DIFF
|
||||
LKLG7C4E3NA774736 | JT808 | 2026-07-01 | daily_total_mileage_km | 0.000 | sample_count=1 | TOTAL_MILEAGE_DIFF
|
||||
```
|
||||
|
||||
说明:该真实位置帧携带的表 27 附加项 `0x01` 总里程值为 `0`,因此本次只验证身份解析、链路落地和差值统计写入;要验证非零日里程,需要回放或等待同一 VIN 的非零总里程连续位置点。
|
||||
|
||||
## 2026-07-01 22:55 Kafka 发布重试部署复验
|
||||
|
||||
部署版本已更新:
|
||||
|
||||
- Git commit:`7e7066d`
|
||||
- 镜像:`crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com/oneos/vehicle-gateway-go:go-7e7066d-20260701225503`
|
||||
|
||||
本轮修复内容:
|
||||
|
||||
- gateway 的 Kafka 发布增加同步重试封装。
|
||||
- 默认配置:
|
||||
- `KAFKA_PUBLISH_ATTEMPTS=3`
|
||||
- `KAFKA_PUBLISH_BACKOFF_MS=100`
|
||||
- `KAFKA_PUBLISH_TIMEOUT_MS=3000`
|
||||
- RAW 仍先于 unified event 写入;RAW 写失败不会继续写 unified。
|
||||
- 单元测试覆盖首次失败后成功、重试耗尽返回错误、上下文取消时停止重试。
|
||||
|
||||
部署后容器状态:
|
||||
|
||||
```text
|
||||
go-vehicle-gateway | go-7e7066d-20260701225503 | Up
|
||||
go-history-writer | go-7e7066d-20260701225503 | Up
|
||||
go-stat-writer | go-7e7066d-20260701225503 | Up
|
||||
go-realtime-api | go-7e7066d-20260701225503 | Up
|
||||
```
|
||||
|
||||
真实 808 帧回放复验:
|
||||
|
||||
```text
|
||||
source_endpoint=172.20.0.1:51966
|
||||
protocol=JT808
|
||||
message_id=0x0200
|
||||
phone=013079963379
|
||||
vin=LKLG7C4E3NA774736
|
||||
parsed.identity.value=13079963379
|
||||
```
|
||||
|
||||
Kafka `vehicle.raw.go.jt808.v1` 确认新消息写入,partition 6 offset 从 `1` 增加到 `2`。最新消息包含:
|
||||
|
||||
```text
|
||||
received_at_ms=1782917751903
|
||||
source_endpoint=172.20.0.1:51966
|
||||
vin=LKLG7C4E3NA774736
|
||||
fields.longitude=119.557997
|
||||
fields.latitude=29.049092
|
||||
fields.total_mileage_km=0
|
||||
```
|
||||
|
||||
TDengine `raw_frames` 确认新 RAW 落地:
|
||||
|
||||
```text
|
||||
2026-07-01 14:55:51.903 | JT808 | LKLG7C4E3NA774736 | phone=013079963379 | source=172.20.0.1:51966
|
||||
```
|
||||
|
||||
Realtime API 确认 Redis 已刷新:
|
||||
|
||||
```text
|
||||
GET /api/realtime/vehicles/LKLG7C4E3NA774736
|
||||
|
||||
received_at_ms=1782917751903
|
||||
source_endpoint=172.20.0.1:51966
|
||||
longitude=119.557997
|
||||
latitude=29.049092
|
||||
total_mileage_km=0
|
||||
```
|
||||
|
||||
说明:当前重试层只能覆盖 Kafka 短暂抖动;如果 Kafka 长时间不可用,仍需后续增加本地磁盘 spool/WAL 和恢复补发。
|
||||
|
||||
## 2026-07-01 23:02 切换 Go 到生产 32960/808 端口
|
||||
|
||||
本轮操作:
|
||||
|
||||
- 停止 ECS 上原有 Java Docker 容器:
|
||||
- `gb32960-ingest-app`
|
||||
- `jt808-ingest-app`
|
||||
- `yutong-mqtt-app`
|
||||
- `vehicle-history-app`
|
||||
- `vehicle-analytics-app`
|
||||
- `vehicle-state-app`
|
||||
- `telemetry-field-parser-app`
|
||||
- 保留 Go sidecar 和 Portainer agent。
|
||||
- 修改 `/opt/lingniu-go/.env`:
|
||||
- `GO_GB32960_TCP_PORT=32960`
|
||||
- `GO_JT808_TCP_PORT=808`
|
||||
- Go gateway 使用镜像:
|
||||
- `crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com/oneos/vehicle-gateway-go:go-243de5c-20260701230028`
|
||||
- 开启 Kafka 本地 spool:
|
||||
- host:`/opt/lingniu-go/spool/gateway`
|
||||
- container:`/data/spool/gateway`
|
||||
|
||||
端口验证:
|
||||
|
||||
```text
|
||||
0.0.0.0:32960 -> go-vehicle-gateway:32960
|
||||
0.0.0.0:808 -> go-vehicle-gateway:808
|
||||
18080/23296 -> 已释放
|
||||
```
|
||||
|
||||
启动日志确认:
|
||||
|
||||
```text
|
||||
kafka durable spool enabled dir=/data/spool/gateway replay_interval_ms=1000
|
||||
tcp listener started protocol=GB32960 addr=[::]:32960
|
||||
tcp listener started protocol=JT808 addr=[::]:808
|
||||
```
|
||||
|
||||
真实连接确认:
|
||||
|
||||
```text
|
||||
GB32960 external remote: 8.134.95.166:50084
|
||||
JT808 external remote: 115.231.168.135:13801
|
||||
```
|
||||
|
||||
生产 `808` 回放复验:
|
||||
|
||||
```text
|
||||
source_endpoint=172.20.0.1:54680
|
||||
vin=LKLG7C4E3NA774736
|
||||
phone=013079963379
|
||||
longitude=119.557997
|
||||
latitude=29.049092
|
||||
total_mileage_km=0
|
||||
```
|
||||
|
||||
TDengine 最新 RAW 复验显示生产 808 已有真实外部数据持续落地:
|
||||
|
||||
```text
|
||||
2026-07-01 15:03:02.960 | JT808 | LKLG7C4E1NA774802 | 013079963291 | 222.66.200.68:28394
|
||||
2026-07-01 15:03:02.114 | JT808 | | 14894135583 | 122.152.221.156:33871
|
||||
2026-07-01 15:03:01.352 | JT808 | | 013307795519 | 115.231.168.135:13801
|
||||
2026-07-01 15:03:01.330 | JT808 | LKLG7C4E3NA774736 | 013079963379 | 222.66.200.68:28393
|
||||
```
|
||||
|
||||
Kafka spool 目录确认:
|
||||
|
||||
```text
|
||||
/opt/lingniu-go/spool/gateway file count = 0
|
||||
```
|
||||
|
||||
说明:生产 808 中仍有部分 phone 未解析出 VIN,后续需要继续维护 `vehicle_identity_binding` 的 phone/device_id/plate 到 VIN 映射,或者增强 808 注册/鉴权帧中的 identity 回写。
|
||||
|
||||
## 2026-07-01 23:09 808 里程统计防 0 污染复验
|
||||
|
||||
部署版本已更新:
|
||||
|
||||
- Git commit:`d347525`
|
||||
- 镜像:`crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com/oneos/vehicle-gateway-go:go-d347525-20260701230845`
|
||||
|
||||
本轮修复内容:
|
||||
|
||||
- `stat-writer` 不再把 `total_mileage_km <= 0` 的采样写入 MySQL 每日指标。
|
||||
- MySQL upsert 对历史 `first_total_mileage_km=0` / `latest_total_mileage_km=0` 做纠偏;后续首次有效总里程会替换掉历史 0。
|
||||
- `realtime-api` 合并实时快照时,非正 `total_mileage_km` 不再覆盖已有正值;速度、位置、状态等其他字段仍按事件时间更新。
|
||||
|
||||
测试验证:
|
||||
|
||||
```text
|
||||
go test ./...
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build ./cmd/gateway
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build ./cmd/history-writer
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build ./cmd/stat-writer
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build ./cmd/realtime-api
|
||||
```
|
||||
|
||||
生产回放验证:
|
||||
|
||||
- 第一条 808 帧:`device_time=2026-07-01T23:09:35+08:00`,`total_mileage_km=12345.6`
|
||||
- 第二条 808 帧:`device_time=2026-07-01T23:09:36+08:00`,`total_mileage_km=0`
|
||||
- 两条帧使用同一 VIN:`LKLG7C4E3NA774736`
|
||||
|
||||
Redis merged 快照确认:第二条 0 里程帧没有覆盖已有有效总里程。
|
||||
|
||||
```text
|
||||
event_time_ms=1782918576000
|
||||
source_endpoint=172.20.0.1:40946
|
||||
speed_kmh=0
|
||||
longitude=119.557997
|
||||
latitude=29.049092
|
||||
total_mileage_km=12345.6
|
||||
field_times_ms.total_mileage_km=1782918575000
|
||||
```
|
||||
|
||||
MySQL `vehicle_daily_metric` 确认:0 里程采样未进入统计,sample_count 只因有效总里程采样增加一次。
|
||||
|
||||
```text
|
||||
LKLG7C4E3NA774736 | JT808 | 2026-07-01 | daily_mileage_km | 0.000 | sample_count=13 | first=12345.600 | latest=12345.600
|
||||
LKLG7C4E3NA774736 | JT808 | 2026-07-01 | daily_total_mileage_km | 12345.600 | sample_count=13 | first=12345.600 | latest=12345.600
|
||||
```
|
||||
|
||||
说明:生产 TDengine 中已经存在大量 808 非零 `total_mileage_km` 采样,但许多 phone 尚未映射到 VIN,因此 MySQL 按 VIN 的统计只会覆盖已解析 VIN 的车辆。下一步应继续补齐 `vehicle_identity_binding`,让更多 808 车辆进入统计。
|
||||
|
||||
## 2026-07-01 23:23 808 注册身份自动维护复验
|
||||
|
||||
部署版本已更新:
|
||||
|
||||
- Git commit:`7b44f97`
|
||||
- 镜像:`crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com/oneos/vehicle-gateway-go:go-7b44f97-20260701232200`
|
||||
|
||||
本轮修复内容:
|
||||
|
||||
- 808 `0x0100` 注册帧解析 `province`、`city`、`manufacturer`、`device_type`、`device_id`、`plate_color`、`plate`。
|
||||
- 808 `0x0102` 鉴权帧解析 `auth_token`。
|
||||
- Go gateway 在 identity resolve 后写入 `jt808_registration`,普通上行也会刷新 `latest_seen_at`。
|
||||
- 注册帧通过车牌解析到 VIN 后,会尝试把 phone/device/plate 反写到 `vehicle_identity_binding`。
|
||||
- 注册帧车牌支持 GBK 解码,避免 `Incorrect string value` 写入错误。
|
||||
|
||||
验证命令:
|
||||
|
||||
```text
|
||||
go test ./...
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build ./cmd/gateway
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build ./cmd/history-writer
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build ./cmd/stat-writer
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build ./cmd/realtime-api
|
||||
```
|
||||
|
||||
ECS 运行状态:
|
||||
|
||||
```text
|
||||
go-vehicle-gateway | go-7b44f97-20260701232200 | 0.0.0.0:808->808, 0.0.0.0:32960->32960
|
||||
go-history-writer | go-7b44f97-20260701232200 | Up
|
||||
go-stat-writer | go-7b44f97-20260701232200 | Up
|
||||
go-realtime-api | go-7b44f97-20260701232200 | 0.0.0.0:20210->20210
|
||||
```
|
||||
|
||||
生产日志确认:
|
||||
|
||||
- 新版本启动后真实 808、32960 连接持续进入。
|
||||
- GBK 车牌注册帧不再出现 `Incorrect string value`。
|
||||
|
||||
MySQL `jt808_registration` 确认:
|
||||
|
||||
```text
|
||||
registration_rows=214
|
||||
distinct_phone=214
|
||||
vin_rows=74
|
||||
plate_rows=74
|
||||
device_rows=72
|
||||
|
||||
013079963379 | device_id=9963379 | plate=沪A06788F | vin=LKLG7C4E3NA774736 | latest_registered_at=2026-07-01 23:23:01
|
||||
013079963321 | device_id=9963321 | plate=沪A59613F | vin=LKLG7C4EXNA774796 | latest_registered_at=2026-07-01 23:23:04
|
||||
```
|
||||
|
||||
说明:`jt808_registration.phone` 保存 808 包头原始 phone;`vehicle_identity_binding.phone` 中已有部分去前导零 phone。Go resolver 会同时查询原始 phone 和去前导零 phone。
|
||||
|
||||
## 2026-07-01 23:28 生产链路与指标清理复验
|
||||
|
||||
本轮复验目标:
|
||||
|
||||
- 确认 Go gateway 的 Kafka durable spool 是重启窗口残留,而不是持续发布失败。
|
||||
- 确认 stat-writer / history-writer / realtime-api 的 Kafka consumer group 无 lag。
|
||||
- 确认真实 808 车辆 registration、TDengine RAW/里程点、Redis 实时查询均在持续更新。
|
||||
- 清理之前生产合成回放产生的 JT808 当日指标测试数据。
|
||||
|
||||
Kafka topic / consumer group:
|
||||
|
||||
```text
|
||||
vehicle.raw.go.jt808.v1 有持续 offset
|
||||
vehicle.raw.go.gb32960.v1 有持续 offset
|
||||
vehicle.event.go.unified.v1 有持续 offset
|
||||
|
||||
go-stat-writer LAG=0
|
||||
go-history-writer LAG=0
|
||||
go-realtime-api LAG=0
|
||||
```
|
||||
|
||||
TDengine:
|
||||
|
||||
```text
|
||||
raw_frames:
|
||||
JT808 2922
|
||||
GB32960 27
|
||||
|
||||
vehicle_mileage_points:
|
||||
JT808 2050
|
||||
GB32960 2
|
||||
```
|
||||
|
||||
Redis 实时查询样例:
|
||||
|
||||
```text
|
||||
GET /api/realtime/vehicles/LKLG7C4E3NA774736
|
||||
vin=LKLG7C4E3NA774736
|
||||
source_endpoint=222.66.200.68:29646
|
||||
plate=沪A06788F
|
||||
longitude=119.556866
|
||||
latitude=29.047911
|
||||
total_mileage_km=12345.6
|
||||
```
|
||||
|
||||
说明:该 Redis merged 快照保留了此前有效 `total_mileage_km=12345.6`,后续真实 808 位置帧上报的 `total_mileage_km=0` 不会覆盖该字段。TDengine 中该 VIN 后续真实里程点的 `total_mileage_km` 均为 0,因此 stat-writer 按“只统计正总里程”的规则不再写 JT808 每日指标。
|
||||
|
||||
MySQL 指标清理:
|
||||
|
||||
```text
|
||||
删除前:
|
||||
LKLG7C4E2NA774775 | JT808 | 2026-07-01 | daily_mileage_km | first=0 | latest=0 | sample_count=7
|
||||
LKLG7C4E2NA774775 | JT808 | 2026-07-01 | daily_total_mileage_km | first=0 | latest=0 | sample_count=7
|
||||
LKLG7C4E3NA774736 | JT808 | 2026-07-01 | daily_mileage_km | first=12345.6 | latest=12345.6 | sample_count=13
|
||||
LKLG7C4E3NA774736 | JT808 | 2026-07-01 | daily_total_mileage_km | first=12345.6 | latest=12345.6 | sample_count=13
|
||||
|
||||
删除后等待真实流量窗口:
|
||||
JT808 2026-07-01 metric rows = 0
|
||||
```
|
||||
|
||||
清理原因:
|
||||
|
||||
- `LKLG7C4E3NA774736` 的 12345.6 来自此前生产连通性合成回放,不是真实平台上报。
|
||||
- `LKLG7C4E2NA774775` 的 0 指标来自旧逻辑污染,当前 stat-writer 已跳过非正总里程。
|
||||
|
||||
Kafka spool:
|
||||
|
||||
```text
|
||||
23:24 spool_count=904
|
||||
23:25 spool_count=862
|
||||
23:28 spool_count=722
|
||||
new_spool_1min=0
|
||||
```
|
||||
|
||||
说明:spool 正在回放下降,且最近 1 分钟没有新增文件;生产 gateway 日志未见 `Incorrect string value`、Kafka publish error 或 replay error。
|
||||
|
||||
## 2026-07-01 23:34 每日指标查询 API 复验
|
||||
|
||||
部署版本已更新:
|
||||
|
||||
- Git commit:`56f4811`
|
||||
- 镜像:`crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com/oneos/vehicle-gateway-go:go-56f4811-20260701233311`
|
||||
|
||||
新增接口:
|
||||
|
||||
```text
|
||||
GET /api/stats/daily-metrics
|
||||
```
|
||||
|
||||
查询参数:
|
||||
|
||||
```text
|
||||
vin 可选
|
||||
protocol 可选,GB32960 / JT808 / YUTONG_MQTT
|
||||
metricKey 可选,daily_mileage_km / daily_total_mileage_km
|
||||
dateFrom 可选,YYYY-MM-DD
|
||||
dateTo 可选,YYYY-MM-DD
|
||||
limit 可选,默认 50,最大 1000
|
||||
offset 可选,默认 0
|
||||
```
|
||||
|
||||
生产可访问样例:
|
||||
|
||||
```text
|
||||
http://115.29.187.205:20210/api/stats/daily-metrics?vin=LB9A32A21R0LS1707&protocol=GB32960&dateFrom=2020-07-01&dateTo=2020-07-01&limit=10
|
||||
```
|
||||
|
||||
返回确认:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"vin": "LB9A32A21R0LS1707",
|
||||
"stat_date": "2020-07-01",
|
||||
"protocol": "GB32960",
|
||||
"metric_key": "daily_mileage_km",
|
||||
"metric_value": 0,
|
||||
"metric_unit": "km",
|
||||
"first_total_mileage_km": 53490.9,
|
||||
"latest_total_mileage_km": 53490.9,
|
||||
"sample_count": 3,
|
||||
"calculation_method": "TOTAL_MILEAGE_DIFF",
|
||||
"created_at": "2026-07-01 22:14:13",
|
||||
"updated_at": "2026-07-01 22:28:25"
|
||||
},
|
||||
{
|
||||
"vin": "LB9A32A21R0LS1707",
|
||||
"stat_date": "2020-07-01",
|
||||
"protocol": "GB32960",
|
||||
"metric_key": "daily_total_mileage_km",
|
||||
"metric_value": 53490.9,
|
||||
"metric_unit": "km",
|
||||
"first_total_mileage_km": 53490.9,
|
||||
"latest_total_mileage_km": 53490.9,
|
||||
"sample_count": 3,
|
||||
"calculation_method": "TOTAL_MILEAGE_DIFF",
|
||||
"created_at": "2026-07-01 22:14:13",
|
||||
"updated_at": "2026-07-01 22:28:25"
|
||||
}
|
||||
],
|
||||
"limit": 10,
|
||||
"offset": 0,
|
||||
"total": 2
|
||||
}
|
||||
```
|
||||
|
||||
JT808 真实生产查询:
|
||||
|
||||
```text
|
||||
http://115.29.187.205:20210/api/stats/daily-metrics?protocol=JT808&dateFrom=2026-07-01&dateTo=2026-07-01&limit=10
|
||||
```
|
||||
|
||||
返回 `total=0`。原因是此前合成回放产生的测试指标已清理,当前真实 808 已绑定 VIN 的车辆仍上报 `total_mileage_km=0`,stat-writer 按规则不写非正总里程指标。
|
||||
|
||||
实时接口回归:
|
||||
|
||||
```text
|
||||
http://115.29.187.205:20210/api/realtime/vehicles/LKLG7C4E3NA774736/online
|
||||
```
|
||||
|
||||
返回 `online=true`,说明新增 stats API 没有影响 Redis 实时查询。
|
||||
|
||||
发布后 spool 观察:
|
||||
|
||||
```text
|
||||
发布后立即:spool_count=1101,new_spool_1min=102
|
||||
等待 60 秒:spool_count=1033,new_spool_1min=0
|
||||
```
|
||||
|
||||
说明:本次发布重启窗口产生的 spool 正在回放下降,且最近 1 分钟没有新增;gateway/realtime-api 最近日志未见 error、failed、Kafka publish error。
|
||||
|
||||
## 2026-07-01 23:50 生产端口接管与 RAW 查询 API 复验
|
||||
|
||||
部署版本:
|
||||
|
||||
- Git commit:`5937132`
|
||||
- 镜像:`crpi-85r4m0ackrm3qpje.cn-shanghai.personal.cr.aliyuncs.com/oneos/vehicle-gateway-go:go-5937132-20260701234220`
|
||||
|
||||
生产端口状态:
|
||||
|
||||
```text
|
||||
go-vehicle-gateway 0.0.0.0:808->808/tcp
|
||||
go-vehicle-gateway 0.0.0.0:32960->32960/tcp
|
||||
```
|
||||
|
||||
旧 Java 容器状态:
|
||||
|
||||
```text
|
||||
gb32960-ingest-app restart=no status=exited
|
||||
jt808-ingest-app restart=no status=exited
|
||||
yutong-mqtt-app restart=no status=exited
|
||||
vehicle-history-app restart=no status=exited
|
||||
vehicle-analytics-app restart=no status=exited
|
||||
vehicle-state-app restart=no status=exited
|
||||
telemetry-field-parser-app restart=no status=exited
|
||||
```
|
||||
|
||||
生产连接确认:
|
||||
|
||||
- JT808 端口 `808` 已收到外部连接:`222.66.200.68`、`115.231.168.135`。
|
||||
- GB32960 端口 `32960` 已收到外部连接:`8.134.95.166`、`117.160.0.65`。
|
||||
- ECS 当前没有 `8089` 监听。
|
||||
|
||||
RAW 查询 API:
|
||||
|
||||
```text
|
||||
GET /api/history/raw-frames
|
||||
```
|
||||
|
||||
生产可访问样例:
|
||||
|
||||
```text
|
||||
http://115.29.187.205:20210/api/history/raw-frames?protocol=JT808&vin=LKLG7C4E3NA774736&messageId=0x0200&limit=1
|
||||
http://115.29.187.205:20210/api/history/raw-frames?protocol=GB32960&vin=LB9A32A21R0LS1707&limit=1
|
||||
```
|
||||
|
||||
接口复验结果:
|
||||
|
||||
```text
|
||||
JT808_RAW:
|
||||
total=1
|
||||
protocol=JT808
|
||||
vin=LKLG7C4E3NA774736
|
||||
phone=013079963379
|
||||
message_id=512
|
||||
message_id_hex=0x0200
|
||||
parse_status=OK
|
||||
raw_hex_len=94
|
||||
parsed_json_len=651
|
||||
fields_json_len=186
|
||||
|
||||
GB32960_RAW:
|
||||
total=1
|
||||
protocol=GB32960
|
||||
vin=LB9A32A21R0LS1707
|
||||
message_id=2
|
||||
message_id_hex=0x0002
|
||||
parse_status=OK
|
||||
raw_hex_len=1548
|
||||
parsed_json_len=3698
|
||||
fields_json_len=563
|
||||
```
|
||||
|
||||
同轮回归验证:
|
||||
|
||||
```text
|
||||
GET /api/realtime/vehicles/LKLG7C4E3NA774736/online
|
||||
online=true
|
||||
protocols=["JT808"]
|
||||
|
||||
GET /api/stats/daily-metrics?vin=LB9A32A21R0LS1707&protocol=GB32960&dateFrom=2020-07-01&dateTo=2026-07-01&limit=1
|
||||
total=1
|
||||
protocol=GB32960
|
||||
metric_key=daily_mileage_km
|
||||
calculation_method=TOTAL_MILEAGE_DIFF
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- RAW 查询已返回 `raw_hex`、`parsed_json`、`fields_json`,可以用于查看完整解析结果。
|
||||
- 日统计和实时查询在生产端口接管后仍可用。
|
||||
|
||||
## 2026-07-02 00:00 原生 systemd 部署与宇通 MQTT 复验
|
||||
|
||||
根据本次 goal 的新要求,Go 接入链路后续不再使用 Docker 部署。本轮已中断 Docker 镜像构建路径,改为 ECS 原生 Linux 二进制 + systemd。
|
||||
|
||||
部署版本:
|
||||
|
||||
- Git commit:`594ab2d`
|
||||
- 部署目录:`/opt/lingniu-go-native`
|
||||
- 当前 release:`/opt/lingniu-go-native/releases/594ab2d`
|
||||
- 当前指针:`/opt/lingniu-go-native/current -> /opt/lingniu-go-native/releases/594ab2d`
|
||||
|
||||
systemd 服务:
|
||||
|
||||
```text
|
||||
lingniu-go-gateway.service active
|
||||
lingniu-go-history-writer.service active
|
||||
lingniu-go-stat-writer.service active
|
||||
lingniu-go-realtime-api.service active
|
||||
```
|
||||
|
||||
端口确认:
|
||||
|
||||
```text
|
||||
[::]:32960 gateway
|
||||
[::]:808 gateway
|
||||
[::]:20210 realtime-api
|
||||
```
|
||||
|
||||
Go Docker 容器已停止:
|
||||
|
||||
```text
|
||||
go-vehicle-gateway Exited
|
||||
go-history-writer Exited
|
||||
go-stat-writer Exited
|
||||
go-realtime-api Exited
|
||||
```
|
||||
|
||||
宇通 MQTT:
|
||||
|
||||
```text
|
||||
yutong mqtt client started
|
||||
mqtt subscribed broker=ssl://cpxlm.axxc.cn:38883 topic=/ytforward/shln/+ qos=1
|
||||
```
|
||||
|
||||
说明:MQTT broker 存在短连接 `EOF` 后自动重连现象,但订阅后已收到真实数据并落 RAW。
|
||||
|
||||
RAW 查询复验:
|
||||
|
||||
```text
|
||||
GET /api/history/raw-frames?protocol=YUTONG_MQTT&limit=3
|
||||
|
||||
total=3
|
||||
protocol=YUTONG_MQTT
|
||||
vin=LMRKH9AC1R1004131
|
||||
vehicle_key=LMRKH9AC1R1004131
|
||||
parse_status=OK
|
||||
parsed_json_len=1104
|
||||
fields_json_len=116
|
||||
```
|
||||
|
||||
同轮回归:
|
||||
|
||||
```text
|
||||
GET /api/history/raw-frames?protocol=JT808&vin=LKLG7C4E3NA774736&messageId=0x0200&limit=1
|
||||
total=1
|
||||
parse_status=OK
|
||||
|
||||
GET /api/history/raw-frames?protocol=GB32960&vin=LB9A32A21R0LS1707&limit=1
|
||||
total=1
|
||||
parse_status=OK
|
||||
|
||||
GET /api/realtime/vehicles/LKLG7C4E3NA774736/online
|
||||
online=true
|
||||
protocols=["JT808"]
|
||||
```
|
||||
|
||||
本轮落地文件:
|
||||
|
||||
- `deploy/systemd/README.md`
|
||||
- `deploy/systemd/lingniu-go-gateway.service`
|
||||
- `deploy/systemd/lingniu-go-history-writer.service`
|
||||
- `deploy/systemd/lingniu-go-stat-writer.service`
|
||||
- `deploy/systemd/lingniu-go-realtime-api.service`
|
||||
|
||||
## 2026-07-02 00:04 宇通 MQTT EOF 诊断
|
||||
|
||||
现象:
|
||||
|
||||
Go 原生部署后,`lingniu-go-gateway` 日志中出现多次:
|
||||
|
||||
```text
|
||||
mqtt connection lost error=EOF
|
||||
mqtt connection lost error=write: broken pipe
|
||||
mqtt subscribed topic=/ytforward/shln/+ qos=1
|
||||
```
|
||||
|
||||
10 分钟窗口统计:
|
||||
|
||||
```text
|
||||
mqtt subscribed = 33
|
||||
mqtt connection lost = 32
|
||||
```
|
||||
|
||||
对照旧 Java `yutong-mqtt-app` 历史日志,停止前也存在同类行为:
|
||||
|
||||
```text
|
||||
mqtt endpoint [yutong] received topic=/ytforward/shln/1 bytes=574
|
||||
mqtt endpoint [yutong] connection lost
|
||||
Caused by: java.io.EOFException
|
||||
mqtt endpoint [yutong] re-subscribed topic=/ytforward/shln/+ qos=1
|
||||
mqtt endpoint [yutong] received topic=/ytforward/shln/3 bytes=586
|
||||
```
|
||||
|
||||
判断:
|
||||
|
||||
- EOF/短连接不是 Go TLS 支持或 systemd 原生部署新引入的问题。
|
||||
- 旧 Java 和新 Go 都表现为:订阅、收到数据、broker 断开、自动重连。
|
||||
- 当前链路可持续收到宇通 MQTT 数据并写入 TDengine RAW。
|
||||
|
||||
复验:
|
||||
|
||||
```text
|
||||
GET /api/history/raw-frames?protocol=YUTONG_MQTT&limit=1
|
||||
|
||||
total=1
|
||||
protocol=YUTONG_MQTT
|
||||
vin=LMRKH9ACXR1004094
|
||||
parse_status=OK
|
||||
parsed_json_len=1080
|
||||
fields_json_len=115
|
||||
```
|
||||
|
||||
运维建议:
|
||||
|
||||
- 单条 EOF 不作为不可用判断。
|
||||
- 以 `YUTONG_MQTT` RAW 入库增长、最近成功 `mqtt subscribed` 时间、服务 `systemctl is-active` 作为可用性判断。
|
||||
- 如果 EOF 频率继续升高且 RAW 不再增长,再联系宇通侧确认 broker 长连接策略、同一 clientId 并发限制或网络出口策略。
|
||||
@@ -1,158 +0,0 @@
|
||||
# Go Vehicle Gateway 验证手册
|
||||
|
||||
本文档用于验证 Go 重构运行面:
|
||||
|
||||
- `go-vehicle-gateway`:GB32960 TCP、JT808 TCP、宇通 MQTT 接入。
|
||||
- `go-history-writer`:Kafka RAW 写 TDengine。
|
||||
- `go-stat-writer`:Kafka RAW 写 MySQL 每日指标。
|
||||
- `go-realtime-api`:Kafka unified event 写 Redis,并提供实时查询 API。
|
||||
|
||||
## 本地构建验证
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./...
|
||||
go build ./cmd/gateway
|
||||
go build ./cmd/history-writer
|
||||
go build ./cmd/stat-writer
|
||||
go build ./cmd/realtime-api
|
||||
```
|
||||
|
||||
## 生产原生部署验证
|
||||
|
||||
```bash
|
||||
python3 tools/go_prod_acceptance.py --date 2026-07-02
|
||||
```
|
||||
|
||||
该命令会聚合执行:
|
||||
|
||||
- `go_systemd_prod_smoke.py`:应用 ECS systemd 服务 active/enabled、端口归属和 gateway spool
|
||||
- `go_kafka_prod_smoke.py`:Kafka topic 和消费组 lag
|
||||
- `go_native_prod_smoke.py`:HTTP API、RAW `parsed_json`、历史核心表 `frame_id` 回链、每日指标公式、Redis 实时新鲜度、TDengine/MySQL/Redis 数据链路
|
||||
|
||||
如需单独检查 systemd:
|
||||
|
||||
```bash
|
||||
python3 tools/go_systemd_prod_smoke.py --host 115.29.187.205 --user root
|
||||
```
|
||||
|
||||
脚本通过 SSH 检查:
|
||||
|
||||
- `lingniu-go-gateway.service`
|
||||
- `lingniu-go-history-writer.service`
|
||||
- `lingniu-go-stat-writer.service`
|
||||
- `lingniu-go-realtime-api.service`
|
||||
- 上述服务均为 `active` 且 `enabled`
|
||||
- `808`、`32960` 由 `gateway` 监听
|
||||
- `20210` 由 `realtime-api` 监听
|
||||
- `/opt/lingniu-go-native/spool/gateway` 无待回放文件
|
||||
|
||||
## 生产环境变量
|
||||
|
||||
当前 goal 的生产面使用 ECS 原生 systemd 部署,不再以 Docker/Portainer 作为生产运行方式。以下环境变量维护在 `/opt/lingniu-go-native/env/*.env`:
|
||||
|
||||
```bash
|
||||
KAFKA_BROKERS=172.17.111.56:9092
|
||||
TDENGINE_DSN=root:<password>@ws(172.17.111.57:6041)/lingniu_vehicle_ts
|
||||
MYSQL_DSN=lingniu_vehicle:<password>@tcp(rm-bp179zbv481rnw3e2.mysql.rds.aliyuncs.com:3306)/lingniu_vehicle_data?parseTime=true&charset=utf8mb4,utf8&loc=Asia%2FShanghai
|
||||
IDENTITY_MYSQL_DSN=${MYSQL_DSN}
|
||||
VEHICLE_IDENTITY_TABLE=vehicle_identity_binding
|
||||
REDIS_ADDR=r-bp1u741kij7e51i481.redis.rds.aliyuncs.com:6379
|
||||
REDIS_PASSWORD=<password>
|
||||
REDIS_DB=50
|
||||
```
|
||||
|
||||
宇通 MQTT 开启时再配置:
|
||||
|
||||
```bash
|
||||
YUTONG_MQTT_ENABLED=true
|
||||
YUTONG_MQTT_URI=<mqtt-uri>
|
||||
YUTONG_MQTT_TOPICS=/ytforward/shln/+
|
||||
YUTONG_MQTT_CLIENT_ID=lingniu-go-yutong-mqtt
|
||||
YUTONG_MQTT_USERNAME=<username>
|
||||
YUTONG_MQTT_PASSWORD=<password>
|
||||
```
|
||||
|
||||
## ECS 进程检查
|
||||
|
||||
```bash
|
||||
systemctl is-active lingniu-go-gateway lingniu-go-history-writer lingniu-go-stat-writer lingniu-go-realtime-api
|
||||
|
||||
ss -lntp | egrep ':(808|32960|20210)\b'
|
||||
```
|
||||
|
||||
## Kafka 验证
|
||||
|
||||
优先使用 smoke 脚本做可重复检查:
|
||||
|
||||
```bash
|
||||
python3 tools/go_kafka_prod_smoke.py --host 114.55.58.251 --user root --max-lag 100
|
||||
```
|
||||
|
||||
脚本会检查 `vehicle.raw.go.gb32960.v1`、`vehicle.raw.go.jt808.v1`、`vehicle.raw.go.yutong-mqtt.v1`、`vehicle.event.go.unified.v1` 是否存在,并汇总 `go-history-writer`、`go-stat-writer`、`go-realtime-api` 的消费 lag。运行环境需要 SSH 免密或已建立可用的 SSH 认证方式。
|
||||
|
||||
手工核对命令:
|
||||
|
||||
```bash
|
||||
kafka-console-consumer --bootstrap-server 172.17.111.56:9092 \
|
||||
--topic vehicle.raw.go.jt808.v1 --max-messages 1 --timeout-ms 10000
|
||||
|
||||
kafka-console-consumer --bootstrap-server 172.17.111.56:9092 \
|
||||
--topic vehicle.raw.go.gb32960.v1 --max-messages 1 --timeout-ms 10000
|
||||
|
||||
kafka-console-consumer --bootstrap-server 172.17.111.56:9092 \
|
||||
--topic vehicle.raw.go.yutong-mqtt.v1 --max-messages 1 --timeout-ms 10000
|
||||
|
||||
kafka-console-consumer --bootstrap-server 172.17.111.56:9092 \
|
||||
--topic vehicle.event.go.unified.v1 --max-messages 1 --timeout-ms 10000
|
||||
|
||||
kafka-consumer-groups --bootstrap-server 172.17.111.56:9092 \
|
||||
--describe --group go-history-writer
|
||||
|
||||
kafka-consumer-groups --bootstrap-server 172.17.111.56:9092 \
|
||||
--describe --group go-stat-writer
|
||||
|
||||
kafka-consumer-groups --bootstrap-server 172.17.111.56:9092 \
|
||||
--describe --group go-realtime-api
|
||||
```
|
||||
|
||||
## TDengine 验证
|
||||
|
||||
```sql
|
||||
USE lingniu_vehicle_ts;
|
||||
SHOW STABLES;
|
||||
SELECT COUNT(*) FROM raw_frames;
|
||||
SELECT COUNT(*) FROM vehicle_locations;
|
||||
SELECT COUNT(*) FROM vehicle_mileage_points;
|
||||
```
|
||||
|
||||
## MySQL 验证
|
||||
|
||||
```sql
|
||||
SELECT protocol, metric_key, COUNT(*)
|
||||
FROM vehicle_daily_metric
|
||||
GROUP BY protocol, metric_key;
|
||||
|
||||
SELECT *
|
||||
FROM vehicle_daily_metric
|
||||
WHERE metric_key IN ('daily_mileage_km', 'daily_total_mileage_km')
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
## Redis 实时查询
|
||||
|
||||
```bash
|
||||
curl -sS 'http://115.29.187.205:20210/api/realtime/vehicles/<vin>'
|
||||
curl -sS 'http://115.29.187.205:20210/api/realtime/vehicles/<vin>/online'
|
||||
curl -sS 'http://115.29.187.205:20210/api/realtime/vehicles/<vin>/protocols/JT808'
|
||||
```
|
||||
|
||||
## 验收证据
|
||||
|
||||
正式切换前至少记录:
|
||||
|
||||
- 一个真实 GB32960 VIN 的 RAW、location、mileage point、Redis snapshot。
|
||||
- 一个真实 JT808 VIN/phone 的 RAW、location、mileage point、daily metric、Redis snapshot。
|
||||
- 一个真实宇通 MQTT VIN 或 device_id 的 RAW、Redis snapshot。
|
||||
- `go-history-writer` 和 `go-stat-writer` 重启后 Kafka offset 能继续推进。
|
||||
@@ -1,34 +0,0 @@
|
||||
# JT808 Daily Mileage Streaming
|
||||
|
||||
## Scope
|
||||
|
||||
The analytics app can calculate daily mileage from JT808 telemetry only. It consumes `vehicle.event.jt808.v1` and saves the current daily result into the common `vehicle-stat` metric repository.
|
||||
|
||||
Only supported message backbone: Kafka.
|
||||
|
||||
## Storage
|
||||
|
||||
Runtime state: none outside `vehicle_stat_metric`. There is no separate JT808 daily-mileage table. The derived value is stored as one `daily_mileage_km` metric row in the common JDBC/MySQL table `vehicle_stat_metric`; the local-day minimum and maximum GPS total mileage values are kept on that same row as calculation source columns.
|
||||
|
||||
The JT808 daily-mileage value is calculated from the GPS total mileage reported in location additional information:
|
||||
|
||||
```text
|
||||
daily_mileage_km = max_total_mileage_km - min_total_mileage_km
|
||||
calculation_method = JT808_TOTAL_MILEAGE_DIFF
|
||||
```
|
||||
|
||||
The first valid JT808 location point for a vehicle and local day stores both calculation endpoints on the metric row and writes `daily_mileage_km=0.0`. Later ordered or replayed points update the lower endpoint when the reported GPS total mileage is smaller, update the upper endpoint when it is larger, and rewrite the derived `daily_mileage_km` on the same row.
|
||||
|
||||
## Runtime Settings
|
||||
|
||||
Set these in Portainer or Nacos, without committing secrets:
|
||||
|
||||
```text
|
||||
KAFKA_TOPIC_JT808_EVENT=vehicle.event.jt808.v1
|
||||
VEHICLE_STAT_JT808_MILEAGE_ENABLED=true
|
||||
MYSQL_JDBC_URL=<jdbc-url>
|
||||
MYSQL_USERNAME=<user>
|
||||
MYSQL_PASSWORD=<password>
|
||||
```
|
||||
|
||||
Algorithm defaults use the Asia/Shanghai daily boundary. Restart recovery reads the same `daily_mileage_km` metric row.
|
||||
@@ -1,351 +0,0 @@
|
||||
# Vehicle Ingest TDengine 链路验收手册
|
||||
|
||||
这份手册用于验收当前 GB32960/JT808/Yutong MQTT 接入重构链路:
|
||||
|
||||
1. 协议 App 接收 TCP 报文。
|
||||
2. 协议 App 归档原始帧元数据,并把事件信封发布到 Kafka。
|
||||
3. `vehicle-history-app` 消费 Kafka。
|
||||
4. history 写入 raw、location 到 TDengine;不写 telemetry_fields。
|
||||
5. Swagger/API 通过 TDengine 做历史分页查询。
|
||||
|
||||
每一步都必须执行命令并检查输出后,才能标记为已验证。
|
||||
|
||||
## 必需运行参数
|
||||
|
||||
启动服务前先设置:
|
||||
|
||||
```bash
|
||||
export KAFKA_BROKERS=114.55.58.251:9092
|
||||
export TDENGINE_HISTORY_ENABLED=true
|
||||
export TDENGINE_HISTORY_DATABASE=vehicle_ts
|
||||
export TDENGINE_JDBC_URL='jdbc:TAOS-WS://<tdengine-host>:6041/vehicle_ts'
|
||||
export TDENGINE_DRIVER_CLASS_NAME=com.taosdata.jdbc.ws.WebSocketDriver
|
||||
export TDENGINE_USERNAME=root
|
||||
export TDENGINE_PASSWORD='<tdengine-password>'
|
||||
export TDENGINE_MIN_IDLE=0
|
||||
export TDENGINE_MAX_POOL_SIZE=32
|
||||
```
|
||||
|
||||
`TDENGINE_MIN_IDLE=0` 用于减少冷启动时 TDengine 连接重试日志。TDengine 地址稳定后,再按生产并发调大连接池。
|
||||
|
||||
高吞吐生产验收只验证 TDengine `raw_frames`、位置表和分页查询闭环。RAW 帧的 `parsedJson`、`metadataJson`、`rawUri` 直接进入 TDengine,GB32960 snapshot/fields 接口基于 `raw_frames` 与当前解析器即时返回结构化结果;接入服务写出的原始 `.bin` 只作为冷备复核材料。逐字段趋势宽表由独立字段解析服务消费 Kafka RAW/事件后维护,不由 history-app 写入。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
mvn -pl :gb32960-ingest-app,:jt808-ingest-app,:yutong-mqtt-app,:vehicle-history-app,:vehicle-analytics-app -am package -Dmaven.test.skip=true
|
||||
```
|
||||
|
||||
预期产物:
|
||||
|
||||
- `modules/apps/gb32960-ingest-app/target/gb32960-ingest-app.jar`
|
||||
- `modules/apps/jt808-ingest-app/target/jt808-ingest-app.jar`
|
||||
- `modules/apps/yutong-mqtt-app/target/yutong-mqtt-app.jar`
|
||||
- `modules/apps/vehicle-history-app/target/vehicle-history-app.jar`
|
||||
- `modules/apps/vehicle-analytics-app/target/vehicle-analytics-app.jar`
|
||||
|
||||
## ECS 服务状态验证
|
||||
|
||||
生产服务只在 ECS 上运行,通过 Portainer/Docker 管理。不要在本机用 `java -jar`、plist 或其它守护进程方式启动 GB32960、JT808、Yutong MQTT、history、analytics。
|
||||
|
||||
```bash
|
||||
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' \
|
||||
| egrep 'gb32960|jt808|yutong|vehicle-history|vehicle-analytics|NAMES'
|
||||
|
||||
ss -lntp | egrep ':(808|32960|20100|20200|20310|20400|20500)\b'
|
||||
|
||||
curl -sS http://127.0.0.1:20400/actuator/health
|
||||
curl -sS http://127.0.0.1:20400/actuator/health/liveness
|
||||
curl -sS http://127.0.0.1:20400/actuator/health/readiness
|
||||
curl -sS http://127.0.0.1:20100/actuator/health
|
||||
curl -sS http://127.0.0.1:20100/actuator/health/liveness
|
||||
curl -sS http://127.0.0.1:20100/actuator/health/readiness
|
||||
curl -sS http://127.0.0.1:20500/actuator/health
|
||||
curl -sS http://127.0.0.1:20500/actuator/health/liveness
|
||||
curl -sS http://127.0.0.1:20500/actuator/health/readiness
|
||||
curl -sS http://127.0.0.1:20200/actuator/health
|
||||
curl -sS http://127.0.0.1:20200/actuator/health/liveness
|
||||
curl -sS http://127.0.0.1:20200/actuator/health/readiness
|
||||
curl -sS http://127.0.0.1:20200/v3/api-docs \
|
||||
| grep -E '/api/event-history/locations|/api/event-history/raw-frames'
|
||||
curl -sS http://127.0.0.1:20310/actuator/health
|
||||
curl -sS http://127.0.0.1:20310/actuator/health/liveness
|
||||
curl -sS http://127.0.0.1:20310/actuator/health/readiness
|
||||
```
|
||||
|
||||
预期:五个容器均为运行状态;健康检查为 `UP`;JT808/GB32960 TCP 端口在 ECS 上监听;OpenAPI 中包含通用位置分页查询和 RAW 帧查询接口。history-app 不暴露 `/api/event-history/telemetry/fields`,也不持续写入逐字段宽表。JT808 位置帧中有 GPS 总里程时,`vehicle-analytics-app` 按差值法写入 MySQL `vehicle_stat_metric` 的 `daily_mileage_km` 指标。
|
||||
|
||||
## 运行位置约束
|
||||
|
||||
生产身份绑定固定使用 MySQL,不再提供 file/memory/sqlite 运行时切换入口。生产服务固定运行在 ECS/Portainer,不提供本机守护进程模板。
|
||||
|
||||
- `jt808-ingest-app` 监听 TCP `808`,HTTP `20400`。
|
||||
- `gb32960-ingest-app` 监听 TCP `32960`,HTTP `20100`。
|
||||
- `yutong-mqtt-app` 监听 HTTP `20500`。
|
||||
- `vehicle-history-app` 监听 HTTP `20200`。
|
||||
- `vehicle-analytics-app` 监听 HTTP `20310`。
|
||||
- history 热查询只依赖 Kafka 和 TDengine `raw_frames`;history 不需要共享 archive volume。
|
||||
- GB32960/JT808/Yutong MQTT 接入服务可以继续使用 `SINK_ARCHIVE_PATH` 保存原始冷备,但这不是 history API 的实时查询前置条件。
|
||||
|
||||
JT808 注册身份绑定:
|
||||
|
||||
- 生产需要把 0x0100 注册信息维护到 MySQL 时,只需要配置 `VEHICLE_IDENTITY_MYSQL_*` 连接参数。
|
||||
- 需要提供 `VEHICLE_IDENTITY_MYSQL_JDBC_URL`、`VEHICLE_IDENTITY_MYSQL_USERNAME`、`VEHICLE_IDENTITY_MYSQL_PASSWORD`。
|
||||
- 服务启动时自动创建 `vehicle_identity_binding` 和 `vehicle_identity_binding_registration` 两张表。`vehicle_identity_binding` 只维护 `plate`、`vin` 两列;`vehicle_identity_binding_registration` 按 `protocol + phone` 保存 JT808 0x0100 注册字段。
|
||||
- 注册帧无真实 VIN 时也会写入 registration 表,`vin` 默认为 `unknown`。外部系统识别车辆后只需要维护车牌和 VIN,例如:`INSERT INTO vehicle_identity_binding (plate, vin) VALUES ('沪A61559F', 'LNVIN000000000001') ON DUPLICATE KEY UPDATE vin = VALUES(vin);`
|
||||
- MySQL 绑定在启动时加载到内存索引,`plate/vin` 反写后会按 `VEHICLE_IDENTITY_MYSQL_REFRESH_INTERVAL` 周期刷新,默认 `60s`;帧解析热路径只查内存,不按帧访问 MySQL。
|
||||
- 后续同一终端上报 raw/event 时,服务会用 registration 表里的 `phone/device_id/plate` 加绑定表里的 `plate/vin` 解析成真实 VIN。
|
||||
|
||||
健康检查:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:20400/actuator/health
|
||||
curl -sS http://127.0.0.1:20400/actuator/health/liveness
|
||||
curl -sS http://127.0.0.1:20400/actuator/health/readiness
|
||||
curl -sS http://127.0.0.1:20100/actuator/health
|
||||
curl -sS http://127.0.0.1:20100/actuator/health/liveness
|
||||
curl -sS http://127.0.0.1:20100/actuator/health/readiness
|
||||
curl -sS http://127.0.0.1:20500/actuator/health
|
||||
curl -sS http://127.0.0.1:20500/actuator/health/liveness
|
||||
curl -sS http://127.0.0.1:20500/actuator/health/readiness
|
||||
curl -sS http://127.0.0.1:20200/actuator/health
|
||||
curl -sS http://127.0.0.1:20200/actuator/health/liveness
|
||||
curl -sS http://127.0.0.1:20200/actuator/health/readiness
|
||||
curl -sS http://127.0.0.1:20310/actuator/health
|
||||
curl -sS http://127.0.0.1:20310/actuator/health/liveness
|
||||
curl -sS http://127.0.0.1:20310/actuator/health/readiness
|
||||
```
|
||||
|
||||
API / Swagger:
|
||||
|
||||
- JT808 ingest health: `http://127.0.0.1:20400/actuator/health`
|
||||
- GB32960 ingest health: `http://127.0.0.1:20100/actuator/health`
|
||||
- Yutong MQTT ingest health: `http://127.0.0.1:20500/actuator/health`
|
||||
- History query: `http://127.0.0.1:20200/swagger-ui/index.html`
|
||||
- Analytics metrics: `http://127.0.0.1:20310/swagger-ui/index.html`
|
||||
|
||||
## Kafka Topic 验证
|
||||
|
||||
历史服务必须消费事件 topic 和 raw topic:
|
||||
|
||||
- `vehicle.event.gb32960.v1`
|
||||
- `vehicle.raw.gb32960.v1`
|
||||
- `vehicle.event.jt808.v1`
|
||||
- `vehicle.raw.jt808.v1`
|
||||
- `vehicle.event.mqtt-yutong.v1`
|
||||
- `vehicle.raw.mqtt-yutong.v1`
|
||||
|
||||
使用 Kafka CLI 或管理工具检查 topic 是否存在,并确认 history consumer group 在测试流量后没有持续堆积。默认 `KAFKA_GROUP_HISTORY=vehicle-history` 时,实际 group 会拆成:
|
||||
|
||||
- `vehicle-history-gb32960-event`
|
||||
- `vehicle-history-gb32960-raw`
|
||||
- `vehicle-history-jt808-event`
|
||||
- `vehicle-history-jt808-raw`
|
||||
- `vehicle-history-yutong-mqtt-event`
|
||||
- `vehicle-history-yutong-mqtt-raw`
|
||||
|
||||
## JT808 实时转发验收
|
||||
|
||||
如果外部平台已经把 JT808 报文转发到 ECS TCP `808`,至少观察 60 秒日志和 Kafka 数据。
|
||||
|
||||
必须拿到以下证据:
|
||||
|
||||
- `jt808-ingest-app` 日志显示接入连接或解析到上游消息 ID。
|
||||
- Kafka 的 `vehicle.event.jt808.v1` 和 `vehicle.raw.jt808.v1` 有新增记录。
|
||||
- `vehicle-history-app` 日志没有持续出现 consumer 或 TDengine 写入失败。
|
||||
|
||||
消费完成后,使用已知终端手机号查询:
|
||||
|
||||
```bash
|
||||
curl -sS 'http://127.0.0.1:20200/api/event-history/jt808/locations?phone=<phone>&dateFrom=2026-06-29T00:00:00%2B08:00&dateTo=2026-06-29T23:59:59%2B08:00&limit=10'
|
||||
```
|
||||
|
||||
预期:响应包含 `items`,并且每条记录包含 `eventTime`、`phone`、`longitude`、`latitude`、`speedKmh`、`rawUri`、`metadataJson`。
|
||||
|
||||
## GB32960 Snapshot / 字段投影验收
|
||||
|
||||
默认高吞吐模式下,GB32960 全字段查询不依赖 telemetry_fields 宽表。history 先查 TDengine `raw_frames`,直接使用 RAW 行中的 `parsedJson`/`parsedFields` 生成 snapshot 和字段投影:
|
||||
|
||||
```bash
|
||||
curl -sS 'http://127.0.0.1:20200/api/event-history/gb32960/snapshots?vin=<vin>&dateFrom=2026-06-29T00:00:00%2B08:00&dateTo=2026-06-29T23:59:59%2B08:00&limit=10'
|
||||
|
||||
curl -sS 'http://127.0.0.1:20200/api/event-history/gb32960/snapshots/fields?vin=<vin>&fields=VEHICLE.speedKmh,VEHICLE.totalMileageKm,POSITION_V2016.longitude,POSITION_V2016.latitude&dateFrom=2026-06-29T00:00:00%2B08:00&dateTo=2026-06-29T23:59:59%2B08:00&limit=10'
|
||||
```
|
||||
|
||||
预期:snapshot 返回 `sourceFrames.rawArchiveUri` 和解析后的 `blocks`;字段投影返回所选字段。新增协议字段后,先通过 replay 或专用解析任务补写 `raw_frames.parsedJson`,再基于历史 RAW 结构化结果查询。
|
||||
|
||||
## TDengine 直接检查
|
||||
|
||||
使用 TDengine CLI 或 JDBC 客户端检查超级表和子表是否创建:
|
||||
|
||||
```sql
|
||||
USE vehicle_ts;
|
||||
SHOW STABLES;
|
||||
SELECT COUNT(*) FROM raw_frames;
|
||||
SELECT COUNT(*) FROM vehicle_locations;
|
||||
SELECT COUNT(*) FROM jt808_locations;
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- `raw_frames`、`vehicle_locations`、`jt808_locations` 存在;有对应协议实时流量后,计数持续增长。
|
||||
- 字段趋势宽表不属于 history-app 验收范围,由独立字段解析服务负责。
|
||||
- 有实时流量后,计数持续增加。
|
||||
|
||||
## 失败语义
|
||||
|
||||
- TDengine 不可达时,TDengine 查询接口应该返回 HTTP `503`,消息类似 `tdengine history query failed`。
|
||||
- TDengine 未启用时,TDengine 专属接口不应该出现在 OpenAPI 中。
|
||||
- 路径不存在时,API 层应该返回 HTTP `404`,而不是泛化的存储失败。
|
||||
|
||||
## 2026-06-29 Superseded Local Live Verification Record
|
||||
|
||||
该记录仅保留为解析和存储行为的历史证据;生产运行位置已经收敛到 ECS/Portainer。
|
||||
|
||||
当时参与验证的服务:
|
||||
|
||||
- `jt808-ingest-app`:TCP `808`,HTTP `20400`
|
||||
- `gb32960-ingest-app`:TCP `32960`,HTTP `20100`
|
||||
- `vehicle-history-app`:HTTP `20200`
|
||||
|
||||
当次健康检查均返回 `{"status":"UP"}`。
|
||||
|
||||
可重复运行 live 验收工具:
|
||||
|
||||
```bash
|
||||
python3 tools/vehicle_ingest_live_verify.py \
|
||||
--tdengine-rest-url 'http://115.29.185.82:6041/rest/sql/vehicle_ts' \
|
||||
--tdengine-username root \
|
||||
--tdengine-password '<tdengine-password>' \
|
||||
--history-base-url 'http://127.0.0.1:20200' \
|
||||
--date-from '2026-06-29 00:00:00' \
|
||||
--date-to '2026-06-29 23:59:59' \
|
||||
--jt808-peer-like '222.66.200.68:%' \
|
||||
--gb32960-peer-like '115.29.187.205:%'
|
||||
```
|
||||
|
||||
该工具输出 `pass`、`warn` 或 `fail`:当前正式 GB32960 只有平台登录、没有车辆 `0x02` 时会输出 `warn`,避免把平台在线误判为车辆数据在线。正式车辆数据验收时增加 `--require-gb32960-vehicle-realtime`,缺少非测试 VIN 的 `0x02` 会直接返回非 0 退出码。
|
||||
|
||||
JT808 真实转发链路已验证:
|
||||
|
||||
- ECS TCP `808` 有外部平台 `222.66.200.68` 持续发送 JT808 报文。
|
||||
- `vehicle.raw.jt808.v1` 和 `vehicle.event.jt808.v1` 被 history 消费。
|
||||
- `raw_frames` 中 `protocol='JT808'` 的真实行数已超过 `36000`。
|
||||
- 外部平台真实 0x0100 注册帧已超过 `347` 条,真实 0x0200 位置帧已超过 `12400` 条。
|
||||
- `jt808_locations` 中位置行数已超过 `8783`。
|
||||
- 当前高吞吐默认链路不维护 `telemetry_fields`;JT808 明细验证以 `raw_frames`、`vehicle_locations` 和 API 分页结果为准。
|
||||
- 分页 API 已用终端号 `13079963301` 验证第一页和 nextCursor 第二页,样例位置包含 `longitude=118.913846`、`latitude=31.927309`、`statusFlag=3` 和 `archive://2026/06/29/JT808/...` 原始帧引用。
|
||||
- 注册帧样例:终端号 `13079963320`、deviceId `9963320`、deviceType `SEG-9888G`、plate `沪A61559F`、maker `70112`、province `31`、city `113`、plateColor `2`。生产 VIN 反写依赖 MySQL identity store,后续 raw/event 的 VIN 解析也从 MySQL 绑定读取。
|
||||
|
||||
## JT808 ECS smoke / 轻压测工具
|
||||
|
||||
仓库提供 `tools/jt808_e2e_smoke.py`,用于重复验证 TCP `808` 到 TDengine history 的闭环:
|
||||
|
||||
```bash
|
||||
export TDENGINE_REST_URL='http://<tdengine-host>:6041/rest/sql/vehicle_ts'
|
||||
export TDENGINE_USERNAME='root'
|
||||
export TDENGINE_PASSWORD='<tdengine-password>'
|
||||
|
||||
python3 tools/jt808_e2e_smoke.py \
|
||||
--connection-mode session \
|
||||
--start-phone 13079962000 \
|
||||
--frames 3 \
|
||||
--expect-history-count 3 \
|
||||
--verify-pagination \
|
||||
--archive-root "$PROJECT_ROOT/data/archive-jt808" \
|
||||
--tdengine-rest-url "$TDENGINE_REST_URL" \
|
||||
--tdengine-username "$TDENGINE_USERNAME" \
|
||||
--tdengine-password "$TDENGINE_PASSWORD"
|
||||
```
|
||||
|
||||
默认 `--connection-mode per-frame`,适合单帧连接 smoke。生产验收建议使用 `--connection-mode session` 覆盖同一连接内连续位置帧。脚本会输出发送数量、history 可见数量、分页验证结果、raw archive 检查结果和 `tdengineRawFrames`。`tdengineRawFrames` 表示本次可见位置记录中的唯一 `rawUri` 有多少个已确认写入 TDengine `raw_frames`。
|
||||
- 合成 0x0100 注册帧终端号 `13079969999` 已验证:TCP `808` 返回 `0x8100` 注册 ACK,`raw_frames.metadata_json` 包含 `jt808.register.province`、`jt808.register.city`、`jt808.register.maker`、`jt808.register.deviceType`、`jt808.register.deviceId`、`jt808.register.plateColor`、`jt808.register.plate`,对应 raw archive 文件存在。
|
||||
|
||||
复查 SQL:
|
||||
|
||||
```sql
|
||||
USE vehicle_ts;
|
||||
SELECT COUNT(*) FROM raw_frames WHERE protocol = 'JT808';
|
||||
SELECT COUNT(*) FROM jt808_locations WHERE protocol = 'JT808';
|
||||
SELECT event_time, vehicle_key, phone, message_id, raw_uri
|
||||
FROM raw_frames
|
||||
WHERE protocol = 'JT808'
|
||||
ORDER BY event_time DESC
|
||||
LIMIT 5;
|
||||
SELECT ts, frame_id, phone, message_id, metadata_json, raw_uri
|
||||
FROM raw_frames
|
||||
WHERE protocol = 'JT808' AND phone = '13079969999' AND message_id = 256
|
||||
ORDER BY ts DESC
|
||||
LIMIT 3;
|
||||
```
|
||||
|
||||
复查 API:
|
||||
|
||||
```bash
|
||||
curl -sS 'http://127.0.0.1:20200/api/event-history/jt808/locations?phone=13079963301&dateFrom=2026-06-29T00:00:00%2B08:00&dateTo=2026-06-29T23:59:59%2B08:00&order=DESC&limit=3'
|
||||
curl -sS 'http://127.0.0.1:20200/api/event-history/jt808/raw-frames?phone=13079963320&messageId=256&dateFrom=2026-06-29T00:00:00%2B08:00&dateTo=2026-06-29T23:59:59%2B08:00&order=DESC&limit=3'
|
||||
```
|
||||
|
||||
`/api/event-history/jt808/raw-frames` 用于排查注册、鉴权、心跳、位置等原始帧索引。返回项会保留 `messageId`、`messageIdHex`、`rawUri`、`parseStatus`、`peer`、`vin`、`phone` 和完整 `metadataJson`;注册帧的 `metadataJson` 包含 `jt808.register.province`、`jt808.register.city`、`jt808.register.maker`、`jt808.register.deviceType`、`jt808.register.deviceId`、`jt808.register.plateColor`、`jt808.register.plate`、`identitySource`、`identityResolved` 等字段。
|
||||
|
||||
GB32960 链路已用正式平台登录和合成实时帧验证:
|
||||
|
||||
- 正式平台 `115.29.187.205` 已连接 TCP `32960`,服务收到 `0x05 PLATFORM_LOGIN` 并返回 ACK;日志显示账号 `Hyundai`、策略 `ALLOW`。
|
||||
- 2026-06-29 正式平台登录 raw 已有 `3` 条,均落入 TDengine `raw_frames`,可通过 RAW 查询看到 `parsedJson` 中的 `PLATFORM_LOGIN` 解析结果。
|
||||
- 当前正式平台尚未发送车辆实时上报 `0x02`;非测试 VIN 的 `message_id=2` 计数为 `0`,因此不能把平台登录误判为车辆数据在线。
|
||||
- TCP `32960` 返回 GB32960 `2323` ACK。
|
||||
- `raw_frames` 中测试 VIN `LTEST202606290001` 已有 `2` 条 raw 记录。
|
||||
- 当前默认通过 GB32960 snapshot/fields 即时解析 `raw_frames` 中的 RAW JSON,不维护字段宽表增长。
|
||||
- latest raw 包含 `archive://2026/06/29/GB32960/LTEST202606290001/...`。
|
||||
- snapshots API 可查到 `speedKmh=62.4`、`totalMileageKm=223456.7`、`longitude=116.397128`、`latitude=39.916527`。
|
||||
|
||||
复查 SQL:
|
||||
|
||||
```sql
|
||||
USE vehicle_ts;
|
||||
SELECT COUNT(*) FROM raw_frames
|
||||
WHERE protocol = 'GB32960' AND vin = 'LTEST202606290001';
|
||||
SELECT event_time, vin, raw_uri, frame_id
|
||||
FROM raw_frames
|
||||
WHERE protocol = 'GB32960' AND vin = 'LTEST202606290001'
|
||||
ORDER BY event_time DESC
|
||||
LIMIT 5;
|
||||
SELECT ts, vin, message_id, event_time, raw_uri, peer, metadata_json
|
||||
FROM raw_frames
|
||||
WHERE protocol = 'GB32960' AND peer LIKE '115.29.187.205:%'
|
||||
ORDER BY ts DESC
|
||||
LIMIT 5;
|
||||
SELECT COUNT(*) FROM raw_frames
|
||||
WHERE protocol = 'GB32960' AND message_id = 2 AND vin <> '' AND vin NOT LIKE 'LTEST%';
|
||||
```
|
||||
|
||||
也可以直接运行仓库 smoke 工具验证 TCP `32960`、GB32960 snapshot/fields、TDengine `raw_frames`。如果需要复核冷备 `.bin`,再传入接入服务 archive 根目录:
|
||||
|
||||
```bash
|
||||
export TDENGINE_REST_URL='http://<tdengine-host>:6041/rest/sql/vehicle_ts'
|
||||
export TDENGINE_USERNAME='root'
|
||||
export TDENGINE_PASSWORD='<tdengine-password>'
|
||||
|
||||
python3 tools/gb32960_e2e_smoke.py \
|
||||
--archive-root "$PROJECT_ROOT/data/archive-jt808" \
|
||||
--tdengine-rest-url "$TDENGINE_REST_URL" \
|
||||
--tdengine-username "$TDENGINE_USERNAME" \
|
||||
--tdengine-password "$TDENGINE_PASSWORD"
|
||||
```
|
||||
|
||||
预期输出包含 `records >= 1`、`fieldCounts` 中关键字段均大于 0、`tdengineRawFrames` 等于本次可见唯一 `rawUri` 数量。传入 `--archive-root` 时,`archiveChecked >= 1` 说明冷备文件也能复核;这里的 `records` 字段表示 snapshot 数量,用于兼容早期脚本输出名。
|
||||
|
||||
复查 API:
|
||||
|
||||
```bash
|
||||
curl -sS 'http://127.0.0.1:20200/api/event-history/gb32960/snapshots?vin=LTEST202606290001&dateFrom=2026-06-29T00:00:00%2B08:00&dateTo=2026-06-29T23:59:59%2B08:00&limit=10'
|
||||
curl -sS 'http://127.0.0.1:20200/api/event-history/gb32960/snapshots/fields?vin=LTEST202606290001&fields=VEHICLE.speedKmh,VEHICLE.totalMileageKm,POSITION_V2016.longitude,POSITION_V2016.latitude&dateFrom=2026-06-29T00:00:00%2B08:00&dateTo=2026-06-29T23:59:59%2B08:00&limit=10'
|
||||
```
|
||||
|
||||
## 当前注意事项
|
||||
|
||||
- 当前生产链路使用 TDengine WebSocket JDBC,例如 `jdbc:TAOS-WS://<tdengine-host>:6041/vehicle_ts`。
|
||||
- `KAFKA_CONSUMER_AUTO_OFFSET_RESET=latest` 适合生产接入新流量;如果要回放历史 Kafka 数据,需要切换 consumer group 或重置 offset。
|
||||
- `vehicle-history-app` raw 和 event 使用不同 consumer binding;raw consumer 必须开启,否则 `raw_frames` 不会持续增长。
|
||||
- JT808 设备没有 VIN 映射时,`vehicle_key` 使用 `jt808:<phone>`;启用 MySQL identity store 后,registration 表中已反写 VIN 的 phone/deviceId/plate 会用于后续 raw/event 的 VIN 解析。
|
||||
- GB32960 正式对端目前只验证到平台登录;等待真实车辆 `0x02` 到达后,再用非测试 VIN 复查 `vehicle_locations`、snapshot/fields API 和导出能力。
|
||||
182
docs/ops/100k-capacity-baseline.md
Normal file
182
docs/ops/100k-capacity-baseline.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# 100K 车辆接入容量基线
|
||||
|
||||
更新时间:2026-07-03
|
||||
|
||||
## 目标
|
||||
|
||||
支撑 100,000 台车辆接入,入口服务在高连接数和高帧率下保持可观测、可背压、可恢复。
|
||||
|
||||
## 初始容量假设
|
||||
|
||||
| 项 | 目标 |
|
||||
| --- | --- |
|
||||
| 连接车辆数 | 100,000 |
|
||||
| 平均帧间隔 | 10-30 秒 |
|
||||
| 平均入口 FPS | 3,000-10,000 |
|
||||
| 短时 burst FPS | 20,000 |
|
||||
| Gateway p99 parse + enqueue | < 50ms |
|
||||
| Redis 当前态延迟 | < 3s |
|
||||
| TDengine 历史写入 lag | 可观测且 burst 后下降 |
|
||||
| MySQL 当前态 | 异步,不能阻塞 Redis |
|
||||
|
||||
## 当前生产观测
|
||||
|
||||
当前 ECS `115.29.187.205` 在低负载下健康:
|
||||
|
||||
- JT808 连接约 242。
|
||||
- GB32960 连接约 2。
|
||||
- Kafka lag 为 0。
|
||||
- NATS ack pending 为 0。
|
||||
- Redis 写入约 0ms。
|
||||
- MySQL 投影约 1-2ms。
|
||||
- ECS load 约 `0.09 / 0.15 / 0.21`。
|
||||
- 可用内存约 6.6GB。
|
||||
- 根分区使用约 37%。
|
||||
|
||||
## 当前已知缺口
|
||||
|
||||
- `net.core.somaxconn=128`,无法作为 100K 连接生产基线。
|
||||
- Gateway 默认 `TCP_MAX_CONNECTIONS` 已调整为 `120000`,生产仍可通过环境变量覆盖。
|
||||
- 已新增可重复的 TCP 连接压测工具,后续需要用它跑 10K/50K/100K 阶段测试并记录结果。
|
||||
- 仍缺读超时按协议维度计数;Gateway frame duration 已提供 histogram,可用于 parse + enqueue + response 的 p95/p99 估算。
|
||||
- TDengine history writer 和 NATS fast writer 已使用 batch 写入;后续需要用带帧率压测验证 batch size、flush latency 和存储端承载能力。
|
||||
- Kafka topic 当前 12 分区,100K 目标下需要结合实际 FPS 再评估分区数。
|
||||
|
||||
## ECS OS 参数建议
|
||||
|
||||
100K 连接目标需要至少以下系统参数作为起点:
|
||||
|
||||
```bash
|
||||
sysctl -w net.core.somaxconn=65535
|
||||
sysctl -w net.ipv4.tcp_max_syn_backlog=65535
|
||||
sysctl -w net.ipv4.ip_local_port_range="10000 65000"
|
||||
sysctl -w net.ipv4.tcp_tw_reuse=1
|
||||
```
|
||||
|
||||
systemd gateway 已配置 `LimitNOFILE=1048576`,需要持续保持。
|
||||
|
||||
## 验收指标
|
||||
|
||||
| 层 | 指标 | 目标 |
|
||||
| --- | --- | --- |
|
||||
| Gateway | `vehicle_gateway_active_connections` | 能稳定到阶段目标连接数 |
|
||||
| Gateway | `vehicle_gateway_connection_closes_total{reason}` | `read_error/extract_error/max_connections` 不异常增长 |
|
||||
| Gateway | `vehicle_gateway_connection_rejections_total` | 容量目标内不增长 |
|
||||
| Gateway async sink | `vehicle_async_sink_queue_depth{sink}` | 队列深度不持续增长 |
|
||||
| Gateway async sink | `vehicle_async_sink_enqueue_total{sink,kind,status}` | `timeout/closed` 不增长 |
|
||||
| Gateway async sink | `vehicle_async_sink_publish_total{sink,kind,status}` | `error` 不增长 |
|
||||
| Gateway async sink | `vehicle_async_sink_publish_duration_ms_histogram_bucket` | p99 不持续上升 |
|
||||
| Gateway | `vehicle_gateway_publish_total{status="ok"}` | 持续增长 |
|
||||
| Gateway | `vehicle_gateway_frame_duration_ms_histogram_bucket` | p99 小于容量目标 |
|
||||
| History writer | `vehicle_history_batch_flush_total{status}` | `error` 不增长 |
|
||||
| History writer | `vehicle_history_batch_rows_total{status}` | batch 行数持续增长 |
|
||||
| History writer | `vehicle_history_batch_pending_messages` | 稳态接近 0,burst 后下降 |
|
||||
| History writer | `vehicle_history_batch_pending_rows` | 稳态接近 0,burst 后下降 |
|
||||
| History writer | `vehicle_history_batch_flush_duration_ms{status}` | flush 延迟不持续上升 |
|
||||
| NATS bridge | `vehicle_bridge_nats_consumer_ack_pending` | 稳态为 0 |
|
||||
| NATS bridge | `vehicle_bridge_nats_consumer_pending` | burst 后下降 |
|
||||
| NATS bridge | `vehicle_bridge_batch_pending_messages` | 稳态接近 0,burst 后下降 |
|
||||
| NATS bridge | `vehicle_bridge_batch_duration_ms_histogram_bucket` | p99 不持续上升 |
|
||||
| NATS fast writer | `vehicle_fast_writer_nats_consumer_ack_pending` | 稳态为 0 |
|
||||
| NATS fast writer | `vehicle_fast_writer_nats_consumer_pending` | burst 后下降 |
|
||||
| NATS fast writer | `vehicle_fast_writer_batch_pending_messages` | 稳态接近 0,burst 后下降 |
|
||||
| NATS fast writer | `vehicle_fast_writer_batch_pending_envelopes` | 稳态接近 0,burst 后下降 |
|
||||
| NATS fast writer | `vehicle_fast_writer_stage_duration_ms_histogram_bucket` | TDengine/Redis/ack 各阶段 p99 不持续上升 |
|
||||
| Kafka consumers | `vehicle_*_kafka_lag` | 稳态为 0,burst 后下降 |
|
||||
| Realtime Redis | `vehicle_realtime_store_update_duration_ms_histogram_bucket{store="redis"}` | p99 毫秒级且不持续上升 |
|
||||
| Realtime MySQL | `vehicle_realtime_store_update_duration_ms_histogram_bucket{store="mysql"}` | p99 不持续上升 |
|
||||
| Realtime MySQL | async queue depth | 不持续增长 |
|
||||
| Stat writer | `vehicle_stat_write_duration_ms_histogram_bucket` | p99 不持续上升 |
|
||||
|
||||
## Retention Guardrails
|
||||
|
||||
10W 车辆接入时,中间件只承担解耦和短期缓冲职责,不能成为长期历史库。
|
||||
|
||||
| Component | Guardrail |
|
||||
| --- | --- |
|
||||
| NATS JetStream `VEHICLE_INGEST` | `NATS_STREAM_MAX_BYTES=21474836480`,`NATS_STREAM_MAX_AGE_HOURS=24`,`NATS_STREAM_ENSURE_TIMEOUT_SECONDS=60` |
|
||||
| Kafka `vehicle.raw.go.*` / `vehicle.fields.go.*` | `retention.ms=21600000`、`segment.ms=600000`、`segment.bytes=268435456` |
|
||||
| Fast writer | `FAST_WRITER_OPERATION_TIMEOUT_MS=1000`,避免 TDengine 批量写尾延迟导致整批重投 |
|
||||
| Root disk | 使用率低于 `80%`,超过 `85%` 进入容量告警 |
|
||||
|
||||
如果 NATS `consumer_pending` 下降但仍高,说明系统在追历史积压;如果 `ack_pending=0` 且 Kafka lag 为 `0`,不要重启服务,重点观察 NATS data size 和 pending 下降斜率。
|
||||
|
||||
## 分阶段压测
|
||||
|
||||
压测入口:
|
||||
|
||||
```bash
|
||||
# 100 连接 smoke test
|
||||
/opt/lingniu-go-native/current/load-sim \
|
||||
-protocol jt808 \
|
||||
-addr 127.0.0.1:808 \
|
||||
-connections 100 \
|
||||
-connect-rate 100 \
|
||||
-send-interval 10s \
|
||||
-duration 2m \
|
||||
-template 0200 \
|
||||
-send=false
|
||||
```
|
||||
|
||||
`load-sim` 在 `-send=true` 时会按连接和帧序号生成可被现有解析器解析的唯一协议帧:
|
||||
|
||||
- JT808 会变更包头手机号、流水号和 `0x0200` 位置时间,并重新计算转义和校验码。
|
||||
- GB32960 会变更 VIN 和实时数据时间,并重新计算 BCC。
|
||||
- 低 FPS 全链路压测必须使用该模式,避免重复静态帧导致 event id 冲突。
|
||||
- 对生产端口执行 `-send=true` 会写入合成 raw 数据,只能在明确隔离标识和压测窗口后执行。
|
||||
|
||||
1. 100 连接 smoke test。
|
||||
2. 10,000 连接保持测试。
|
||||
3. 10,000 连接 + 1 FPS/连接短测。
|
||||
4. 50,000 连接保持测试。
|
||||
5. 100,000 连接保持测试。
|
||||
6. 按真实协议分布进行混合帧率测试。
|
||||
|
||||
每一阶段必须记录:
|
||||
|
||||
- active connections
|
||||
- gateway frame/publish counters
|
||||
- NATS pending 和 ack pending
|
||||
- Kafka lag
|
||||
- Redis/MySQL/TDengine 写入耗时和 pending depth
|
||||
- CPU/load/memory/file descriptors
|
||||
- 错误日志
|
||||
|
||||
## 2026-07-03 阶段压测记录
|
||||
|
||||
压测方式:在 ECS 本机使用 `/opt/lingniu-go-native/current/load-sim` 对 `127.0.0.1:808` 发起 JT808 hold-only 连接,`-send=false`,不发送业务帧,不污染 raw 数据。
|
||||
|
||||
压测前基线,时间 `2026-07-03 18:03:10 CST`:
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| 服务状态 | 6 个 Go systemd 服务均 active |
|
||||
| JT808 连接 | 约 240 |
|
||||
| GB32960 连接 | 约 2 |
|
||||
| load average | `0.43 / 0.22 / 0.23` |
|
||||
| available memory | 约 `6661 MB` |
|
||||
| NATS ack pending | `0` |
|
||||
| NATS pending | 约 `39` |
|
||||
| history/stat/realtime Kafka lag | `0` |
|
||||
|
||||
阶段结果:
|
||||
|
||||
| 阶段 | 时间 | 参数 | 峰值连接/FD | 结果 | 资源和队列 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 100 连接 | `18:03:21-18:04:21 CST` | `connections=100 connect-rate=100 duration=60s send=false` | JT808 active `340`,`ss` 约 `341` | opened `100`,failed `0`,frames `0`,write_errors `0` | load 约 `0.33/0.21/0.22`,available memory 约 `6659 MB` |
|
||||
| 1,000 连接 | `18:04:38-18:05:38 CST` | `connections=1000 connect-rate=500 duration=60s send=false` | JT808 active `1240`,`ss` 约 `1241`,gateway FD 约 `1256` | opened `1000`,failed `0`,frames `0`,write_errors `0` | load 约 `0.80/0.33/0.26`,available memory 约 `6645 MB` |
|
||||
| 10,000 连接 | `18:05:59-18:07:59 CST` | `connections=10000 connect-rate=1000 duration=120s send=false` | JT808 active `10240`,`ss` 约 `10241`,gateway FD 约 `10256` | opened `10000`,failed `0`,frames `0`,write_errors `0` | 20 秒时 load 约 `0.45/0.33/0.27`,available memory 约 `6409 MB`,NATS ack pending `0` |
|
||||
| 50,000 连接 | `18:11:22-18:14:22 CST` | `connections=50000 connect-rate=2000 duration=180s send=false` | JT808 active `50238`,`ss` 约 `50239`,gateway FD 约 `50254` | opened `50000`,failed `0`,frames `0`,write_errors `0` | 45 秒时 load 约 `0.07/0.23/0.24`,available memory 约 `5590 MB`,NATS ack pending 短时 `29`,后续归零 |
|
||||
| 100,000 总连接 | `18:18:13-18:21:13 CST` | JT808 `50000` + GB32960 `50000`,各 `connect-rate=2000 duration=180s send=false` | JT808 active `50236`,GB32960 active `50002`,总 active 约 `100238`,gateway FD 约 `100252` | JT808 opened `50000` / failed `0`;GB32960 opened `50000` / failed `0`;frames `0`,write_errors `0` | 75 秒时 load 约 `0.16/0.30/0.29`,available memory 约 `4511 MB`,NATS ack pending 短时 `10`,后续归零 |
|
||||
|
||||
压测后:
|
||||
|
||||
- `2026-07-03 18:08:47 CST` JT808 active 回落到 `240`,`ss` 约 `241`。
|
||||
- `2026-07-03 18:15:36 CST` 5W 压测后 JT808 active 回落到约 `238`,`ss` 约 `239`。
|
||||
- `2026-07-03 18:16:49 CST` NATS ack pending 回到 `0`,history/stat/realtime Kafka lag 总和均为 `0`。
|
||||
- `2026-07-03 18:23:02 CST` 10W 总连接压测后 JT808 active 回落到 `236`,GB32960 active 回落到 `2`,NATS ack pending 为 `0`,history/stat/realtime Kafka lag 总和均为 `0`。
|
||||
- `20211/20212/20213/20214/20200` readyz 均 OK。
|
||||
- 压测窗口未出现 gateway `error|failed|panic|fatal|rejected` 日志。
|
||||
- `vehicle_gateway_connection_rejections_total` 未输出,表示本轮没有连接拒绝计数。
|
||||
|
||||
结论:单 ECS 已通过本机 10W 总连接 hold-only 基线测试。该结果证明当前内核参数、gateway 连接上限和 systemd 文件句柄设置可以承载 10W 空闲长连接;下一阶段必须进入带真实帧率的低 FPS 压测,验证解析、NATS、Kafka、Redis、TDengine、MySQL 全链路吞吐。
|
||||
91
docs/ops/go-service-observability.md
Normal file
91
docs/ops/go-service-observability.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Go Service Observability
|
||||
|
||||
## ECS
|
||||
|
||||
Host: `115.29.187.205`
|
||||
|
||||
The Go services expose local-only health and metrics endpoints. They are intended for ECS-local checks, Prometheus scraping through an agent, or SSH troubleshooting. They are not public business APIs.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Service | systemd unit | Address | Health | Readiness | Metrics |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Gateway | `lingniu-go-gateway.service` | `127.0.0.1:20211` | `/healthz` | `/readyz` | `/metrics` |
|
||||
| History writer | `lingniu-go-history-writer.service` | `127.0.0.1:20212` | `/healthz` | `/readyz` | `/metrics` |
|
||||
| Stat writer | `lingniu-go-stat-writer.service` | `127.0.0.1:20213` | `/healthz` | `/readyz` | `/metrics` |
|
||||
| NATS Kafka bridge | `lingniu-go-nats-kafka-bridge.service` | `127.0.0.1:20214` | `/healthz` | `/readyz` | `/metrics` |
|
||||
| Realtime API | `lingniu-go-realtime-api.service` | `0.0.0.0:20200` | `/healthz` | `/readyz` | `/metrics` |
|
||||
|
||||
## Quick Checks
|
||||
|
||||
生产事故处理顺序和阈值解释见 [车辆接入生产运行手册](vehicle-ingest-runbook.md)。
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:20211/readyz
|
||||
curl -fsS http://127.0.0.1:20211/metrics
|
||||
curl -fsS http://127.0.0.1:20212/readyz
|
||||
curl -fsS http://127.0.0.1:20213/readyz
|
||||
curl -fsS http://127.0.0.1:20214/readyz
|
||||
curl -fsS http://127.0.0.1:20200/readyz
|
||||
```
|
||||
|
||||
本机容量健康摘要:
|
||||
|
||||
```bash
|
||||
/opt/lingniu-go-native/current/capacity-check
|
||||
```
|
||||
|
||||
它会抓取 Gateway、History writer、Stat writer、NATS bridge、Realtime API、NATS fast writer 的本地 `/metrics`,输出 JSON。退出码 `0` 表示当前关键 backlog 和拒绝计数正常,退出码 `2` 表示存在 pending、Kafka lag、连接拒绝或 metrics 抓取失败,适合接入 cron/告警。
|
||||
|
||||
ECS 上通过 systemd timer 每分钟执行一次:
|
||||
|
||||
```bash
|
||||
systemctl status lingniu-go-capacity-check.timer
|
||||
systemctl list-timers lingniu-go-capacity-check.timer
|
||||
journalctl -u lingniu-go-capacity-check.service --since '10 minutes ago' --no-pager
|
||||
systemctl start lingniu-go-capacity-check.service
|
||||
```
|
||||
|
||||
`lingniu-go-capacity-check.service` 是 oneshot 服务。容量健康时退出码为 `0`;不健康时退出码为 `2`,timer 会保留 failed 结果,JSON findings 会写入 journal。
|
||||
|
||||
## Core Counters
|
||||
|
||||
| Metric | Meaning |
|
||||
| --- | --- |
|
||||
| `vehicle_gateway_active_connections` | Current TCP connections by protocol. Labels: `protocol`. |
|
||||
| `vehicle_gateway_connection_closes_total` | TCP connection closes by protocol and reason. Labels: `protocol`, `reason`. Reasons include `eof`, `read_timeout`, `read_error`, `extract_error`, `context_cancelled`, `max_connections`. |
|
||||
| `vehicle_gateway_connection_rejections_total` | TCP connection rejections by protocol and reason. Labels: `protocol`, `reason`. |
|
||||
| `vehicle_gateway_frames_total` | Protocol frames received and parsed by the gateway. Labels: `protocol`, `status`. |
|
||||
| `vehicle_gateway_frame_duration_ms` | Last observed Gateway frame handling duration and histogram buckets for p95/p99. Labels: `protocol`, `status`. |
|
||||
| `vehicle_gateway_publish_total` | Gateway publish result for raw events. Unified appears only when the compatibility stream is explicitly enabled. Labels: `protocol`, `kind`, `status`. |
|
||||
| `vehicle_async_sink_publish_duration_ms_histogram` | Async sink publish duration histogram covering worker publish calls to the delegate sink. Labels: `sink`, `kind`, `status`. |
|
||||
| `vehicle_bridge_messages_total` | NATS messages fetched by the bridge. Labels: `subject`, `status`. |
|
||||
| `vehicle_bridge_kafka_writes_total` | Bridge writes to Kafka. Labels: `topic`, `status`. |
|
||||
| `vehicle_bridge_nats_acks_total` | NATS ack results after Kafka write. Labels: `subject`, `status`. |
|
||||
| `vehicle_bridge_nats_consumer_pending` | JetStream messages pending for the bridge durable consumer. |
|
||||
| `vehicle_bridge_nats_consumer_ack_pending` | JetStream messages delivered to bridge but not yet acked. |
|
||||
| `vehicle_bridge_nats_consumer_waiting` | Pull requests waiting on the bridge durable consumer. |
|
||||
| `vehicle_bridge_batch_pending_messages` | NATS messages fetched by the bridge but not yet written to Kafka and acked. |
|
||||
| `vehicle_bridge_batch_pending_kafka_messages` | Kafka messages prepared for the current bridge batch. |
|
||||
| `vehicle_bridge_batch_duration_ms_histogram` | Bridge batch duration histogram covering Kafka write and NATS ack. Labels: `status`. |
|
||||
| `vehicle_fast_writer_nats_consumer_pending` | JetStream messages pending for the fast writer durable consumer. |
|
||||
| `vehicle_fast_writer_nats_consumer_ack_pending` | JetStream messages delivered to fast writer but not yet acked. |
|
||||
| `vehicle_fast_writer_nats_consumer_waiting` | Pull requests waiting on the fast writer durable consumer. |
|
||||
| `vehicle_fast_writer_batch_pending_messages` | NATS fast writer messages fetched but not yet written to TDengine/Redis and acked. |
|
||||
| `vehicle_fast_writer_batch_pending_envelopes` | Valid parsed envelopes in the current NATS fast writer batch. |
|
||||
| `vehicle_fast_writer_stage_duration_ms_histogram` | Fast writer stage duration histogram for TDengine, Redis, and NATS ack. The Redis stage is one batch pipeline when the repository supports batch updates. Labels: `subject`, `stage`, `status`. |
|
||||
| `vehicle_history_writes_total` | TDengine history writes. Labels: `topic`, `status`. |
|
||||
| `vehicle_history_batch_pending_messages` | Messages fetched by history writer but not yet flushed and committed. |
|
||||
| `vehicle_history_batch_pending_rows` | Parsed raw envelopes waiting in the current TDengine batch. |
|
||||
| `vehicle_history_batch_flush_duration_ms` | Last TDengine batch flush duration. Labels: `status`. |
|
||||
| `vehicle_stat_writes_total` | MySQL metric writes. Labels: `topic`, `status`. |
|
||||
| `vehicle_stat_write_duration_ms_histogram` | MySQL stat writer append duration histogram. Labels: `topic`, `status`. |
|
||||
| `vehicle_realtime_updates_total` | Redis/MySQL realtime projector updates. Labels: `topic`, `status`. |
|
||||
| `vehicle_realtime_store_update_duration_ms_histogram` | Redis/MySQL store update duration histogram. Labels: `store`, `protocol`, `status`. |
|
||||
| `vehicle_history_kafka_lag` | Estimated Kafka lag for history writer by topic and partition. |
|
||||
| `vehicle_stat_kafka_lag` | Estimated Kafka lag for stat writer by topic and partition. |
|
||||
| `vehicle_realtime_kafka_lag` | Estimated Kafka lag for realtime projector by topic and partition. |
|
||||
|
||||
## First Principle
|
||||
|
||||
These metrics are operational telemetry. They should stay out of MySQL and TDengine business tables unless a product query explicitly requires persisted aggregates. Persisted aggregate metrics should be derived from Kafka replay rather than by widening realtime or raw tables.
|
||||
541
docs/ops/go-vehicle-ingest-memory.md
Normal file
541
docs/ops/go-vehicle-ingest-memory.md
Normal file
@@ -0,0 +1,541 @@
|
||||
# Go 车辆接入上下文记忆
|
||||
|
||||
更新时间:2026-07-03
|
||||
|
||||
这份文档用于在 Codex 上下文重载后快速恢复项目状态。更完整的运行排查步骤见:
|
||||
|
||||
- `docs/ops/vehicle-ingest-runbook.md`
|
||||
- `docs/architecture/production-data-plane-inventory.md`
|
||||
|
||||
## 当前目标
|
||||
|
||||
Go 版本车辆数据接入链路已经作为生产主链路运行在 ECS `115.29.187.205`。
|
||||
|
||||
核心目标:
|
||||
|
||||
1. 高性能可靠接收 GB32960、JT808、宇通 MQTT。
|
||||
2. 协议解析后先写 NATS,再桥接 Kafka。
|
||||
3. Kafka raw 供 TDengine 历史、Redis 实时、MySQL 当前态/统计消费。
|
||||
4. 尽量避免 Java 老链路、Xinda 相关链路和重复存储。
|
||||
5. 解析字段只投影一次,避免 TDengine/Redis/MySQL 各自重复 flatten。
|
||||
6. 新长期目标:朝 10W 车辆生产稳定运行优化,容量基线见 `docs/ops/100k-capacity-baseline.md`。
|
||||
|
||||
2026-07-03 之后,`nats-fast-writer` 的快速路径是 TDengine 批写 + Redis 批量 pipeline:一个 NATS fetch batch 先批量写 TDengine,再用 `FastUpdateBatch` 把实时 KV、在线 TTL、last_seen 和协议索引合并到一次 Redis pipeline,最后逐条 ack NATS。
|
||||
|
||||
TDengine writer 的 raw/location 子表创建有进程内单飞保护:同一子表 key 并发首次写入时,只有一个 goroutine 执行 `CREATE TABLE IF NOT EXISTS` 和 tag 更新,其他 goroutine 等待结果后继续 INSERT,避免 10W 车辆启动或回放时对 TDengine 形成重复 DDL 风暴。
|
||||
|
||||
容量健康摘要工具:`/opt/lingniu-go-native/current/capacity-check` 会抓本机各 Go 服务 `/metrics` 并输出 JSON。关键 backlog、Kafka lag、连接拒绝或 metrics 抓取失败时退出码为 `2`,可以接 cron/告警。
|
||||
|
||||
## 服务和端口
|
||||
|
||||
ECS:`115.29.187.205`
|
||||
|
||||
| 服务 | systemd | 端口 | 作用 |
|
||||
| --- | --- | --- | --- |
|
||||
| Gateway | `lingniu-go-gateway.service` | `808`、`32960`、`20211` | JT808、GB32960、宇通 MQTT 接入和解析 |
|
||||
| NATS fast writer | `lingniu-go-nats-fast-writer.service` | 健康端口随 env | NATS 快速写 TDengine/Redis |
|
||||
| NATS Kafka bridge | `lingniu-go-nats-kafka-bridge.service` | `20214` | NATS 到 Kafka |
|
||||
| History writer | `lingniu-go-history-writer.service` | `20212` | Kafka raw 写 TDengine |
|
||||
| Stat writer | `lingniu-go-stat-writer.service` | `20213` | Kafka raw 写每日里程 |
|
||||
| Realtime API | `lingniu-go-realtime-api.service` | `20200` | Redis/MySQL 投影和 HTTP API |
|
||||
|
||||
业务端口:
|
||||
|
||||
- JT808:`0.0.0.0:808`
|
||||
- GB32960:`0.0.0.0:32960`
|
||||
- Swagger/API:`http://115.29.187.205:20200/swagger-ui/index.html`
|
||||
|
||||
## 部署路径
|
||||
|
||||
Go 服务使用 systemd 裸机部署,不使用 Docker。
|
||||
|
||||
```bash
|
||||
/opt/lingniu-go-native/current
|
||||
/opt/lingniu-go-native/releases/*
|
||||
/opt/lingniu-go-native/env/*.env
|
||||
```
|
||||
|
||||
最近 release:
|
||||
|
||||
```text
|
||||
/opt/lingniu-go-native/releases/100k-hardening-20260703175730
|
||||
/opt/lingniu-go-native/releases/parsed-fields-once-20260703163821
|
||||
```
|
||||
|
||||
服务重启:
|
||||
|
||||
```bash
|
||||
systemctl restart lingniu-go-gateway.service
|
||||
systemctl restart lingniu-go-nats-fast-writer.service
|
||||
systemctl restart lingniu-go-nats-kafka-bridge.service
|
||||
systemctl restart lingniu-go-history-writer.service
|
||||
systemctl restart lingniu-go-stat-writer.service
|
||||
systemctl restart lingniu-go-realtime-api.service
|
||||
```
|
||||
|
||||
健康检查:
|
||||
|
||||
```bash
|
||||
for port in 20211 20212 20213 20214 20200; do
|
||||
curl -fsS "http://127.0.0.1:${port}/readyz"
|
||||
echo
|
||||
done
|
||||
```
|
||||
|
||||
## 中间件
|
||||
|
||||
Kafka / NATS ECS:
|
||||
|
||||
- 公网:`114.55.58.251`
|
||||
- 内网:`172.17.111.56`
|
||||
- NATS:`172.17.111.56:4222`
|
||||
|
||||
TDengine:
|
||||
|
||||
- 内网:`172.17.111.57`
|
||||
- WS:`172.17.111.57:6041`
|
||||
- 数据库:`lingniu_vehicle_ts`
|
||||
|
||||
MySQL RDS:
|
||||
|
||||
- 内网:`rm-bp179zbv481rnw3e2.mysql.rds.aliyuncs.com:3306`
|
||||
- 数据库:`lingniu_vehicle_data`
|
||||
|
||||
Redis:
|
||||
|
||||
- 内网:`r-bp1u741kij7e51i481.redis.rds.aliyuncs.com:6379`
|
||||
- DB:`50`
|
||||
|
||||
不要在最终回答中明文输出生产密码。
|
||||
|
||||
## Topic
|
||||
|
||||
当前 Go raw topic:
|
||||
|
||||
```text
|
||||
vehicle.raw.go.gb32960.v1
|
||||
vehicle.raw.go.jt808.v1
|
||||
vehicle.raw.go.yutong-mqtt.v1
|
||||
```
|
||||
|
||||
fields topic 已存在:
|
||||
|
||||
```text
|
||||
vehicle.fields.go.gb32960.v1
|
||||
vehicle.fields.go.jt808.v1
|
||||
vehicle.fields.go.yutong-mqtt.v1
|
||||
```
|
||||
|
||||
当前核心链路仍以 raw topic 作为事实输入;raw envelope 里已经包含一次性预计算的 `parsed_fields`。
|
||||
|
||||
## 代码结构
|
||||
|
||||
主要目录:
|
||||
|
||||
```text
|
||||
go/vehicle-gateway/cmd/gateway
|
||||
go/vehicle-gateway/cmd/history-writer
|
||||
go/vehicle-gateway/cmd/realtime-api
|
||||
go/vehicle-gateway/cmd/stat-writer
|
||||
go/vehicle-gateway/cmd/nats-fast-writer
|
||||
go/vehicle-gateway/cmd/nats-kafka-bridge
|
||||
go/vehicle-gateway/internal/protocol/gb32960
|
||||
go/vehicle-gateway/internal/protocol/jt808
|
||||
go/vehicle-gateway/internal/protocol/yutongmqtt
|
||||
go/vehicle-gateway/internal/realtime
|
||||
go/vehicle-gateway/internal/history
|
||||
go/vehicle-gateway/internal/identity
|
||||
```
|
||||
|
||||
每次改代码后在本地跑:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## 解析和存储原则
|
||||
|
||||
### 一次解析字段
|
||||
|
||||
2026-07-03 已完成优化:
|
||||
|
||||
- `FrameEnvelope` 增加:
|
||||
- `parsed_fields`
|
||||
- `parsed_field_types`
|
||||
- Gateway 在发布 raw 前调用 `realtime.EnsureParsedFields(&env)`。
|
||||
- TDengine writer、Redis realtime KV、MySQL snapshot 优先使用 `env.ParsedFields`。
|
||||
- 旧消息没有 `parsed_fields` 时,保留 fallback,从 `Parsed` 展开一次。
|
||||
|
||||
相关文件:
|
||||
|
||||
```text
|
||||
go/vehicle-gateway/internal/envelope/envelope.go
|
||||
go/vehicle-gateway/internal/realtime/kv.go
|
||||
go/vehicle-gateway/internal/gateway/tcp_server.go
|
||||
go/vehicle-gateway/internal/gateway/mqtt_client.go
|
||||
go/vehicle-gateway/internal/history/writer.go
|
||||
go/vehicle-gateway/internal/realtime/repository.go
|
||||
go/vehicle-gateway/internal/realtime/snapshot_writer.go
|
||||
```
|
||||
|
||||
### GB32960 多帧
|
||||
|
||||
GB32960 有两类实际模式:
|
||||
|
||||
1. 单条 `0x02` 同时包含标准 data unit 和 vendor data unit。
|
||||
2. 标准帧加 vendor 栈分片帧。
|
||||
|
||||
例子:
|
||||
|
||||
- `LB9A32A24R0LS1426`:单帧完整混合上报,单帧约 `774 bytes`,`gd_fc_stack.cell_count=108`。
|
||||
- `LNXNEGRR7SR318212`:多帧上报,`gd_fc_stack.cell_count=432`,按 `200 + 200 + 32` 分片。
|
||||
|
||||
TDengine raw_frames 必须保持单帧事实,不跨帧合并。
|
||||
|
||||
Redis/MySQL 实时视图可以按 `protocol + vin` 增量合并,但不能让后来的 stack-only 帧覆盖标准车辆字段。
|
||||
|
||||
### JT808
|
||||
|
||||
JT808 主入口端口是 `808`,不再使用 `8089`。
|
||||
|
||||
解析重点:
|
||||
|
||||
- 包头手机号按 BCD 去前导 0。
|
||||
- `0x0100` 注册帧写 `jt808_registration`。
|
||||
- `0x0102` 鉴权帧尝试按 phone 查 registration/binding 关联 VIN。
|
||||
- `0x0200` 位置帧即使没有注册/鉴权,也会按 phone 降级查 `vehicle_identity_binding`,建立 session。
|
||||
- 位置附加信息 `0x01` 是 GPS 总里程,按差值用于每日里程。
|
||||
|
||||
## MySQL 身份表
|
||||
|
||||
`vehicle_identity_binding` 是外部维护事实表,服务只读,不允许运行时写入。
|
||||
|
||||
核心字段:
|
||||
|
||||
```text
|
||||
vin
|
||||
plate
|
||||
phone
|
||||
oem
|
||||
updated_at
|
||||
```
|
||||
|
||||
用途:
|
||||
|
||||
- JT808 用 phone 或 plate 找 VIN。
|
||||
- Realtime snapshot/location 用 VIN 反查 plate。
|
||||
- 后续导入 G7s、广安车联等来源时更新 phone/oem。
|
||||
|
||||
`jt808_registration` 是 808 注册鉴权运行状态表,以 phone 为主键,记录注册、鉴权、source_endpoint、关联 VIN 状态。
|
||||
|
||||
## 2026-07-03 数据维护记录
|
||||
|
||||
### G7s binding
|
||||
|
||||
源文件:
|
||||
|
||||
```text
|
||||
/Users/lingniu/Library/Mobile Documents/com~apple~CloudDocs/rsync/2026_07_02_17_11_37转发配置列表-1(2).xlsx
|
||||
```
|
||||
|
||||
处理规则:
|
||||
|
||||
1. 删除原有 G7/G7s 的 `phone/oem`。
|
||||
2. 使用文件中的 `手机号` 列更新。
|
||||
3. 冲突的不更新,其他更新。
|
||||
|
||||
执行结果:
|
||||
|
||||
- 清空旧 G7/G7s:`666` 行。
|
||||
- 更新安全 G7s:`561` 行。
|
||||
- 跳过:`72` 行。
|
||||
- 缺 plate 或 VIN:`14`
|
||||
- 与非 G7/G7s 绑定冲突:`58`
|
||||
|
||||
跳过清单:
|
||||
|
||||
```text
|
||||
outputs/g7s_binding_skipped_apply.csv
|
||||
```
|
||||
|
||||
### JT808 unknown VIN 导出
|
||||
|
||||
导出条件:
|
||||
|
||||
- `jt808_registration.vin` 为空、`unknown` 或 `unknow`
|
||||
- 且 `phone` 不存在于 `vehicle_identity_binding.phone`
|
||||
|
||||
结果:
|
||||
|
||||
- `30` 个 phone
|
||||
|
||||
导出文件:
|
||||
|
||||
```text
|
||||
outputs/jt808_unknown_vin_phone_not_in_binding.csv
|
||||
```
|
||||
|
||||
## 常用查询
|
||||
|
||||
历史 raw frame:
|
||||
|
||||
```bash
|
||||
curl -s 'http://115.29.187.205:20200/api/history/raw-frames?protocol=GB32960&vin=LB9A32A24R0LS1426&limit=1&includeFields=true'
|
||||
```
|
||||
|
||||
只取指定 parsed fields:
|
||||
|
||||
```bash
|
||||
curl -s 'http://115.29.187.205:20200/api/history/raw-frames?protocol=GB32960&vin=LB9A32A24R0LS1426&limit=1&includeFields=true&fields=gb32960.vehicle.speed_kmh&fields=gb32960.gd_fc_stack.stack_water_outlet_temp_c'
|
||||
```
|
||||
|
||||
808 源 IP:
|
||||
|
||||
```bash
|
||||
journalctl -u lingniu-go-gateway.service --since '10 minutes ago' --no-pager
|
||||
ss -tn sport = :808
|
||||
```
|
||||
|
||||
最近已观察到 808 源 IP:
|
||||
|
||||
```text
|
||||
115.159.85.149
|
||||
222.66.200.68
|
||||
122.152.221.156
|
||||
115.231.168.135
|
||||
```
|
||||
|
||||
## 部署步骤
|
||||
|
||||
本地构建:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-deploy/gateway ./cmd/gateway
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-deploy/history-writer ./cmd/history-writer
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-deploy/realtime-api ./cmd/realtime-api
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-deploy/stat-writer ./cmd/stat-writer
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-deploy/nats-fast-writer ./cmd/nats-fast-writer
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-deploy/nats-kafka-bridge ./cmd/nats-kafka-bridge
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /tmp/lingniu-go-deploy/capacity-check ./cmd/capacity-check
|
||||
```
|
||||
|
||||
上传部署:
|
||||
|
||||
```bash
|
||||
release="name-$(date +%Y%m%d%H%M%S)"
|
||||
ssh root@115.29.187.205 "mkdir -p /opt/lingniu-go-native/releases/$release"
|
||||
scp /tmp/lingniu-go-deploy/* root@115.29.187.205:/opt/lingniu-go-native/releases/$release/
|
||||
ssh root@115.29.187.205 "chmod 755 /opt/lingniu-go-native/releases/$release/* && ln -sfn /opt/lingniu-go-native/releases/$release /opt/lingniu-go-native/current"
|
||||
```
|
||||
|
||||
部署后验证:
|
||||
|
||||
```bash
|
||||
ssh root@115.29.187.205 '
|
||||
systemctl is-active lingniu-go-gateway.service lingniu-go-nats-fast-writer.service lingniu-go-nats-kafka-bridge.service lingniu-go-history-writer.service lingniu-go-stat-writer.service lingniu-go-realtime-api.service
|
||||
for port in 20211 20212 20213 20214 20200; do curl -fsS "http://127.0.0.1:${port}/readyz"; echo; done
|
||||
journalctl -u lingniu-go-gateway.service --since "2 minutes ago" --no-pager | grep -Ei "error|failed|panic|fatal" || true
|
||||
'
|
||||
```
|
||||
|
||||
## 2026-07-03 10W 接入优化部署记录
|
||||
|
||||
Release:
|
||||
|
||||
```text
|
||||
/opt/lingniu-go-native/releases/100k-hardening-20260703175730
|
||||
```
|
||||
|
||||
包含:
|
||||
|
||||
- Gateway 默认 `TCP_MAX_CONNECTIONS=120000`。
|
||||
- 新增 `vehicle_gateway_connection_rejections_total`。
|
||||
- 新增 `/opt/lingniu-go-native/current/load-sim` 运维压测工具。
|
||||
- Redis-first realtime projector 增加 MySQL async queue dropped 回归测试。
|
||||
|
||||
ECS 已落地内核参数:
|
||||
|
||||
```text
|
||||
net.core.somaxconn = 65535
|
||||
net.ipv4.tcp_max_syn_backlog = 65535
|
||||
net.ipv4.ip_local_port_range = 10000 65000
|
||||
net.ipv4.tcp_tw_reuse = 1
|
||||
```
|
||||
|
||||
注意:远端 `/etc/sysctl.conf` 原本有 `net.ipv4.tcp_max_syn_backlog = 1024`,已改为 `65535`,同时保留 `/etc/sysctl.d/zz-lingniu-vehicle-ingest.conf`。
|
||||
|
||||
部署后验证:
|
||||
|
||||
- `current` 指向 `100k-hardening-20260703175730`。
|
||||
- `20211/20212/20213/20214/20200` readyz 均为 ok。
|
||||
- `808`、`32960`、`20200`、`20211-20214` 监听 backlog 均显示 `65535`。
|
||||
- Gateway 重启后持续接收 GB32960、JT808、Yutong MQTT 帧。
|
||||
- 重启完成后窗口未发现 gateway/realtime-api 持续 error/fatal 日志。
|
||||
|
||||
### 10W 阶段压测进展
|
||||
|
||||
2026-07-03 已完成 ECS 本机 JT808 hold-only 阶段压测,不发送业务帧:
|
||||
|
||||
| 阶段 | 结果 |
|
||||
| --- | --- |
|
||||
| 100 连接 / 60s | opened `100`,failed `0`,frames `0`,write_errors `0` |
|
||||
| 1,000 连接 / 60s | opened `1000`,failed `0`,frames `0`,write_errors `0` |
|
||||
| 10,000 连接 / 120s | opened `10000`,failed `0`,frames `0`,write_errors `0` |
|
||||
| 50,000 连接 / 180s | opened `50000`,failed `0`,frames `0`,write_errors `0` |
|
||||
| 100,000 总连接 / 180s | JT808 opened `50000`、GB32960 opened `50000`,failed `0`,frames `0`,write_errors `0` |
|
||||
|
||||
1W 阶段峰值约 `10240` JT808 active connections,gateway FD 约 `10256`,NATS ack pending 为 `0`,压测后连接回落到生产基线约 `240`,readyz 全部 OK。
|
||||
|
||||
5W 阶段峰值约 `50238` JT808 active connections,gateway FD 约 `50254`,available memory 约 `5590 MB`。NATS ack pending 中间短时到 `29`,`18:16:49 CST` 回到 `0`;history/stat/realtime Kafka lag 总和均为 `0`。压测后 JT808 active 回落到生产基线约 `238-240`,readyz 全部 OK。
|
||||
|
||||
10W 总连接阶段使用双端口 loopback:JT808 `50000` + GB32960 `50000`,均 `-send=false`。峰值约 `100238` active connections,gateway FD 约 `100252`,available memory 约 `4511 MB`。NATS ack pending 中间短时到 `10`,`18:23:02 CST` 回到 `0`;history/stat/realtime Kafka lag 总和均为 `0`。压测后 JT808 回落到约 `236`,GB32960 回落到约 `2`,readyz 全部 OK。
|
||||
|
||||
### Gateway async publish 指标
|
||||
|
||||
2026-07-03 已部署 gateway async sink 指标到 ECS 当前 release:
|
||||
|
||||
```text
|
||||
vehicle_async_sink_enqueue_total{sink,kind,status}
|
||||
vehicle_async_sink_publish_total{sink,kind,status}
|
||||
vehicle_async_sink_publish_duration_ms{sink,kind,status}
|
||||
vehicle_async_sink_publish_duration_ms_histogram_bucket{sink,kind,status,le}
|
||||
vehicle_async_sink_publish_duration_ms_histogram_count{sink,kind,status}
|
||||
vehicle_async_sink_publish_duration_ms_histogram_sum{sink,kind,status}
|
||||
vehicle_async_sink_queue_depth{sink}
|
||||
```
|
||||
|
||||
部署后验证:
|
||||
|
||||
- `vehicle_async_sink_queue_depth{sink="nats"} 0`
|
||||
- `vehicle_async_sink_enqueue_total{kind="raw",sink="nats",status="queued"}` 持续增长
|
||||
- `vehicle_async_sink_publish_total{kind="raw",sink="nats",status="ok"}` 持续增长
|
||||
- NATS bridge ack pending 为 `0`
|
||||
- Gateway 启动后窗口无 `error|failed|panic|fatal` 日志
|
||||
|
||||
这些指标是进入带帧率压测前的关键保护栏:如果 `queue_depth` 持续增长、`enqueue_total{status="timeout"}` 增长,或 `vehicle_async_sink_publish_duration_ms_histogram_bucket` 的 p99 持续上升,说明入口 publish 队列或下游 NATS/Kafka 写入已成为瓶颈。
|
||||
|
||||
### Gateway connection close reason
|
||||
|
||||
2026-07-03 已为 TCP gateway 增加连接关闭原因计数:
|
||||
|
||||
```text
|
||||
vehicle_gateway_connection_closes_total{protocol,reason}
|
||||
```
|
||||
|
||||
当前 `reason` 包括 `eof`、`read_timeout`、`read_error`、`extract_error`、`context_cancelled`、`max_connections`。10W 长连接和带帧率压测时,应重点关注 `read_error`、`extract_error`、`max_connections` 是否异常增长;`read_timeout` 需要结合协议上报周期和在线数判断。
|
||||
|
||||
### NATS fast writer stage histogram
|
||||
|
||||
2026-07-03 已为 NATS fast writer 增加阶段耗时 histogram:
|
||||
|
||||
```text
|
||||
vehicle_fast_writer_stage_duration_ms_histogram_bucket{subject,stage,status,le}
|
||||
vehicle_fast_writer_stage_duration_ms_histogram_count{subject,stage,status}
|
||||
vehicle_fast_writer_stage_duration_ms_histogram_sum{subject,stage,status}
|
||||
```
|
||||
|
||||
`stage` 取值包括 `tdengine`、`redis`、`ack`,分别对应快速写 TDengine raw/location、Redis realtime 投影、NATS ack。带帧率压测时如果某个 stage 的 p99 持续上升,可以直接定位 fast path 是存储瓶颈还是 ack/网络瓶颈。
|
||||
|
||||
2026-07-03 后续优化:`nats-fast-writer` 已从“Fetch batch 后逐条写 TDengine/Redis”改为“Fetch batch 后先调用 `history.Writer.AppendAllBatch` 批量写 TDengine,再调用 `realtime.Repository.FastUpdateBatch` 用一次 Redis pipeline 写实时 KV/在线状态/索引,最后逐条 ack NATS”。这样可以同时减少 TDengine 和 Redis round trip;ack 仍按单条执行,避免存储失败时误 ack。
|
||||
|
||||
2026-07-03 后续优化:`nats-fast-writer` 的 TDengine SQL 连接池已从固定 `1` 改为可配置:
|
||||
|
||||
```text
|
||||
FAST_WRITER_TDENGINE_MAX_OPEN_CONNS
|
||||
FAST_WRITER_TDENGINE_MAX_IDLE_CONNS
|
||||
```
|
||||
|
||||
默认 `max_open=1`、`max_idle=1`,生产可以通过 env 小步放大。此前实测默认跟随 worker 放大到 8 会导致 `[0x200] db is not specified` / `[0x2616] Database not specified`,根因是 TDengine `USE <database>` 不会自动作用到 `database/sql` 新建连接。2026-07-03 已将 fast-writer 使用的 history writer 改为生成 `database.table` 形式的 database-qualified SQL,并在 ECS 上将 pool 调到 `2/2` 验证通过:未再出现选库错误,写入 ok 计数持续增长。
|
||||
|
||||
同时新增 batch pending gauge:
|
||||
|
||||
```text
|
||||
vehicle_fast_writer_nats_consumer_pending{stream,consumer}
|
||||
vehicle_fast_writer_nats_consumer_ack_pending{stream,consumer}
|
||||
vehicle_fast_writer_nats_consumer_waiting{stream,consumer}
|
||||
vehicle_fast_writer_batch_pending_messages
|
||||
vehicle_fast_writer_batch_pending_envelopes
|
||||
```
|
||||
|
||||
`vehicle_fast_writer_nats_consumer_*` 表示 JetStream durable consumer 层面的 backlog/ack-pending;`vehicle_fast_writer_batch_pending_*` 表示 fast-writer 已拉取但尚未完成 TDengine/Redis/ack 的批内压力。带帧率压测时它们应在稳态接近 0,burst 后能够回落;如果持续非 0,需要结合 `vehicle_fast_writer_stage_duration_ms_histogram` 判断卡在 NATS 拉取、TDengine、Redis 还是 ack。
|
||||
|
||||
### NATS Kafka bridge batch 指标
|
||||
|
||||
2026-07-03 已为 NATS -> Kafka bridge 增加 batch pending 和 duration histogram:
|
||||
|
||||
```text
|
||||
vehicle_bridge_batch_pending_messages
|
||||
vehicle_bridge_batch_pending_kafka_messages
|
||||
vehicle_bridge_batch_duration_ms_histogram_bucket{status,le}
|
||||
vehicle_bridge_batch_duration_ms_histogram_count{status}
|
||||
vehicle_bridge_batch_duration_ms_histogram_sum{status}
|
||||
```
|
||||
|
||||
该耗时覆盖一批 NATS 消息路由到 Kafka、Kafka 同步写入、NATS ack 的完整过程。带帧率压测时,如果 pending 持续非 0 或 duration p99 持续上升,应优先排查 Kafka broker、bridge batch size、NATS ack-pending 和网络。
|
||||
|
||||
### Realtime store update histogram
|
||||
|
||||
2026-07-03 已为 realtime-api Redis/MySQL store update 增加 duration histogram:
|
||||
|
||||
```text
|
||||
vehicle_realtime_store_update_duration_ms_histogram_bucket{store,protocol,status,le}
|
||||
vehicle_realtime_store_update_duration_ms_histogram_count{store,protocol,status}
|
||||
vehicle_realtime_store_update_duration_ms_histogram_sum{store,protocol,status}
|
||||
```
|
||||
|
||||
该耗时覆盖 Redis 快速实时投影和 MySQL 异步当前态/位置投影的实际写入耗时。带帧率压测时,Redis p99 上升会直接影响 realtime consumer 追平;MySQL p99 上升需要结合 `vehicle_realtime_async_queue_depth` 和 `vehicle_realtime_async_queue_total{status="dropped"}` 判断是否开始丢弃低优先级当前态写入。
|
||||
|
||||
### Stat writer write histogram
|
||||
|
||||
2026-07-03 已为 stat-writer MySQL append 增加 duration histogram:
|
||||
|
||||
```text
|
||||
vehicle_stat_write_duration_ms_histogram_bucket{topic,status,le}
|
||||
vehicle_stat_write_duration_ms_histogram_count{topic,status}
|
||||
vehicle_stat_write_duration_ms_histogram_sum{topic,status}
|
||||
```
|
||||
|
||||
该耗时覆盖单条 Kafka 消息解析后写入 `vehicle_daily_mileage` 的 MySQL append 路径。带帧率压测时,如果该 p99 持续上升并伴随 `vehicle_stat_kafka_lag` 增长,优先排查 MySQL 写入、每日里程表索引和 stat-writer 消费能力。
|
||||
|
||||
### Gateway frame duration histogram
|
||||
|
||||
2026-07-03 已为 Gateway 帧处理耗时增加 histogram:
|
||||
|
||||
```text
|
||||
vehicle_gateway_frame_duration_ms_histogram_bucket{protocol,status,le}
|
||||
vehicle_gateway_frame_duration_ms_histogram_count{protocol,status}
|
||||
vehicle_gateway_frame_duration_ms_histogram_sum{protocol,status}
|
||||
```
|
||||
|
||||
该耗时覆盖单帧从解析、identity resolve、parsed fields 生成、publish enqueue 到协议响应写入的整体入口处理路径。带帧率压测时用它按协议观察 p95/p99,不能只看 `vehicle_gateway_frame_duration_ms` 最后一条 gauge。
|
||||
|
||||
### History writer TDengine 批写
|
||||
|
||||
2026-07-03 已实现 history-writer 第一阶段批写:
|
||||
|
||||
- `history.Writer.AppendAllBatch` 按 TDengine child table 聚合 raw/location 多行 `INSERT`。
|
||||
- `cmd/history-writer` 默认 `HISTORY_BATCH_SIZE=200`、`HISTORY_BATCH_WAIT_MS=100`。
|
||||
- TDengine batch 成功后才批量提交 Kafka messages;失败不提交 offset。
|
||||
- 保留单条 `AppendAll` 路径作为回退。
|
||||
|
||||
新增指标:
|
||||
|
||||
```text
|
||||
vehicle_history_batch_flush_total{status}
|
||||
vehicle_history_batch_rows_total{status}
|
||||
vehicle_history_batch_pending_messages
|
||||
vehicle_history_batch_pending_rows
|
||||
vehicle_history_batch_flush_duration_ms{status}
|
||||
```
|
||||
|
||||
进入带帧率压测时,必须同时观察 `vehicle_history_kafka_lag`、`vehicle_history_batch_pending_*` 和 `vehicle_history_batch_*`。如果 pending 持续非 0 但 Kafka lag 尚未放大,说明 history-writer 已经在本地批写或提交阶段形成早期积压。
|
||||
|
||||
## 下次恢复上下文时先做
|
||||
|
||||
1. 读本文件。
|
||||
2. 读 `docs/ops/vehicle-ingest-runbook.md`。
|
||||
3. 读 `docs/architecture/production-data-plane-inventory.md`。
|
||||
4. 执行 `git status --short`。
|
||||
5. 如果涉及生产,先查 ECS 服务健康和最近日志。
|
||||
266
docs/ops/vehicle-ingest-runbook.md
Normal file
266
docs/ops/vehicle-ingest-runbook.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# 车辆接入生产运行手册
|
||||
|
||||
## 范围
|
||||
|
||||
本文覆盖 ECS `115.29.187.205` 上的 Go 原生车辆接入链路:
|
||||
|
||||
- GB32960 TCP 接入
|
||||
- JT/T 808 TCP 接入
|
||||
- 宇通 MQTT 接入
|
||||
- NATS 到 Kafka 桥接
|
||||
- Kafka 历史、统计、实时消费者
|
||||
- Redis 实时缓存、MySQL 实时表、TDengine 历史表
|
||||
|
||||
运行手册的目标是按自上而下的顺序定位问题:入口、队列、桥接、消费、存储、查询 API。
|
||||
|
||||
当前生产服务、topic、表和 Redis key 的清单见 [生产数据面清单](../architecture/production-data-plane-inventory.md)。
|
||||
|
||||
10W 车辆容量目标、当前缺口和压测口径见 [100K 车辆接入容量基线](100k-capacity-baseline.md)。
|
||||
|
||||
## 服务地图
|
||||
|
||||
| 层级 | 服务 | systemd 单元 | 本机端点 |
|
||||
| --- | --- | --- | --- |
|
||||
| 接入 | Gateway | `lingniu-go-gateway.service` | `127.0.0.1:20211` |
|
||||
| 桥接 | NATS Kafka bridge | `lingniu-go-nats-kafka-bridge.service` | `127.0.0.1:20214` |
|
||||
| 历史 | TDengine writer | `lingniu-go-history-writer.service` | `127.0.0.1:20212` |
|
||||
| 统计 | MySQL stat writer | `lingniu-go-stat-writer.service` | `127.0.0.1:20213` |
|
||||
| 实时 | Realtime API/projector | `lingniu-go-realtime-api.service` | `127.0.0.1:20200` |
|
||||
|
||||
## Topic 配置基线
|
||||
|
||||
生产环境的 env 文件必须和 Go topic 命名空间保持一致:
|
||||
|
||||
| 服务 | 必要 topic |
|
||||
| --- | --- |
|
||||
| `lingniu-go-history-writer.service` | `vehicle.raw.go.gb32960.v1`、`vehicle.raw.go.jt808.v1`、`vehicle.raw.go.yutong-mqtt.v1` |
|
||||
| `lingniu-go-stat-writer.service` | `vehicle.raw.go.gb32960.v1`、`vehicle.raw.go.jt808.v1`、`vehicle.raw.go.yutong-mqtt.v1` |
|
||||
| `lingniu-go-realtime-api.service` | `vehicle.raw.go.gb32960.v1`、`vehicle.raw.go.jt808.v1`、`vehicle.raw.go.yutong-mqtt.v1` |
|
||||
| `lingniu-go-nats-kafka-bridge.service` | NATS raw subjects 到同名 Kafka topic;`vehicle.event.go.unified.v1` 只在兼容开关打开时使用 |
|
||||
|
||||
如果 stat-writer 少消费某个 raw topic,对应协议的每日里程不会进入 `vehicle_daily_mileage`。修正后可能出现短时间 Kafka lag,这是在追补历史 backlog;只要 `vehicle_stat_writes_total` 持续增长且 lag 下降,就是健康状态。
|
||||
|
||||
Realtime API 是当前态投影,默认在没有已提交 offset 时从 latest 开始消费。切换 topic 或新建 consumer group 后,不应让 realtime 追扫历史 raw backlog;需要重建当前态时,应使用明确的回放任务或手动 reset offset。
|
||||
|
||||
业务端口:
|
||||
|
||||
- GB32960 TCP:`0.0.0.0:32960`
|
||||
- JT/T 808 TCP:`0.0.0.0:808`
|
||||
- 实时/API:`0.0.0.0:20200`
|
||||
|
||||
## 五分钟排查顺序
|
||||
|
||||
先回答最核心的问题:数据有没有进来,有没有排队,有没有被消费,最后有没有被查询到。
|
||||
|
||||
1. 检查所有服务 `/readyz`。
|
||||
2. 检查 gateway 帧计数和 TCP 活跃连接。
|
||||
3. 检查 NATS bridge 的 pending 和 ack-pending。
|
||||
4. 检查 bridge 写 Kafka 与 NATS ack 是否同时增长。
|
||||
5. 检查 history、stat、realtime 三类 Kafka consumer lag。
|
||||
6. 检查各 writer 的写入、提交、更新计数。
|
||||
7. 最后再查存储和业务查询 API。
|
||||
|
||||
```bash
|
||||
for port in 20211 20212 20213 20214 20200; do
|
||||
curl -fsS "http://127.0.0.1:${port}/readyz"
|
||||
echo
|
||||
done
|
||||
|
||||
curl -fsS http://127.0.0.1:20211/metrics \
|
||||
| grep -E 'vehicle_gateway_(active_connections|frames_total|publish_total)|vehicle_async_sink'
|
||||
|
||||
curl -fsS http://127.0.0.1:20214/metrics \
|
||||
| grep -E 'vehicle_bridge_(nats_consumer|kafka_writes_total|nats_acks_total)'
|
||||
|
||||
curl -fsS http://127.0.0.1:20212/metrics | grep vehicle_history_kafka_lag
|
||||
curl -fsS http://127.0.0.1:20212/metrics | grep vehicle_history_batch
|
||||
curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_kafka_lag
|
||||
curl -fsS http://127.0.0.1:20200/metrics | grep vehicle_realtime_kafka_lag
|
||||
|
||||
/opt/lingniu-go-native/current/capacity-check
|
||||
```
|
||||
|
||||
## 健康基线
|
||||
|
||||
当前生产环境的健康特征:
|
||||
|
||||
- 所有 `/readyz` 都返回 `status=ok`。
|
||||
- `vehicle_bridge_nats_consumer_ack_pending` 为 `0`。
|
||||
- history、stat、realtime 的 Kafka lag 为 `0` 或短时间小幅波动后归零。
|
||||
- `capacity-check` 返回 `status=ok` 且退出码为 `0`。
|
||||
- gateway 的帧计数持续增长。
|
||||
- bridge 的 Kafka write 和 NATS ack 计数同时增长。
|
||||
- writer 的成功计数增长,同时 Kafka lag 不持续扩大。
|
||||
|
||||
定时容量巡检由 systemd timer 触发:
|
||||
|
||||
```bash
|
||||
systemctl status lingniu-go-capacity-check.timer
|
||||
journalctl -u lingniu-go-capacity-check.service --since '10 minutes ago' --no-pager
|
||||
```
|
||||
|
||||
如果 `lingniu-go-capacity-check.service` 失败,先看 journal JSON 里的 `findings`,再按对应层级处理。
|
||||
|
||||
GB32960 和 JT/T 808 的活跃连接数受上游平台连接方式影响,不能直接等同于车辆数。突然归零或持续异常下降才是信号。
|
||||
|
||||
## 压测入口
|
||||
|
||||
Go 版本提供 `cmd/load-sim` 用于阶段性连接和帧写入压测。压测生产入口前必须先确认上游真实数据窗口,避免和业务流量混淆。
|
||||
|
||||
```bash
|
||||
cd /opt/lingniu-go-native/current/go/vehicle-gateway
|
||||
|
||||
go run ./cmd/load-sim \
|
||||
-protocol jt808 \
|
||||
-addr 127.0.0.1:808 \
|
||||
-connections 100 \
|
||||
-connect-rate 100 \
|
||||
-send-interval 10s \
|
||||
-duration 2m \
|
||||
-template 0200 \
|
||||
-send=false
|
||||
```
|
||||
|
||||
## 告警阈值建议
|
||||
|
||||
| 信号 | 建议阈值 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `/readyz` 非 ok | 立即处理 | 服务或依赖不可用。 |
|
||||
| Gateway 活跃连接 | 预期有流量时某协议降为 `0` | 上游网络、监听端口或进程可能异常。 |
|
||||
| `vehicle_gateway_connection_closes_total{reason="read_error"}` | 连续增长 | 入口 TCP 读失败,优先查网络、客户端断连和内核连接状态。 |
|
||||
| `vehicle_gateway_connection_closes_total{reason="extract_error"}` | 连续增长 | 报文边界或协议提取异常,优先抽查 raw 日志和协议 extractor。 |
|
||||
| `vehicle_gateway_connection_closes_total{reason="read_timeout"}` | 突然高于历史基线 | 车辆长时间无上报或链路空闲超时,需结合在线数和上游平台状态判断。 |
|
||||
| `vehicle_gateway_frames_total{status!="OK"}` | 连续 2 分钟增长 | 解析器或上游报文质量异常。 |
|
||||
| `vehicle_gateway_frame_duration_ms_histogram_bucket` | p99 连续 5 分钟超过容量目标 | Gateway parse、identity resolve、publish enqueue 或响应链路变慢。 |
|
||||
| `vehicle_async_sink_queue_depth{sink="nats"}` | 持续增长且不回落 | Gateway 到 NATS/Kafka 的异步 publish 队列开始积压。 |
|
||||
| `vehicle_async_sink_enqueue_total{status="timeout"}` | 任意增长 | Gateway publish 队列已满或 worker 长时间阻塞,入口可能开始丢实时性。 |
|
||||
| `vehicle_async_sink_publish_total{status="error"}` | 连续增长 | NATS/Kafka publish 失败,需要先查中间件连接和日志。 |
|
||||
| `vehicle_async_sink_publish_duration_ms_histogram_bucket` | p99 连续 5 分钟上升 | Gateway async worker 写 NATS/Kafka 变慢,通常会带动 queue depth 增长。 |
|
||||
| `vehicle_history_batch_flush_total{status="error"}` | 任意增长 | TDengine 批写失败;Kafka offset 不会提交,应先查 TDengine 和 SQL 错误。 |
|
||||
| `vehicle_history_batch_pending_messages` | 持续非 0 或 burst 后不回落 | history-writer 已拉取但未完成写入/提交,可能卡在 TDengine 或 Kafka commit。 |
|
||||
| `vehicle_history_batch_pending_rows` | 持续非 0 或 burst 后不回落 | TDengine 有批量写入积压,通常早于 Kafka lag 放大。 |
|
||||
| `vehicle_history_batch_flush_duration_ms{status="ok"}` | 持续上升 | TDengine 写入延迟增加,可能需要降低 batch size 或扩容 TDengine。 |
|
||||
| `vehicle_bridge_nats_consumer_ack_pending` | 连续 2 分钟 `> 0` | 消息已投递给 bridge,但 Kafka 写入后未完成 ack。 |
|
||||
| `vehicle_bridge_nats_consumer_pending` | 持续增长且 `> 10000` | bridge 消费 NATS 的速度跟不上生产速度。 |
|
||||
| `vehicle_bridge_batch_pending_messages` | 持续非 0 或 burst 后不回落 | bridge 已拉取 NATS 消息但尚未完成 Kafka 写入和 NATS ack。 |
|
||||
| `vehicle_bridge_batch_duration_ms_histogram_bucket` | p99 连续 5 分钟上升 | Kafka 写入或 NATS ack 开始变慢,通常会先于 ack-pending 扩大。 |
|
||||
| `vehicle_fast_writer_nats_consumer_ack_pending` | 连续 2 分钟 `> 0` | 消息已投递给 fast-writer,但 TDengine/Redis 写入后未完成 ack。 |
|
||||
| `vehicle_fast_writer_nats_consumer_pending` | 持续增长且 `> 10000` | fast-writer 消费 NATS 的速度跟不上入口写入速度。 |
|
||||
| `vehicle_fast_writer_batch_pending_messages` | 持续非 0 或 burst 后不回落 | fast-writer 已拉取 NATS 消息但尚未完成 TDengine/Redis 写入和 ack。 |
|
||||
| `vehicle_fast_writer_batch_pending_envelopes` | 持续非 0 或 burst 后不回落 | fast-writer 当前批次已有有效 envelope 在等待落库或 ack。 |
|
||||
| `vehicle_fast_writer_stage_duration_ms_histogram_bucket` | 某个 stage 的 p99 连续 5 分钟上升 | NATS 快速写链路在 TDengine、Redis 或 NATS ack 某一阶段变慢;TDengine 阶段可小步调整 `FAST_WRITER_TDENGINE_MAX_OPEN_CONNS`,Redis 阶段是整批 pipeline 写入耗时,调整后必须观察是否出现选库错误和 ack-pending 增长。 |
|
||||
| `vehicle_realtime_store_update_duration_ms_histogram_bucket{store="redis"}` | p99 连续 5 分钟上升 | Redis 实时投影变慢,会直接影响 realtime consumer 追平能力。 |
|
||||
| `vehicle_realtime_store_update_duration_ms_histogram_bucket{store="mysql"}` | p99 连续 5 分钟上升 | MySQL 当前态/位置投影变慢,需结合 async queue depth 和 dropped 计数判断。 |
|
||||
| `vehicle_stat_write_duration_ms_histogram_bucket` | p99 连续 5 分钟上升 | MySQL 每日里程统计写入变慢,可能导致 stat Kafka lag 增长。 |
|
||||
| Kafka lag | 连续 5 分钟增长或 `> 10000` | 下游 consumer 或存储存在瓶颈。 |
|
||||
| Writer 成功计数 | 入口增长但 writer 不增长 | bridge、Kafka、consumer 或存储链路断开。 |
|
||||
|
||||
## 事故处理路径
|
||||
|
||||
### 没有新数据
|
||||
|
||||
1. 先确认监听端口。
|
||||
|
||||
```bash
|
||||
ss -lntp | grep -E ':(808|32960|20200|20211|20212|20213|20214) '
|
||||
```
|
||||
|
||||
2. 检查 gateway readiness 和日志。
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:20211/readyz
|
||||
journalctl -u lingniu-go-gateway.service --since '10 minutes ago' --no-pager
|
||||
```
|
||||
|
||||
3. 看 gateway 帧计数是否增长。
|
||||
4. 如果 gateway 有增长但 bridge 没增长,查 NATS publish 和 bridge 日志。
|
||||
5. 如果 bridge 写入增长但 writer 不增长,查 Kafka lag 和 consumer 日志。
|
||||
|
||||
### Kafka lag 持续增长
|
||||
|
||||
1. 先从 metrics 定位是哪个服务、哪个 topic、哪个 partition。
|
||||
2. 检查对应服务 `/readyz`。
|
||||
3. 检查存储依赖:history 看 TDengine,stat 看 MySQL,realtime 看 Redis/MySQL/TDengine。
|
||||
4. 先看日志,再决定是否重启。
|
||||
|
||||
```bash
|
||||
journalctl -u lingniu-go-history-writer.service --since '10 minutes ago' --no-pager
|
||||
journalctl -u lingniu-go-stat-writer.service --since '10 minutes ago' --no-pager
|
||||
journalctl -u lingniu-go-realtime-api.service --since '10 minutes ago' --no-pager
|
||||
```
|
||||
|
||||
Kafka 是可回放日志。只要 Kafka 还保留消息,恢复 consumer 后 lag 应该能自动追平。不要在 lag 未归零前手工补数。
|
||||
|
||||
### NATS pending 持续增长
|
||||
|
||||
1. 检查 bridge `/readyz` 和日志。
|
||||
2. 对比 `vehicle_bridge_kafka_writes_total` 和 `vehicle_bridge_nats_acks_total`。
|
||||
3. 如果 Kafka 写入失败,检查 ECS 到 Kafka broker 的网络。
|
||||
4. 如果 `ack_pending > 0`,优先检查 Kafka 是否监听 `9092`、Kafka 盘是否打满,再看 bridge 日志。
|
||||
5. 如果 `ack_pending = 0` 但 `consumer_pending` 下降,说明 bridge 正在追历史积压;不要反复重启,持续观察下降速度即可。
|
||||
6. 如果 `consumer_pending` 不下降,才考虑扩容 bridge 或降低 batch/fetch 等配置。
|
||||
|
||||
```bash
|
||||
journalctl -u lingniu-go-nats-kafka-bridge.service --since '10 minutes ago' --no-pager
|
||||
systemctl restart lingniu-go-nats-kafka-bridge.service
|
||||
```
|
||||
|
||||
生产 stream 必须设置字节上限,避免 NATS JetStream 在 Kafka 或下游故障时吃满根盘:
|
||||
|
||||
```bash
|
||||
grep NATS_STREAM_MAX_BYTES /opt/lingniu-go-native/env/nats-fast-writer.env
|
||||
grep NATS_STREAM_MAX_BYTES /opt/lingniu-go-native/env/nats-kafka-bridge.env
|
||||
grep NATS_STREAM_ENSURE_TIMEOUT_SECONDS /opt/lingniu-go-native/env/nats-fast-writer.env
|
||||
grep NATS_STREAM_ENSURE_TIMEOUT_SECONDS /opt/lingniu-go-native/env/nats-kafka-bridge.env
|
||||
du -sh /opt/lingniu-nats/data
|
||||
df -h /
|
||||
```
|
||||
|
||||
当前建议值:`NATS_STREAM_MAX_BYTES=21474836480`,即 `20GiB`;`NATS_STREAM_ENSURE_TIMEOUT_SECONDS=60`,避免大 stream 元数据更新时被 NATS 客户端默认 5s 超时误杀。Kafka topic 只作为短期缓冲,当前建议保留 `6h`,不要把 Kafka 或 NATS 当长期历史存储;长期历史和 RAW 查询以 TDengine/MySQL 投影为准。
|
||||
|
||||
fast-writer 的 `FAST_WRITER_OPERATION_TIMEOUT_MS` 建议为 `1000`。实时链路仍以 100ms 级为目标,但 TDengine 批量写存在尾延迟,过小的超时会造成 NATS 消息反复重投和重复写压力。
|
||||
|
||||
### Raw 有数据但实时查不到
|
||||
|
||||
1. 查 realtime Kafka lag。
|
||||
2. 查 realtime update 计数。
|
||||
3. 查 realtime API readiness。
|
||||
4. 查最新 snapshot/location API。
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:20200/readyz
|
||||
curl -fsS 'http://127.0.0.1:20200/api/realtime/locations?limit=1'
|
||||
curl -fsS 'http://127.0.0.1:20200/api/realtime/snapshots?limit=1'
|
||||
```
|
||||
|
||||
## 重启顺序
|
||||
|
||||
优先重启最小故障层:
|
||||
|
||||
1. 只有一个 consumer lag 异常时,先重启对应 writer。
|
||||
2. 实时投影或 API 异常时,重启 realtime API。
|
||||
3. bridge pending 或 ack-pending 卡住时,重启 NATS Kafka bridge。
|
||||
4. 只有入口监听、解析循环或上游连接处理异常时,才重启 gateway。
|
||||
|
||||
```bash
|
||||
systemctl restart lingniu-go-history-writer.service
|
||||
systemctl restart lingniu-go-stat-writer.service
|
||||
systemctl restart lingniu-go-realtime-api.service
|
||||
systemctl restart lingniu-go-nats-kafka-bridge.service
|
||||
systemctl restart lingniu-go-gateway.service
|
||||
```
|
||||
|
||||
排查前不要直接全量重启。全量重启会抹掉时间线证据,也会掩盖问题到底发生在入口、队列、存储还是查询层。
|
||||
|
||||
## 存储原则
|
||||
|
||||
运行指标保留在 `/metrics`,不要为了服务健康计数新增 MySQL 或 TDengine 业务表。
|
||||
|
||||
业务存储保持最小化:
|
||||
|
||||
- raw frames 保存完整 parsed JSON,用于回放和审计。
|
||||
- realtime snapshot/location 只保存 API 需要的当前业务字段。
|
||||
- history 表保存可查询的时间序列核心字段。
|
||||
- 派生统计应能从 Kafka 或 raw history 重新计算。
|
||||
|
||||
只有当产品查询明确需要、并且指标定义稳定时,才新增持久化聚合。
|
||||
@@ -1,600 +0,0 @@
|
||||
# GB32960 Body Parser — Per-Block Exception Isolation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** `Gb32960BodyParser` 主循环对单信息块的解析异常实现**单块隔离**:一块解析失败不再整帧放弃,改为把失败块兜成 `InfoBlock.Raw` 并继续解析后续信息块。
|
||||
|
||||
**Architecture:** 在主循环每次 `parser.parse(body)` 外面套 try/catch(仅捕获 `DecodeException` / `BufferUnderflowException` / `IndexOutOfBoundsException`,放行 RuntimeException 以免掩盖 parser bug)。失败时:
|
||||
- 若 `fixedLen ≥ 0` 且剩余字节 ≥ fixedLen → 回滚 reader → 按 fixedLen 截取 Raw → position 前进 fixedLen → `continue` 循环
|
||||
- 否则(变长 parser 或剩余不足)→ 剩余字节全部兜成 Raw → `break` 循环(变长块无法安全找下一块边界)
|
||||
|
||||
通过 `Gb32960Properties.Parse.lenientBlockFailure`(默认 true)控制开关,可以回退到严格模式。**不改 `InfoBlock.Raw` record 结构**(避免 downstream 兼容风险——`Gb32960EventMapper` 按 `findBlock(Class)` 类型查找,新增 Raw 不影响其行为)。
|
||||
|
||||
**Tech Stack:** Java 25, JUnit 5, AssertJ, Spring Boot ConfigurationProperties, Maven。仅改动 `protocol-gb32960` 模块。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java` — 新增 `Parse` 内嵌类 + `parse` 字段/getter/setter
|
||||
- **Modify** `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java` — 主循环 try/catch + recovery 逻辑 + `lenientBlockFailure` 字段
|
||||
- **Modify** `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java` — 把配置穿给 BodyParser
|
||||
- **Create** `protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParserIsolationTest.java` — 3 个隔离场景 + 1 个严格模式回退场景
|
||||
- **Modify** `bootstrap-all/src/main/resources/application.yml` — 添加 `parse:` 注释段示例
|
||||
- **Modify** `CHANGELOG.md` — 追加本次变更条目
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 基线验证 —— 现有测试全绿
|
||||
|
||||
**Files:** 无
|
||||
|
||||
- [ ] **Step 1.1: 跑 protocol-gb32960 模块测试**
|
||||
|
||||
Run: `mvn -pl protocol-gb32960 test -q`
|
||||
Expected: BUILD SUCCESS,所有现有测试通过(`Gb32960DecoderGoldenTest`、`Gb32960FullBlocksTest`、`Gb32960DecoderTest`、`GuangdongFcEndToEndTest`、parser/* 各 Block Parser 单测、profile/* 选择器单测)。
|
||||
|
||||
如果基线红了,**先停下来修复基线**再进入 Task 2。
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 添加 Parse 配置子节点
|
||||
|
||||
**Files:**
|
||||
- Modify: `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java`
|
||||
|
||||
- [ ] **Step 2.1: 在 `Gb32960Properties` 类体中(在 `Auth` 字段后、`Tls` 字段前)新增 `parse` 字段**
|
||||
|
||||
位置参考:现在 L24 `private Auth auth = new Auth();` 下面一行,在 L27 `private Tls tls = new Tls();` 之前插入:
|
||||
|
||||
```java
|
||||
/**
|
||||
* 报文解析行为配置。
|
||||
*
|
||||
* <p>默认 {@code lenientBlockFailure=true}:单个信息块解析异常时兜成
|
||||
* {@link com.lingniu.ingest.protocol.gb32960.model.InfoBlock.Raw} 继续解析,
|
||||
* 不再整帧放弃。关闭后恢复旧行为(任意异常直接抛 {@code DecodeException}
|
||||
* 放弃整帧),仅在灰度回滚时使用。
|
||||
*/
|
||||
private Parse parse = new Parse();
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2: 在类底部(`Tls` 静态类之后、`VendorExtension` 静态类之前)添加 `Parse` 静态内嵌类**
|
||||
|
||||
```java
|
||||
/**
|
||||
* 报文解析容错策略。
|
||||
*/
|
||||
public static class Parse {
|
||||
/**
|
||||
* 单块解析异常时是否兜底为 {@link com.lingniu.ingest.protocol.gb32960.model.InfoBlock.Raw}
|
||||
* 继续解析。默认开启。
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code true}(默认):parser 抛 DecodeException / BufferUnderflowException /
|
||||
* IndexOutOfBoundsException 时,固定长度块按 fixedLen 截取 Raw 后 continue;
|
||||
* 变长块或剩余字节不足时,剩余全部兜成 Raw 后 break 循环。
|
||||
* <li>{@code false}:保留旧行为,任意异常直接抛出 DecodeException 放弃整帧。
|
||||
* </ul>
|
||||
*/
|
||||
private boolean lenientBlockFailure = true;
|
||||
|
||||
public boolean isLenientBlockFailure() { return lenientBlockFailure; }
|
||||
public void setLenientBlockFailure(boolean lenientBlockFailure) {
|
||||
this.lenientBlockFailure = lenientBlockFailure;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2.3: 在属性 getter/setter 区域(现有 `getTls` 之后)补充 `getParse` / `setParse`**
|
||||
|
||||
```java
|
||||
public Parse getParse() { return parse; }
|
||||
public void setParse(Parse parse) { this.parse = parse; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2.4: 编译验证**
|
||||
|
||||
Run: `mvn -pl protocol-gb32960 compile -q`
|
||||
Expected: BUILD SUCCESS。
|
||||
|
||||
- [ ] **Step 2.5: 提交**
|
||||
|
||||
```bash
|
||||
git add protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960Properties.java
|
||||
git commit -m "$(cat <<'EOF'
|
||||
config(gb32960): add parse.lenientBlockFailure toggle
|
||||
|
||||
新增 lingniu.ingest.gb32960.parse.lenientBlockFailure 配置开关(默认 true),
|
||||
为 Gb32960BodyParser 的单块异常隔离行为做回退开关。实现在后续 commit。
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: RED —— 写三个失败测试覆盖隔离场景
|
||||
|
||||
**Files:**
|
||||
- Create: `protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParserIsolationTest.java`
|
||||
|
||||
- [ ] **Step 3.1: 创建测试文件**
|
||||
|
||||
完整内容:
|
||||
|
||||
```java
|
||||
package com.lingniu.ingest.protocol.gb32960.codec;
|
||||
|
||||
import com.lingniu.ingest.api.spi.DecodeException;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.AlarmV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlockType;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* 验证 {@link Gb32960BodyParser} 单块异常隔离行为。
|
||||
*
|
||||
* <p>三个隔离场景:
|
||||
* <ol>
|
||||
* <li>固定长度块 parser 抛异常 → 失败块兜 Raw(按 fixedLen 截取)+ 后续块继续解析
|
||||
* <li>固定长度块剩余字节不足(截断帧) → 兜 Raw(剩余) + break 循环
|
||||
* <li>变长块 parser 抛异常 → 兜 Raw(从失败块起剩余全部) + break 循环
|
||||
* </ol>
|
||||
*
|
||||
* <p>外加一个严格模式回退测试:{@code lenientBlockFailure=false} 时任意异常应抛 DecodeException。
|
||||
*/
|
||||
class Gb32960BodyParserIsolationTest {
|
||||
|
||||
/** 构造一个"看起来合法但中途 parser 失败"用来注入失败的 Position parser。 */
|
||||
private static final InfoBlockParser EXPLODING_FIXED_LEN_POSITION = new InfoBlockParser() {
|
||||
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
|
||||
@Override public int typeCode() { return 0x05; }
|
||||
@Override public int fixedLength() { return 9; }
|
||||
@Override public InfoBlock parse(ByteBuffer buffer) {
|
||||
// 模拟 parser 读了几字节后发现不合法就抛
|
||||
buffer.get(); buffer.get();
|
||||
throw new DecodeException("simulated parser failure in Position");
|
||||
}
|
||||
};
|
||||
|
||||
/** 变长块(fixedLength=-1),parse 时消费若干字节后抛。模拟 Alarm/Voltage 类列表读越界。 */
|
||||
private static final InfoBlockParser EXPLODING_VAR_LEN_ALARM = new InfoBlockParser() {
|
||||
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
|
||||
@Override public int typeCode() { return 0x07; }
|
||||
@Override public int fixedLength() { return -1; }
|
||||
@Override public InfoBlock parse(ByteBuffer buffer) {
|
||||
// 假装读了个长度字段就爆
|
||||
buffer.get();
|
||||
throw new DecodeException("simulated parser failure in Alarm list-length read");
|
||||
}
|
||||
};
|
||||
|
||||
@Test
|
||||
void fixedLengthBlockFailure_isIsolated_subsequentBlocksStillParsed() {
|
||||
// 帧布局:Vehicle(0x01, 20B) + Position(0x05, 9B 但 parser 爆) + Engine(0x04) 不构造,
|
||||
// 简化为 Vehicle + FailingPosition + Vehicle 再次,验证"Position 之后还能继续"。
|
||||
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
|
||||
new VehicleV2016BlockParser(),
|
||||
EXPLODING_FIXED_LEN_POSITION));
|
||||
|
||||
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
|
||||
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeValidVehicle(os); // 1+20 = 21B
|
||||
writePositionTypeAnd9ByteBody(os); // 1+9 = 10B (parser 会爆)
|
||||
writeValidVehicle(os); // 1+20 = 21B
|
||||
ByteBuffer body = ByteBuffer.wrap(os.toByteArray());
|
||||
|
||||
var result = parser.parse(ProtocolVersion.V2016, body);
|
||||
|
||||
assertThat(result.blocks()).hasSize(3);
|
||||
assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
|
||||
assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> {
|
||||
assertThat(raw.typeCode()).isEqualTo(0x05);
|
||||
assertThat(raw.type()).isEqualTo(InfoBlockType.RAW);
|
||||
assertThat(raw.bytes()).hasSize(9); // 按 fixedLen 截取
|
||||
});
|
||||
assertThat(result.blocks().get(2)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
|
||||
assertThat(body.hasRemaining()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void truncatedFixedLengthBlock_isWrappedAsRaw_loopTerminates() {
|
||||
// Vehicle(21B) + Position(type 0x05)但 body 只给 3B —— 不足 9B
|
||||
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
|
||||
new VehicleV2016BlockParser(),
|
||||
new PositionV2016BlockParser()));
|
||||
|
||||
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
|
||||
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeValidVehicle(os);
|
||||
os.write(0x05); // Position typeCode
|
||||
os.write(0); os.write(0); os.write(0); // 只 3B,远远不够 9B
|
||||
ByteBuffer body = ByteBuffer.wrap(os.toByteArray());
|
||||
|
||||
var result = parser.parse(ProtocolVersion.V2016, body);
|
||||
|
||||
assertThat(result.blocks()).hasSize(2);
|
||||
assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
|
||||
assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> {
|
||||
assertThat(raw.typeCode()).isEqualTo(0x05);
|
||||
assertThat(raw.bytes()).hasSize(3); // 剩余字节全兜成 Raw
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void variableLengthBlockFailure_swallowsRemainderAsRaw_loopBreaks() {
|
||||
// Vehicle(21B) + FailingAlarm(0x07)后面还有一个 Vehicle,但因 Alarm 变长无法安全跳过,
|
||||
// 整段 Alarm 起的剩余字节都被兜成 Raw 后 break。
|
||||
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
|
||||
new VehicleV2016BlockParser(),
|
||||
EXPLODING_VAR_LEN_ALARM));
|
||||
|
||||
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
|
||||
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeValidVehicle(os); // 21B
|
||||
os.write(0x07); // Alarm typeCode
|
||||
for (int i = 0; i < 10; i++) os.write(0xAA); // 10B 的 alarm body(parser 爆)
|
||||
writeValidVehicle(os); // 21B(应当不再被解析)
|
||||
ByteBuffer body = ByteBuffer.wrap(os.toByteArray());
|
||||
|
||||
var result = parser.parse(ProtocolVersion.V2016, body);
|
||||
|
||||
assertThat(result.blocks()).hasSize(2);
|
||||
assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
|
||||
assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> {
|
||||
assertThat(raw.typeCode()).isEqualTo(0x07);
|
||||
// 剩余 10B alarm body + 1+20=21B 后续 Vehicle = 31B
|
||||
assertThat(raw.bytes()).hasSize(31);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void strictMode_throwsOnAnyBlockFailure() {
|
||||
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
|
||||
new VehicleV2016BlockParser(),
|
||||
EXPLODING_FIXED_LEN_POSITION));
|
||||
|
||||
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
|
||||
parser.setLenientBlockFailure(false);
|
||||
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeValidVehicle(os);
|
||||
writePositionTypeAnd9ByteBody(os);
|
||||
ByteBuffer body = ByteBuffer.wrap(os.toByteArray());
|
||||
|
||||
assertThatThrownBy(() -> parser.parse(ProtocolVersion.V2016, body))
|
||||
.isInstanceOf(DecodeException.class);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ------------------------------------------------------------------------
|
||||
|
||||
/** 写一个完整的合法 V2016 Vehicle 块(typeCode + 20B body)。 */
|
||||
private static void writeValidVehicle(ByteArrayOutputStream os) {
|
||||
os.write(0x01); // typeCode Vehicle
|
||||
os.write(0x01); // vehicleState=1
|
||||
os.write(0x01); // chargingState=1
|
||||
os.write(0x01); // runningMode=1
|
||||
os.write(0); os.write(0); // speed=0
|
||||
os.write(0); os.write(0); os.write(0); os.write(0); // mileage=0
|
||||
os.write(0); os.write(0); // totalVoltage=0
|
||||
os.write(0); os.write(0); // totalCurrent=0(offset 1000,原值0)
|
||||
os.write(50); // soc=50%
|
||||
os.write(0x01); // dcdc
|
||||
os.write(0); // gear
|
||||
os.write(0); os.write(0); // insulation
|
||||
os.write(0); // accelerator
|
||||
os.write(0); // brake
|
||||
}
|
||||
|
||||
/** 写 Position typeCode(0x05) + 9 字节 body(内容不重要,parser 替换成 EXPLODING 的)。 */
|
||||
private static void writePositionTypeAnd9ByteBody(ByteArrayOutputStream os) {
|
||||
os.write(0x05);
|
||||
for (int i = 0; i < 9; i++) os.write(0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3.2: 跑测试验证 RED**
|
||||
|
||||
Run: `mvn -pl protocol-gb32960 test -Dtest=Gb32960BodyParserIsolationTest -q`
|
||||
Expected:
|
||||
- `fixedLengthBlockFailure_isIsolated_subsequentBlocksStillParsed` FAIL(当前会抛 DecodeException)
|
||||
- `truncatedFixedLengthBlock_isWrappedAsRaw_loopTerminates` FAIL(当前 L132 会抛 DecodeException)
|
||||
- `variableLengthBlockFailure_swallowsRemainderAsRaw_loopBreaks` FAIL(异常冒出整帧失败)
|
||||
- `strictMode_throwsOnAnyBlockFailure` FAIL(没有 `setLenientBlockFailure` 方法,编译就红)
|
||||
|
||||
**验证点**:编译失败(strictMode test 里 `setLenientBlockFailure` 尚未存在)是**预期** RED 信号——下一步实现。
|
||||
|
||||
---
|
||||
|
||||
## Task 4: GREEN —— 实现单块异常隔离
|
||||
|
||||
**Files:**
|
||||
- Modify: `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java`
|
||||
|
||||
- [ ] **Step 4.1: 在类字段区新增 `lenientBlockFailure` 字段 + setter**
|
||||
|
||||
在 `private final VendorExtensionSelector selector;`(现 L54)下面插入:
|
||||
|
||||
```java
|
||||
/**
|
||||
* 单块解析异常时是否兜底为 Raw 后继续。由
|
||||
* {@link com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties.Parse#isLenientBlockFailure()}
|
||||
* 注入;默认 true。
|
||||
*/
|
||||
private boolean lenientBlockFailure = true;
|
||||
|
||||
public void setLenientBlockFailure(boolean lenientBlockFailure) {
|
||||
this.lenientBlockFailure = lenientBlockFailure;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4.2: 改造主循环:替换 L130~L150 的整个 "parse + 长度校验" 段**
|
||||
|
||||
**完整替换块**:以下代码替换原来从 `int fixedLen = parser.fixedLength();`(L130)开始到 `blocks.add(block);`(L150)结束的整块。
|
||||
|
||||
```java
|
||||
int fixedLen = parser.fixedLength();
|
||||
int posBefore = body.position();
|
||||
|
||||
// 旧行为:fixedLen 预检不足 → 直接抛。新行为(lenient 模式):走统一恢复路径。
|
||||
if (fixedLen >= 0 && body.remaining() < fixedLen) {
|
||||
if (!lenientBlockFailure) {
|
||||
throw new DecodeException(
|
||||
"info block 0x" + Integer.toHexString(typeCode)
|
||||
+ " needs " + fixedLen + " bytes but got " + body.remaining());
|
||||
}
|
||||
// 剩余字节数不足 fixedLen —— 无法按块截取,整尾兜 Raw + break
|
||||
int remaining = body.remaining();
|
||||
byte[] tail = new byte[remaining];
|
||||
body.get(tail);
|
||||
log.warn("[gb32960] truncated block typeCode=0x{} declaredFixedLen={} remaining={} — wrapping tail as Raw",
|
||||
Integer.toHexString(typeCode), fixedLen, remaining);
|
||||
blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, tail));
|
||||
break;
|
||||
}
|
||||
|
||||
InfoBlock block;
|
||||
try {
|
||||
block = parser.parse(body);
|
||||
} catch (DecodeException | java.nio.BufferUnderflowException | IndexOutOfBoundsException e) {
|
||||
if (!lenientBlockFailure) {
|
||||
if (e instanceof DecodeException de) throw de;
|
||||
throw new DecodeException(
|
||||
"parser " + parser.getClass().getSimpleName() + " failed: " + e.getMessage(), e);
|
||||
}
|
||||
// 恢复路径:先回滚 position 到 parse 入口
|
||||
body.position(posBefore);
|
||||
if (fixedLen >= 0) {
|
||||
// 固定长度块:按 fixedLen 截取 → 兜 Raw → continue 循环
|
||||
byte[] corrupt = new byte[fixedLen];
|
||||
body.get(corrupt);
|
||||
log.warn("[gb32960] block parse failed typeCode=0x{} parser={} fixedLen={} — isolated as Raw, continuing",
|
||||
Integer.toHexString(typeCode), parser.getClass().getSimpleName(), fixedLen, e);
|
||||
blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, corrupt));
|
||||
continue;
|
||||
} else {
|
||||
// 变长块:无法安全找下一块边界 → 剩余整段兜 Raw + break
|
||||
int remaining = body.remaining();
|
||||
byte[] tail = new byte[remaining];
|
||||
body.get(tail);
|
||||
log.warn("[gb32960] variable-length block parse failed typeCode=0x{} parser={} remaining={} — wrapping remainder as Raw, stopping loop",
|
||||
Integer.toHexString(typeCode), parser.getClass().getSimpleName(), remaining, e);
|
||||
blocks.add(new InfoBlock.Raw(typeCode, InfoBlockType.RAW, tail));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int consumed = body.position() - posBefore;
|
||||
if (fixedLen >= 0 && consumed != fixedLen) {
|
||||
// 这是 parser 契约违反(声明 fixedLen=X 但实际读了 Y),保持抛异常以暴露 parser bug。
|
||||
throw new DecodeException(
|
||||
"parser " + parser.getClass().getSimpleName()
|
||||
+ " consumed " + consumed
|
||||
+ " bytes but declared " + fixedLen);
|
||||
}
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("[gb32960] block typeCode=0x{} parser={} pos {}→{} consumed={} declaredFixed={}",
|
||||
Integer.toHexString(typeCode), parser.getClass().getSimpleName(),
|
||||
typeStartPos, body.position(), consumed, fixedLen);
|
||||
}
|
||||
blocks.add(block);
|
||||
```
|
||||
|
||||
- [ ] **Step 4.3: 运行隔离测试验证 GREEN**
|
||||
|
||||
Run: `mvn -pl protocol-gb32960 test -Dtest=Gb32960BodyParserIsolationTest -q`
|
||||
Expected: 4 个测试全部 PASS。
|
||||
|
||||
- [ ] **Step 4.4: 运行全模块测试检查回归**
|
||||
|
||||
Run: `mvn -pl protocol-gb32960 test -q`
|
||||
Expected: BUILD SUCCESS,所有原有测试(含 Golden、FullBlocks、GuangdongFcEndToEnd)依旧通过。
|
||||
|
||||
如果有原有测试红了:**停下来诊断**。最可能的原因是某个现有测试之前靠"抛异常"行为验证错误帧的,需要手动判断这是测试问题还是实现问题。
|
||||
|
||||
- [ ] **Step 4.5: 提交**
|
||||
|
||||
```bash
|
||||
git add protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParser.java \
|
||||
protocol-gb32960/src/test/java/com/lingniu/ingest/protocol/gb32960/codec/Gb32960BodyParserIsolationTest.java
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(gb32960): isolate per-block parse failures in body parser
|
||||
|
||||
主循环改造:单信息块 parser 抛 DecodeException / BufferUnderflowException /
|
||||
IndexOutOfBoundsException 时不再放弃整帧。
|
||||
|
||||
- 固定长度块(fixedLen≥0):回滚 reader,按 fixedLen 截取字节兜成 InfoBlock.Raw,
|
||||
position 前进 fixedLen,continue 循环继续解析后续块
|
||||
- 变长块(fixedLen=-1)或剩余不足 fixedLen:剩余字节全部兜成 Raw 后 break
|
||||
- parser 契约违反(consumed != declared fixedLen)仍抛异常,以暴露 parser bug
|
||||
- 通过 lingniu.ingest.gb32960.parse.lenientBlockFailure=false 可回退严格模式
|
||||
- 新增 Gb32960BodyParserIsolationTest 覆盖 3 种失败场景 + 1 严格模式
|
||||
|
||||
不改 InfoBlock.Raw record 结构;下游 Gb32960EventMapper 按 findBlock(Class) 类型
|
||||
查找,新增 Raw 不影响其行为。
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 把配置注入 BodyParser(AutoConfiguration 连线)
|
||||
|
||||
**Files:**
|
||||
- Modify: `protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java`
|
||||
|
||||
- [ ] **Step 5.1: 定位 `Gb32960BodyParser` bean 定义**
|
||||
|
||||
先查看:`grep -n "Gb32960BodyParser" protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java`
|
||||
|
||||
根据返回的行号,在构造 `Gb32960BodyParser` 的 @Bean 方法里,构造完成后立即调用:
|
||||
|
||||
```java
|
||||
Gb32960BodyParser parser = new Gb32960BodyParser(profileRegistry, selector);
|
||||
parser.setLenientBlockFailure(properties.getParse().isLenientBlockFailure());
|
||||
return parser;
|
||||
```
|
||||
|
||||
(如果目前的构造返回一行表达式,改成先赋给 local 变量再设 flag 再 return。)
|
||||
|
||||
- [ ] **Step 5.2: 编译并运行全模块测试**
|
||||
|
||||
Run: `mvn -pl protocol-gb32960 test -q`
|
||||
Expected: BUILD SUCCESS。
|
||||
|
||||
- [ ] **Step 5.3: 提交**
|
||||
|
||||
```bash
|
||||
git add protocol-gb32960/src/main/java/com/lingniu/ingest/protocol/gb32960/config/Gb32960AutoConfiguration.java
|
||||
git commit -m "$(cat <<'EOF'
|
||||
config(gb32960): wire parse.lenientBlockFailure into BodyParser bean
|
||||
|
||||
Gb32960AutoConfiguration 在创建 Gb32960BodyParser bean 后,将
|
||||
Gb32960Properties.Parse.lenientBlockFailure 通过 setter 注入,让运行期配置生效。
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: bootstrap-all 配置文档化
|
||||
|
||||
**Files:**
|
||||
- Modify: `bootstrap-all/src/main/resources/application.yml`
|
||||
|
||||
- [ ] **Step 6.1: 在 `lingniu.ingest.gb32960` 节点的 `vendor-extensions` 段之后、`jt808` 之前,插入 parse 配置示例**
|
||||
|
||||
参考位置:现有 application.yml L57 末尾(`vendor-extensions` list 结束)。在 L58 `jt808:` 之前加入:
|
||||
|
||||
```yaml
|
||||
# 报文解析容错。默认启用单块异常隔离:某个信息块解析失败时兜成 Raw 后继续,
|
||||
# 不再整帧丢弃。仅在需要严格失败语义(灰度回滚或抓虫)时设为 false。
|
||||
parse:
|
||||
lenient-block-failure: true
|
||||
```
|
||||
|
||||
- [ ] **Step 6.2: 启动一次 bootstrap-all 的 dry-run 编译**
|
||||
|
||||
Run: `mvn -pl bootstrap-all compile -q`
|
||||
Expected: BUILD SUCCESS(只是配置段注释变化,不会影响编译)。
|
||||
|
||||
- [ ] **Step 6.3: 提交**
|
||||
|
||||
```bash
|
||||
git add bootstrap-all/src/main/resources/application.yml
|
||||
git commit -m "$(cat <<'EOF'
|
||||
config(gb32960): document parse.lenient-block-failure in application.yml
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 全仓库回归测试
|
||||
|
||||
**Files:** 无(只是验证)
|
||||
|
||||
- [ ] **Step 7.1: 跑完整项目测试**
|
||||
|
||||
Run: `mvn test -q`
|
||||
Expected: BUILD SUCCESS。重点关注:
|
||||
- `protocol-gb32960` 模块全绿(Golden、FullBlocks、Isolation、GuangdongFcEndToEnd、Decoder、Mapper、所有 parser 单测、profile)
|
||||
- 其它模块(`ingest-core`、`sink-kafka` 等)不受影响(无改动应自动通过)
|
||||
|
||||
若有红,停下来诊断。
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 更新 CHANGELOG
|
||||
|
||||
**Files:**
|
||||
- Modify: `CHANGELOG.md`
|
||||
|
||||
- [ ] **Step 8.1: 在 CHANGELOG.md 文件顶部(在 `## [0.1.0] — 2026-04-15` 标题之前)新增一个 `## [Unreleased]` 段落;如已存在则追加**
|
||||
|
||||
新增段落内容(如已存在 Unreleased 段则在其 `### Added` / `### Changed` 内部追加):
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
### Changed —— GB/T 32960 Body Parser 单块异常隔离
|
||||
- `Gb32960BodyParser` 主循环对单信息块的 parser 异常不再放弃整帧:
|
||||
- 固定长度块(`fixedLength ≥ 0`):回滚 reader → 按 fixedLen 截取字节兜成
|
||||
`InfoBlock.Raw` → 继续解析后续块;
|
||||
- 变长块或剩余字节不足:剩余字节全部兜成 `Raw` 后终止循环;
|
||||
- `parser` 契约违反(声明 fixedLen=X 但实际读了 Y)仍抛 `DecodeException`,
|
||||
以暴露 parser bug,不被静默吞掉;
|
||||
- 只捕获 `DecodeException` / `BufferUnderflowException` / `IndexOutOfBoundsException`,
|
||||
`RuntimeException` 继续向上抛,保留 bug 可见性。
|
||||
- 新增配置 `lingniu.ingest.gb32960.parse.lenient-block-failure`(默认 `true`);
|
||||
回退严格模式用 `false`。
|
||||
- `InfoBlock.Raw` record 结构**未变**,下游 `Gb32960EventMapper` 按 `findBlock(Class)`
|
||||
类型匹配,新增 Raw 不影响业务事件映射。
|
||||
- 覆盖测试:`Gb32960BodyParserIsolationTest` —— 3 种隔离场景 + 严格模式回退。
|
||||
```
|
||||
|
||||
- [ ] **Step 8.2: 提交**
|
||||
|
||||
```bash
|
||||
git add CHANGELOG.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(changelog): record gb32960 body parser block isolation
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
- [x] **Spec coverage**: 原讨论的 A1~A7 全部覆盖——A1 状态机(Task 4 Step 4.2)、A2 三类错误策略(同)、A3 收窄异常种类(同,只 catch 三种)、A4 日志分类(warn 带 parser 名/typeCode/原因)、A5 配置开关(Task 2)、A6 合成帧测试(Task 3 三场景 + 严格回退)、A7 下游兼容(不改 Raw record,`findBlock` 类型匹配不受影响,plan 中已说明)。
|
||||
- [x] **Placeholder scan**: 无 TBD / TODO / "实现上面的" 等占位;每个 Step 含实际代码 or 命令。
|
||||
- [x] **Type consistency**: `lenientBlockFailure` 在 Properties / BodyParser 字段 / setter / 测试中拼写一致。`InfoBlockType.RAW` 枚举值依赖当前存在(已核对 InfoBlock.Raw record)。
|
||||
- [x] **测试命名一致**:`Gb32960BodyParserIsolationTest` 在 Task 3 创建、Task 4/7 引用一致。
|
||||
- [x] **未引入新枚举/proto 变更**:InfoBlock.Raw 沿用现有 `(int typeCode, InfoBlockType type, byte[] bytes)` 构造。
|
||||
@@ -1,817 +0,0 @@
|
||||
> **Superseded:** This 2026-06-23 DuckDB hot-store plan is historical context.
|
||||
> Use `docs/target-architecture.md` and
|
||||
> `docs/superpowers/specs/2026-06-29-vehicle-ingest-redesign.md` for the
|
||||
> current production architecture: TDengine stores hot history, `sink-archive`
|
||||
> owns raw bytes, and `event-file-store` / DuckDB are no longer part of the
|
||||
> current build surface.
|
||||
|
||||
# GB32960 History DuckDB Hot Store Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the current high-write-risk Parquet rewrite history path with a DuckDB hot history store that supports fast `vin + time` queries and full RAW frame replay.
|
||||
|
||||
**Architecture:** Add a new `DuckDbHotEventFileStore` implementation behind the existing `EventFileStore` interface, backed by append/upsert DuckDB tables instead of rewriting per-vehicle Parquet files. Keep the current HTTP history API and RAW archive reader intact, then switch `vehicle-history-app` to the hot DuckDB store by configuration.
|
||||
|
||||
**Tech Stack:** Java 26, Spring Boot, DuckDB JDBC, JUnit 5, AssertJ, existing Kafka `VehicleEnvelope`, existing `ArchiveStore`.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
This plan implements Phase 1 from `docs/superpowers/specs/2026-06-23-gb32960-production-readiness-design.md`.
|
||||
|
||||
Included:
|
||||
|
||||
- DuckDB hot event table for `EventFileRecord`.
|
||||
- Idempotent writes by `event_id`.
|
||||
- Query by protocol/date/VIN/eventType/eventTime/order/limit.
|
||||
- Lookup by `rawArchiveUri`.
|
||||
- Spring configuration to select the hot store.
|
||||
- Tests that prove history endpoints still replay RAW frames.
|
||||
- Local verification using the existing `vehicle-history-app`.
|
||||
|
||||
Not included in this plan:
|
||||
|
||||
- MySQL daily statistics.
|
||||
- VIN-to-platform local mapping.
|
||||
- Full telemetry point columnar table.
|
||||
- Alarm timeline MySQL tables.
|
||||
- Cold Parquet export.
|
||||
|
||||
Those will be separate plans after this foundation lands.
|
||||
|
||||
## Files
|
||||
|
||||
- Create: `modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/DuckDbHotEventFileStore.java`
|
||||
- Create: `modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/DuckDbHotEventFileStoreTest.java`
|
||||
- Modify: `modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreProperties.java`
|
||||
- Modify: `modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfiguration.java`
|
||||
- Modify: `modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfigurationTest.java`
|
||||
- Modify: `modules/apps/vehicle-history-app/src/main/resources/application.yml`
|
||||
- Modify: `modules/apps/vehicle-history-app/src/test/java/com/lingniu/ingest/historyapp/VehicleHistoryAppDefaultsTest.java`
|
||||
- Modify: `modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/Gb32960DecodedFrameServiceTest.java`
|
||||
|
||||
## Task 1: Add Hot Store Configuration
|
||||
|
||||
**Files:**
|
||||
- Modify: `modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreProperties.java`
|
||||
- Modify: `modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfiguration.java`
|
||||
- Test: `modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/config/EventFileStoreAutoConfigurationTest.java`
|
||||
|
||||
- [ ] **Step 1: Write the failing auto-configuration test**
|
||||
|
||||
Add a test that sets `lingniu.ingest.event-file-store.storage=duckdb-hot` and expects the bean class to be `DuckDbHotEventFileStore`.
|
||||
|
||||
```java
|
||||
@Test
|
||||
void createsDuckDbHotStoreWhenStorageIsDuckDbHot() {
|
||||
contextRunner
|
||||
.withPropertyValues(
|
||||
"lingniu.ingest.event-file-store.enabled=true",
|
||||
"lingniu.ingest.event-file-store.storage=duckdb-hot",
|
||||
"lingniu.ingest.event-file-store.path=" + tempDir.resolve("history"))
|
||||
.run(context -> assertThat(context)
|
||||
.hasSingleBean(EventFileStore.class)
|
||||
.getBean(EventFileStore.class)
|
||||
.isInstanceOf(DuckDbHotEventFileStore.class));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the new test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl :event-file-store -Dtest=EventFileStoreAutoConfigurationTest#createsDuckDbHotStoreWhenStorageIsDuckDbHot test
|
||||
```
|
||||
|
||||
Expected: compilation failure because `DuckDbHotEventFileStore` and `storage` property do not exist.
|
||||
|
||||
- [ ] **Step 3: Add the `storage` property**
|
||||
|
||||
Add to `EventFileStoreProperties`:
|
||||
|
||||
```java
|
||||
/**
|
||||
* Storage backend. `duckdb-hot` is the production backend; `parquet-sidecar`
|
||||
* keeps the previous implementation available for compatibility tests.
|
||||
*/
|
||||
private String storage = "duckdb-hot";
|
||||
|
||||
public String getStorage() {
|
||||
return storage;
|
||||
}
|
||||
|
||||
public void setStorage(String storage) {
|
||||
this.storage = storage;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Switch auto-configuration by storage mode**
|
||||
|
||||
Change `eventFileStore(...)` to:
|
||||
|
||||
```java
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public EventFileStore eventFileStore(EventFileStoreProperties properties,
|
||||
ObjectProvider<ObjectMapper> objectMapper) {
|
||||
ObjectMapper mapper = mapper(objectMapper);
|
||||
Path root = Path.of(properties.getPath());
|
||||
ZoneId zoneId = ZoneId.of(properties.getZoneId());
|
||||
String storage = properties.getStorage() == null ? "" : properties.getStorage().trim();
|
||||
return switch (storage) {
|
||||
case "", "duckdb-hot" -> new DuckDbHotEventFileStore(root, zoneId, mapper);
|
||||
case "parquet-sidecar" -> new DuckDbParquetEventFileStore(root, zoneId, mapper);
|
||||
default -> throw new IllegalStateException(
|
||||
"unsupported event-file-store storage: " + properties.getStorage());
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Import `DuckDbHotEventFileStore`.
|
||||
|
||||
- [ ] **Step 5: Run configuration tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl :event-file-store -Dtest=EventFileStoreAutoConfigurationTest test
|
||||
```
|
||||
|
||||
Expected: tests pass after the store class exists in Task 2.
|
||||
|
||||
## Task 2: Implement DuckDB Hot Store Schema and Writes
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/DuckDbHotEventFileStore.java`
|
||||
- Create: `modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/DuckDbHotEventFileStoreTest.java`
|
||||
|
||||
- [ ] **Step 1: Write failing append/query/idempotency tests**
|
||||
|
||||
Create `DuckDbHotEventFileStoreTest`:
|
||||
|
||||
```java
|
||||
class DuckDbHotEventFileStoreTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void appendsRecordsAndQueriesByVinTypeAndTime() throws Exception {
|
||||
EventFileStore store = store();
|
||||
store.append(rawRecord("raw-1", "VIN001", "2026-06-23T01:00:00Z"));
|
||||
store.append(rawRecord("raw-2", "VIN002", "2026-06-23T01:00:01Z"));
|
||||
store.append(rawRecord("raw-3", "VIN001", "2026-06-23T01:00:02Z"));
|
||||
|
||||
EventFileQuery query = new EventFileQuery(
|
||||
ProtocolId.GB32960,
|
||||
LocalDate.parse("2026-06-23"),
|
||||
LocalDate.parse("2026-06-23"),
|
||||
Instant.parse("2026-06-23T01:00:00Z"),
|
||||
Instant.parse("2026-06-23T01:00:03Z"),
|
||||
EventFileQuery.Order.DESC,
|
||||
2,
|
||||
"VIN001",
|
||||
"RAW_ARCHIVE");
|
||||
|
||||
assertThat(store.query(query))
|
||||
.extracting(EventFileRecord::eventId)
|
||||
.containsExactly("raw-3", "raw-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void appendAllIsIdempotentByEventId() throws Exception {
|
||||
EventFileStore store = store();
|
||||
EventFileRecord original = rawRecord("same-id", "VIN001", "2026-06-23T01:00:00Z");
|
||||
EventFileRecord replacement = new EventFileRecord(
|
||||
"same-id",
|
||||
ProtocolId.GB32960,
|
||||
"RAW_ARCHIVE",
|
||||
"VIN001",
|
||||
Instant.parse("2026-06-23T01:00:05Z"),
|
||||
Instant.parse("2026-06-23T01:00:06Z"),
|
||||
"archive://replacement.bin",
|
||||
Map.of("source", "replacement"),
|
||||
"{\"replacement\":true}");
|
||||
|
||||
store.appendAll(List.of(original, replacement));
|
||||
|
||||
assertThat(store.query(new EventFileQuery(
|
||||
ProtocolId.GB32960,
|
||||
LocalDate.parse("2026-06-23"),
|
||||
LocalDate.parse("2026-06-23"),
|
||||
EventFileQuery.Order.ASC,
|
||||
10,
|
||||
"VIN001",
|
||||
"RAW_ARCHIVE")))
|
||||
.singleElement()
|
||||
.satisfies(record -> {
|
||||
assertThat(record.eventId()).isEqualTo("same-id");
|
||||
assertThat(record.rawArchiveUri()).isEqualTo("archive://replacement.bin");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void findsRecordByRawArchiveUri() throws Exception {
|
||||
EventFileStore store = store();
|
||||
EventFileRecord record = rawRecord("raw-uri", "VIN001", "2026-06-23T01:00:00Z");
|
||||
store.append(record);
|
||||
|
||||
EventFileRecord found = store.findByRawArchiveUri(record.rawArchiveUri());
|
||||
|
||||
assertThat(found).isNotNull();
|
||||
assertThat(found.eventId()).isEqualTo("raw-uri");
|
||||
}
|
||||
|
||||
private EventFileStore store() {
|
||||
return new DuckDbHotEventFileStore(tempDir, ZoneId.of("Asia/Shanghai"), new ObjectMapper());
|
||||
}
|
||||
|
||||
private static EventFileRecord rawRecord(String id, String vin, String eventTime) {
|
||||
return new EventFileRecord(
|
||||
id,
|
||||
ProtocolId.GB32960,
|
||||
"RAW_ARCHIVE",
|
||||
vin,
|
||||
Instant.parse(eventTime),
|
||||
Instant.parse(eventTime).plusMillis(100),
|
||||
"archive://" + id + ".bin",
|
||||
Map.of("platformAccount", "Hyundai", "command", "REALTIME_REPORT"),
|
||||
"{\"eventId\":\"" + id + "\"}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test and verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl :event-file-store -Dtest=DuckDbHotEventFileStoreTest test
|
||||
```
|
||||
|
||||
Expected: compilation failure because `DuckDbHotEventFileStore` does not exist.
|
||||
|
||||
- [ ] **Step 3: Create the hot store class**
|
||||
|
||||
Create `DuckDbHotEventFileStore` with this structure:
|
||||
|
||||
```java
|
||||
public final class DuckDbHotEventFileStore implements EventFileStore {
|
||||
|
||||
private static final TypeReference<Map<String, String>> STRING_MAP = new TypeReference<>() {};
|
||||
|
||||
private final Path root;
|
||||
private final Path dbPath;
|
||||
private final ZoneId partitionZone;
|
||||
private final ObjectMapper objectMapper;
|
||||
private volatile boolean initialized;
|
||||
|
||||
public DuckDbHotEventFileStore(Path root, ZoneId partitionZone) {
|
||||
this(root, partitionZone, new ObjectMapper());
|
||||
}
|
||||
|
||||
public DuckDbHotEventFileStore(Path root, ZoneId partitionZone, ObjectMapper objectMapper) {
|
||||
if (root == null) {
|
||||
throw new IllegalArgumentException("root must not be null");
|
||||
}
|
||||
this.root = root.toAbsolutePath();
|
||||
this.dbPath = this.root.resolve("events.duckdb");
|
||||
this.partitionZone = partitionZone == null ? ZoneId.of("Asia/Shanghai") : partitionZone;
|
||||
this.objectMapper = objectMapper == null ? new ObjectMapper() : objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void appendAll(List<EventFileRecord> records) throws IOException {
|
||||
if (records == null || records.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
ensureInitialized();
|
||||
try (Connection connection = DriverManager.getConnection(jdbcUrl())) {
|
||||
connection.setAutoCommit(false);
|
||||
try {
|
||||
upsertRecords(connection, records);
|
||||
connection.commit();
|
||||
} catch (SQLException | IOException e) {
|
||||
connection.rollback();
|
||||
throw e;
|
||||
} finally {
|
||||
connection.setAutoCommit(true);
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new IOException("write duckdb hot event store failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<EventFileRecord> query(EventFileQuery query) throws IOException {
|
||||
ensureInitialized();
|
||||
// Implement in Task 3.
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventFileRecord findByRawArchiveUri(String rawArchiveUri) throws IOException {
|
||||
ensureInitialized();
|
||||
// Implement in Task 3.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add schema initialization**
|
||||
|
||||
Add:
|
||||
|
||||
```java
|
||||
private void ensureInitialized() throws IOException {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
Files.createDirectories(root);
|
||||
try (Connection connection = DriverManager.getConnection(jdbcUrl());
|
||||
Statement statement = connection.createStatement()) {
|
||||
statement.execute("""
|
||||
CREATE TABLE IF NOT EXISTS event_records (
|
||||
event_id VARCHAR PRIMARY KEY,
|
||||
protocol VARCHAR NOT NULL,
|
||||
event_type VARCHAR NOT NULL,
|
||||
vin VARCHAR NOT NULL,
|
||||
event_time_ms BIGINT NOT NULL,
|
||||
ingest_time_ms BIGINT NOT NULL,
|
||||
partition_date DATE NOT NULL,
|
||||
raw_archive_uri VARCHAR NOT NULL,
|
||||
metadata_json VARCHAR NOT NULL,
|
||||
payload_json VARCHAR NOT NULL
|
||||
)
|
||||
""");
|
||||
statement.execute("""
|
||||
CREATE INDEX IF NOT EXISTS event_records_protocol_date_time_idx
|
||||
ON event_records(protocol, partition_date, event_time_ms)
|
||||
""");
|
||||
statement.execute("""
|
||||
CREATE INDEX IF NOT EXISTS event_records_vin_date_time_idx
|
||||
ON event_records(protocol, vin, partition_date, event_time_ms)
|
||||
""");
|
||||
statement.execute("""
|
||||
CREATE INDEX IF NOT EXISTS event_records_vin_type_date_time_idx
|
||||
ON event_records(protocol, vin, event_type, partition_date, event_time_ms)
|
||||
""");
|
||||
statement.execute("""
|
||||
CREATE INDEX IF NOT EXISTS event_records_raw_archive_uri_idx
|
||||
ON event_records(raw_archive_uri)
|
||||
""");
|
||||
initialized = true;
|
||||
} catch (SQLException e) {
|
||||
throw new IOException("initialize duckdb hot event store failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add idempotent batch upsert**
|
||||
|
||||
Use DuckDB `INSERT OR REPLACE` inside one transaction:
|
||||
|
||||
```java
|
||||
private void upsertRecords(Connection connection, List<EventFileRecord> records)
|
||||
throws SQLException, IOException {
|
||||
try (PreparedStatement ps = connection.prepareStatement("""
|
||||
INSERT OR REPLACE INTO event_records VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""")) {
|
||||
for (EventFileRecord record : records) {
|
||||
ps.setString(1, record.eventId());
|
||||
ps.setString(2, record.protocol().name());
|
||||
ps.setString(3, record.eventType());
|
||||
ps.setString(4, record.vin());
|
||||
ps.setLong(5, record.eventTime().toEpochMilli());
|
||||
ps.setLong(6, record.ingestTime().toEpochMilli());
|
||||
ps.setString(7, LocalDate.ofInstant(record.eventTime(), partitionZone).toString());
|
||||
ps.setString(8, record.rawArchiveUri());
|
||||
ps.setString(9, objectMapper.writeValueAsString(record.metadata()));
|
||||
ps.setString(10, record.payloadJson());
|
||||
ps.addBatch();
|
||||
}
|
||||
ps.executeBatch();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run the hot store tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl :event-file-store -Dtest=DuckDbHotEventFileStoreTest test
|
||||
```
|
||||
|
||||
Expected: query tests still fail until Task 3 implements reads; append initialization should compile.
|
||||
|
||||
## Task 3: Implement Hot Store Queries and Raw URI Lookup
|
||||
|
||||
**Files:**
|
||||
- Modify: `modules/sinks/event-file-store/src/main/java/com/lingniu/ingest/eventfilestore/DuckDbHotEventFileStore.java`
|
||||
- Test: `modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/DuckDbHotEventFileStoreTest.java`
|
||||
|
||||
- [ ] **Step 1: Implement `query(EventFileQuery)`**
|
||||
|
||||
Use prepared statements for every external value:
|
||||
|
||||
```java
|
||||
@Override
|
||||
public List<EventFileRecord> query(EventFileQuery query) throws IOException {
|
||||
ensureInitialized();
|
||||
String order = query.order() == EventFileQuery.Order.DESC ? "DESC" : "ASC";
|
||||
StringBuilder where = new StringBuilder("""
|
||||
WHERE protocol = ?
|
||||
AND partition_date BETWEEN CAST(? AS DATE) AND CAST(? AS DATE)
|
||||
""");
|
||||
if (query.vin() != null) {
|
||||
where.append(" AND vin = ?\n");
|
||||
}
|
||||
if (query.eventType() != null) {
|
||||
where.append(" AND event_type = ?\n");
|
||||
}
|
||||
if (query.eventTimeFrom() != null) {
|
||||
where.append(" AND event_time_ms >= ?\n");
|
||||
}
|
||||
if (query.eventTimeTo() != null) {
|
||||
where.append(" AND event_time_ms <= ?\n");
|
||||
}
|
||||
String sql = """
|
||||
SELECT event_id, protocol, event_type, vin, event_time_ms, ingest_time_ms,
|
||||
raw_archive_uri, metadata_json, payload_json
|
||||
FROM event_records
|
||||
%s
|
||||
ORDER BY event_time_ms %s, ingest_time_ms %s, event_id %s
|
||||
LIMIT ?
|
||||
""".formatted(where, order, order, order);
|
||||
try (Connection connection = DriverManager.getConnection(jdbcUrl());
|
||||
PreparedStatement ps = connection.prepareStatement(sql)) {
|
||||
bindQuery(ps, query);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<EventFileRecord> out = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
out.add(record(rs));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new IOException("query duckdb hot event store failed", e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add query binding helper**
|
||||
|
||||
```java
|
||||
private static void bindQuery(PreparedStatement ps, EventFileQuery query) throws SQLException {
|
||||
int index = 1;
|
||||
ps.setString(index++, query.protocol().name());
|
||||
ps.setString(index++, query.dateFrom().toString());
|
||||
ps.setString(index++, query.dateTo().toString());
|
||||
if (query.vin() != null) {
|
||||
ps.setString(index++, query.vin());
|
||||
}
|
||||
if (query.eventType() != null) {
|
||||
ps.setString(index++, query.eventType());
|
||||
}
|
||||
if (query.eventTimeFrom() != null) {
|
||||
ps.setLong(index++, query.eventTimeFrom().toEpochMilli());
|
||||
}
|
||||
if (query.eventTimeTo() != null) {
|
||||
ps.setLong(index++, query.eventTimeTo().toEpochMilli());
|
||||
}
|
||||
ps.setInt(index, query.limit());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement `findByRawArchiveUri`**
|
||||
|
||||
```java
|
||||
@Override
|
||||
public EventFileRecord findByRawArchiveUri(String rawArchiveUri) throws IOException {
|
||||
if (rawArchiveUri == null || rawArchiveUri.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
ensureInitialized();
|
||||
try (Connection connection = DriverManager.getConnection(jdbcUrl());
|
||||
PreparedStatement ps = connection.prepareStatement("""
|
||||
SELECT event_id, protocol, event_type, vin, event_time_ms, ingest_time_ms,
|
||||
raw_archive_uri, metadata_json, payload_json
|
||||
FROM event_records
|
||||
WHERE raw_archive_uri = ?
|
||||
ORDER BY event_type = 'RAW_ARCHIVE' DESC, ingest_time_ms DESC, event_id DESC
|
||||
LIMIT 1
|
||||
""")) {
|
||||
ps.setString(1, rawArchiveUri);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? record(rs) : null;
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new IOException("query duckdb hot store by raw archive uri failed", e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add record mapper and helpers**
|
||||
|
||||
```java
|
||||
private EventFileRecord record(ResultSet rs) throws SQLException, IOException {
|
||||
return new EventFileRecord(
|
||||
rs.getString("event_id"),
|
||||
protocol(rs.getString("protocol")),
|
||||
rs.getString("event_type"),
|
||||
rs.getString("vin"),
|
||||
Instant.ofEpochMilli(rs.getLong("event_time_ms")),
|
||||
Instant.ofEpochMilli(rs.getLong("ingest_time_ms")),
|
||||
rs.getString("raw_archive_uri"),
|
||||
readMetadata(rs.getString("metadata_json")),
|
||||
rs.getString("payload_json"));
|
||||
}
|
||||
|
||||
private Map<String, String> readMetadata(String json) throws IOException {
|
||||
if (json == null || json.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
return objectMapper.readValue(json, STRING_MAP);
|
||||
}
|
||||
|
||||
private static ProtocolId protocol(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return ProtocolId.UNKNOWN;
|
||||
}
|
||||
try {
|
||||
return ProtocolId.valueOf(value);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return ProtocolId.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
private String jdbcUrl() {
|
||||
return "jdbc:duckdb:" + dbPath;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run hot store tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl :event-file-store -Dtest=DuckDbHotEventFileStoreTest test
|
||||
```
|
||||
|
||||
Expected: all `DuckDbHotEventFileStoreTest` tests pass.
|
||||
|
||||
## Task 4: Preserve Legacy Store Tests and Update Defaults
|
||||
|
||||
**Files:**
|
||||
- Modify: `modules/sinks/event-file-store/src/test/java/com/lingniu/ingest/eventfilestore/DuckDbParquetEventFileStoreTest.java`
|
||||
- Modify: `modules/apps/vehicle-history-app/src/main/resources/application.yml`
|
||||
- Modify: `modules/apps/vehicle-history-app/src/test/java/com/lingniu/ingest/historyapp/VehicleHistoryAppDefaultsTest.java`
|
||||
|
||||
- [ ] **Step 1: Keep Parquet tests explicitly legacy**
|
||||
|
||||
No behavior change is needed in `DuckDbParquetEventFileStoreTest`; leave it instantiating `DuckDbParquetEventFileStore` directly. Add a class comment:
|
||||
|
||||
```java
|
||||
/**
|
||||
* Compatibility coverage for the legacy Parquet sidecar backend.
|
||||
* Production history uses DuckDbHotEventFileStore through auto-configuration.
|
||||
*/
|
||||
class DuckDbParquetEventFileStoreTest {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Set vehicle-history-app storage default**
|
||||
|
||||
Add to `modules/apps/vehicle-history-app/src/main/resources/application.yml`:
|
||||
|
||||
```yaml
|
||||
event-file-store:
|
||||
enabled: ${EVENT_FILE_STORE_ENABLED:true}
|
||||
storage: ${EVENT_FILE_STORE_STORAGE:duckdb-hot}
|
||||
path: ${EVENT_FILE_STORE_PATH:./target/event-store/}
|
||||
zone-id: ${EVENT_FILE_STORE_ZONE_ID:Asia/Shanghai}
|
||||
batch-size: ${EVENT_FILE_STORE_BATCH_SIZE:1000}
|
||||
flush-interval-millis: ${EVENT_FILE_STORE_FLUSH_INTERVAL_MILLIS:1000}
|
||||
```
|
||||
|
||||
Keep existing indentation and only add `storage`; if `batch-size` already exists, update it to `1000`.
|
||||
|
||||
- [ ] **Step 3: Update app default test**
|
||||
|
||||
In `VehicleHistoryAppDefaultsTest`, assert:
|
||||
|
||||
```java
|
||||
assertThat(context.getEnvironment()
|
||||
.getProperty("lingniu.ingest.event-file-store.storage"))
|
||||
.isEqualTo("duckdb-hot");
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run app default tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl :vehicle-history-app -Dtest=VehicleHistoryAppDefaultsTest test
|
||||
```
|
||||
|
||||
Expected: default configuration test passes.
|
||||
|
||||
## Task 5: Verify History Ingest Still Archives RAW Bytes
|
||||
|
||||
**Files:**
|
||||
- Modify: `modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/EventHistoryEnvelopeIngestorTest.java`
|
||||
- Test: `modules/services/event-history-service/src/test/java/com/lingniu/ingest/eventhistory/Gb32960DecodedFrameServiceTest.java`
|
||||
|
||||
- [ ] **Step 1: Add an integration-style test using the hot store**
|
||||
|
||||
Add a test that writes a RAW envelope through `EventHistoryEnvelopeIngestor` into `DuckDbHotEventFileStore`, then finds it by URI.
|
||||
|
||||
```java
|
||||
@Test
|
||||
void rawArchiveEnvelopeCanBeFoundFromDuckDbHotStoreByUri(@TempDir Path tempDir) throws Exception {
|
||||
EventFileStore store = new DuckDbHotEventFileStore(tempDir, ZoneId.of("Asia/Shanghai"), OBJECT_MAPPER);
|
||||
CapturingArchiveStore archive = new CapturingArchiveStore();
|
||||
EventHistoryEnvelopeIngestor ingestor =
|
||||
new EventHistoryEnvelopeIngestor(store, new TelemetryEnvelopeRecordMapper(), archive);
|
||||
String rawArchiveKey = "2026/06/23/GB32960/VINRAW001/raw-event-hot.bin";
|
||||
String rawArchiveUri = "archive://" + rawArchiveKey;
|
||||
byte[] rawBytes = new byte[]{0x23, 0x23, 0x02, 0x01};
|
||||
|
||||
VehicleEnvelope envelope = VehicleEnvelope.newBuilder()
|
||||
.setSchemaVersion("1.0")
|
||||
.setEventId("raw-event-hot")
|
||||
.setVin("VINRAW001")
|
||||
.setSource("GB32960")
|
||||
.setProtocolVersion("V2016")
|
||||
.setEventTimeMs(1_782_112_400_000L)
|
||||
.setIngestTimeMs(1_782_112_401_000L)
|
||||
.putMetadata(RawArchiveKeys.META_KEY, rawArchiveKey)
|
||||
.putMetadata(RawArchiveKeys.META_URI, rawArchiveUri)
|
||||
.setRawArchive(RawArchiveRef.newBuilder()
|
||||
.setUri(rawArchiveUri)
|
||||
.setSizeBytes(rawBytes.length)
|
||||
.setData(ByteString.copyFrom(rawBytes))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
EnvelopeIngestResult result = ingestor.tryIngest(envelope.toByteArray());
|
||||
|
||||
assertThat(result.status()).isEqualTo(EnvelopeIngestResult.Status.STORED);
|
||||
assertThat(store.findByRawArchiveUri(rawArchiveUri))
|
||||
.isNotNull()
|
||||
.extracting(EventFileRecord::eventId)
|
||||
.isEqualTo("raw-event-hot");
|
||||
assertThat(archive.bytesByKey).containsEntry(rawArchiveKey, rawBytes);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the event history ingestor test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl :event-history-service -Dtest=EventHistoryEnvelopeIngestorTest test
|
||||
```
|
||||
|
||||
Expected: test passes.
|
||||
|
||||
- [ ] **Step 3: Run decoded frame service tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl :event-history-service -Dtest=Gb32960DecodedFrameServiceTest test
|
||||
```
|
||||
|
||||
Expected: existing replay/snapshot tests pass. If they use a fake store, no change is needed.
|
||||
|
||||
## Task 6: Full Module Verification
|
||||
|
||||
**Files:**
|
||||
- No source changes unless failures expose missing imports or config assertions.
|
||||
|
||||
- [ ] **Step 1: Run sink module tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl :event-file-store test
|
||||
```
|
||||
|
||||
Expected: all event-file-store tests pass, including both hot and legacy stores.
|
||||
|
||||
- [ ] **Step 2: Run event-history-service tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl :event-history-service test
|
||||
```
|
||||
|
||||
Expected: all event-history-service tests pass.
|
||||
|
||||
- [ ] **Step 3: Package vehicle-history-app**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
mvn -pl :vehicle-history-app -am package -DskipTests
|
||||
```
|
||||
|
||||
Expected: package succeeds.
|
||||
|
||||
## Task 7: Local Runtime Verification
|
||||
|
||||
**Files:**
|
||||
- No committed source changes.
|
||||
|
||||
- [ ] **Step 1: Stop any old history service on port 20200**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
lsof -tiTCP:20200 -sTCP:LISTEN | xargs -r kill
|
||||
```
|
||||
|
||||
Expected: no command output, or the old process exits.
|
||||
|
||||
- [ ] **Step 2: Start vehicle-history-app with hot DuckDB store**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
EVENT_FILE_STORE_STORAGE=duckdb-hot \
|
||||
EVENT_FILE_STORE_PATH=./target/live-history-event-store \
|
||||
SINK_ARCHIVE_PATH=./target/live-history-archive \
|
||||
KAFKA_BROKERS=114.55.58.251:9092 \
|
||||
KAFKA_CONSUMER_ENABLED=true \
|
||||
KAFKA_GROUP_HISTORY=vehicle-history-hot-$(date +%Y%m%d%H%M%S) \
|
||||
java --sun-misc-unsafe-memory-access=allow \
|
||||
-jar modules/apps/vehicle-history-app/target/vehicle-history-app.jar
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- app starts on `http://127.0.0.1:20200`;
|
||||
- logs show Kafka consumer subscribed;
|
||||
- `target/live-history-event-store/events.duckdb` is created.
|
||||
|
||||
- [ ] **Step 3: Verify health**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:20200/actuator/health
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```json
|
||||
{"status":"UP"}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify a recent VIN query**
|
||||
|
||||
Run with a VIN observed in live archive:
|
||||
|
||||
```bash
|
||||
curl -sS 'http://127.0.0.1:20200/api/event-history/gb32960/telemetry-snapshots?vin=LNXNEGRR9SR318194&platformAccount=Hyundai&dateFrom=2026-06-23&dateTo=2026-06-24&order=DESC&limit=3'
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- response is a JSON array;
|
||||
- if live traffic for that VIN exists after service start, at least one snapshot appears;
|
||||
- `rawArchiveUris` point to existing files under `target/live-history-archive`.
|
||||
|
||||
- [ ] **Step 5: Verify raw frame replay**
|
||||
|
||||
Use one `rawArchiveUri` from Step 4:
|
||||
|
||||
```bash
|
||||
curl -sS 'http://127.0.0.1:20200/api/event-history/gb32960/frame?rawArchiveUri=archive://REPLACE_ME&platformAccount=Hyundai'
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- response contains `vin`, `command`, `eventTime`, and parsed `blocks`;
|
||||
- no `raw archive is missing` warning for the selected URI.
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
- [ ] The new hot store does not rewrite Parquet files on every append.
|
||||
- [ ] `appendAll` writes one batch in one transaction.
|
||||
- [ ] Duplicate `event_id` replay is idempotent.
|
||||
- [ ] Existing history HTTP APIs still use `EventFileStore`, so controller contracts are unchanged.
|
||||
- [ ] RAW bytes still land in `ArchiveStore`.
|
||||
- [ ] `findByRawArchiveUri` is backed by DuckDB index.
|
||||
- [ ] No MySQL credential, RDS host, username, or password appears in the plan or source files.
|
||||
- [ ] This plan does not claim the full production goal is complete; it only lands the history foundation.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,46 +0,0 @@
|
||||
# JT808 Kafka Streaming Mileage Plan
|
||||
|
||||
## Current Goal
|
||||
|
||||
Consume JT808 Kafka location events and write the derived daily mileage into the common vehicle-stat metric repository.
|
||||
|
||||
## Current Design
|
||||
|
||||
- Only supported message backbone: Kafka.
|
||||
- Source topic: `vehicle.event.jt808.v1`
|
||||
- Runtime app: `vehicle-analytics-app`
|
||||
- Runtime state: none outside `vehicle_stat_metric`
|
||||
- Metric output: `VehicleStatRepository.recordDailyMileageSample(...)`
|
||||
- Production metric storage: JDBC/MySQL `vehicle_stat_metric`
|
||||
- Date boundary: `Asia/Shanghai`
|
||||
- Calculation method: `JT808_TOTAL_MILEAGE_DIFF`
|
||||
|
||||
JT808 daily mileage is calculated only from the GPS total mileage reported by JT808 location additional information:
|
||||
|
||||
```text
|
||||
daily_mileage_km = max_total_mileage_km - min_total_mileage_km
|
||||
```
|
||||
|
||||
The first valid local-day sample writes `daily_mileage_km=0.0`. Later ordered or replayed samples update the same metric row with:
|
||||
|
||||
```text
|
||||
metric_value = max_total_mileage_km - min_total_mileage_km
|
||||
metric_key = daily_mileage_km
|
||||
```
|
||||
|
||||
The local-day minimum and maximum GPS total mileage values stay on that same metric row as calculation source columns so restarts recover from MySQL without Redis or another state table.
|
||||
|
||||
## Runtime Properties
|
||||
|
||||
```text
|
||||
KAFKA_TOPIC_JT808_EVENT=vehicle.event.jt808.v1
|
||||
VEHICLE_STAT_ENABLED=true
|
||||
VEHICLE_STAT_JT808_MILEAGE_ENABLED=true
|
||||
MYSQL_JDBC_URL=<jdbc-url>
|
||||
MYSQL_USERNAME=<user>
|
||||
MYSQL_PASSWORD=<password>
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
Do not create or write a protocol-specific JT808 daily-mileage table. Do not add another message backbone, distance accumulation, integral calculation, Redis state, or memory state back into this path unless the mileage definition changes again.
|
||||
@@ -1,720 +0,0 @@
|
||||
# Go Vehicle Ingest Redesign Phase 1 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build the first production-capable Go runtime for GB32960, JT808, and Yutong MQTT ingestion with unified Kafka, TDengine, MySQL statistics, and Redis realtime state boundaries.
|
||||
|
||||
**Architecture:** Create a new single Go module at `go/vehicle-gateway` and migrate only the useful pieces from the existing `go/ingest-edge` and `go/vehicle-state` prototypes. The gateway produces unified envelopes to Kafka; independent consumers write TDengine history, MySQL daily metrics, and Redis realtime state.
|
||||
|
||||
**Tech Stack:** Go 1.26+, Kafka (`segmentio/kafka-go`), Redis (`redis/go-redis`), MySQL (`go-sql-driver/mysql`), TDengine official Go connector, MQTT (`eclipse/paho.mqtt.golang`), standard library TCP.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create: `go/vehicle-gateway/go.mod`
|
||||
- Create: `go/vehicle-gateway/cmd/gateway/main.go`
|
||||
- Create: `go/vehicle-gateway/cmd/history-writer/main.go`
|
||||
- Create: `go/vehicle-gateway/cmd/stat-writer/main.go`
|
||||
- Create: `go/vehicle-gateway/cmd/realtime-api/main.go`
|
||||
- Create: `go/vehicle-gateway/internal/envelope/envelope.go`
|
||||
- Create: `go/vehicle-gateway/internal/envelope/envelope_test.go`
|
||||
- Create: `go/vehicle-gateway/internal/protocol/jt808/*`
|
||||
- Create: `go/vehicle-gateway/internal/protocol/gb32960/*`
|
||||
- Create: `go/vehicle-gateway/internal/protocol/yutongmqtt/*`
|
||||
- Create: `go/vehicle-gateway/internal/gateway/*`
|
||||
- Create: `go/vehicle-gateway/internal/identity/*`
|
||||
- Create: `go/vehicle-gateway/internal/eventbus/*`
|
||||
- Create: `go/vehicle-gateway/internal/history/*`
|
||||
- Create: `go/vehicle-gateway/internal/stats/*`
|
||||
- Create: `go/vehicle-gateway/internal/realtime/*`
|
||||
- Create: `go/vehicle-gateway/internal/observability/*`
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/target-architecture.md`
|
||||
- Create: `deploy/portainer/docker-compose-go.yml`
|
||||
|
||||
The existing `go/ingest-edge` and `go/vehicle-state` directories are migration sources only. After phase 1 is verified, remove or mark them superseded.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create Single Go Module
|
||||
|
||||
**Files:**
|
||||
- Create: `go/vehicle-gateway/go.mod`
|
||||
- Create: `go/vehicle-gateway/internal/observability/logger.go`
|
||||
- Create: `go/vehicle-gateway/cmd/gateway/main.go`
|
||||
|
||||
- [ ] **Step 1: Create module manifest**
|
||||
|
||||
Add `go/vehicle-gateway/go.mod`:
|
||||
|
||||
```go
|
||||
module lingniu-vehicle-ingest/go/vehicle-gateway
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/redis/go-redis/v9 v9.17.2
|
||||
github.com/segmentio/kafka-go v0.4.49
|
||||
github.com/taosdata/driver-go/v3 v3.8.1
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add logger helper**
|
||||
|
||||
Add `go/vehicle-gateway/internal/observability/logger.go`:
|
||||
|
||||
```go
|
||||
package observability
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
func NewLogger(service string) *slog.Logger {
|
||||
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{AddSource: true})
|
||||
return slog.New(handler).With("service", service)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add temporary gateway entrypoint**
|
||||
|
||||
Add `go/vehicle-gateway/cmd/gateway/main.go`:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
|
||||
|
||||
func main() {
|
||||
logger := observability.NewLogger("vehicle-gateway")
|
||||
logger.Info("vehicle gateway scaffold started")
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify scaffold builds**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go mod tidy
|
||||
go test ./...
|
||||
go build ./cmd/gateway
|
||||
```
|
||||
|
||||
Expected: all commands exit `0`.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Define Unified Envelope
|
||||
|
||||
**Files:**
|
||||
- Create: `go/vehicle-gateway/internal/envelope/envelope.go`
|
||||
- Create: `go/vehicle-gateway/internal/envelope/envelope_test.go`
|
||||
|
||||
- [ ] **Step 1: Write envelope tests**
|
||||
|
||||
Add `go/vehicle-gateway/internal/envelope/envelope_test.go`:
|
||||
|
||||
```go
|
||||
package envelope
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFrameEnvelopeVehicleKeyPrefersVIN(t *testing.T) {
|
||||
e := FrameEnvelope{Protocol: ProtocolJT808, VIN: "LNBVIN00000000001", Phone: "013307795425"}
|
||||
if got := e.VehicleKey(); got != "LNBVIN00000000001" {
|
||||
t.Fatalf("VehicleKey() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameEnvelopeVehicleKeyFallsBackToPhone(t *testing.T) {
|
||||
e := FrameEnvelope{Protocol: ProtocolJT808, Phone: "013307795425"}
|
||||
if got := e.VehicleKey(); got != "JT808:013307795425" {
|
||||
t.Fatalf("VehicleKey() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameEnvelopeEventIDStable(t *testing.T) {
|
||||
e := FrameEnvelope{
|
||||
Protocol: ProtocolJT808,
|
||||
MessageID: "0x0200",
|
||||
Phone: "013307795425",
|
||||
Sequence: 1,
|
||||
EventTimeMS: 1782745114000,
|
||||
ReceivedAtMS: 1782745114999,
|
||||
RawHex: "7e02000000ff7e",
|
||||
}
|
||||
a := e.StableEventID()
|
||||
b := e.StableEventID()
|
||||
if a == "" || a != b {
|
||||
t.Fatalf("event id must be non-empty and stable: %q %q", a, b)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests and confirm failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/envelope
|
||||
```
|
||||
|
||||
Expected: fail because `FrameEnvelope` is not defined.
|
||||
|
||||
- [ ] **Step 3: Implement envelope**
|
||||
|
||||
Add `go/vehicle-gateway/internal/envelope/envelope.go`:
|
||||
|
||||
```go
|
||||
package envelope
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Protocol string
|
||||
|
||||
const (
|
||||
ProtocolGB32960 Protocol = "GB32960"
|
||||
ProtocolJT808 Protocol = "JT808"
|
||||
ProtocolYutongMQTT Protocol = "YUTONG_MQTT"
|
||||
)
|
||||
|
||||
type ParseStatus string
|
||||
|
||||
const (
|
||||
ParseOK ParseStatus = "OK"
|
||||
ParsePartial ParseStatus = "PARTIAL"
|
||||
ParseBadFrame ParseStatus = "BAD_FRAME"
|
||||
)
|
||||
|
||||
type FrameEnvelope struct {
|
||||
EventID string `json:"event_id"`
|
||||
TraceID string `json:"trace_id"`
|
||||
Protocol Protocol `json:"protocol"`
|
||||
MessageID string `json:"message_id"`
|
||||
Sequence uint16 `json:"sequence"`
|
||||
VIN string `json:"vin,omitempty"`
|
||||
VehicleKeyHint string `json:"vehicle_key,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
Plate string `json:"plate,omitempty"`
|
||||
SourceEndpoint string `json:"source_endpoint,omitempty"`
|
||||
EventTimeMS int64 `json:"event_time_ms"`
|
||||
ReceivedAtMS int64 `json:"received_at_ms"`
|
||||
RawHex string `json:"raw_hex,omitempty"`
|
||||
RawText string `json:"raw_text,omitempty"`
|
||||
Parsed map[string]any `json:"parsed,omitempty"`
|
||||
Fields map[string]any `json:"fields,omitempty"`
|
||||
ParseStatus ParseStatus `json:"parse_status"`
|
||||
ParseError string `json:"parse_error,omitempty"`
|
||||
}
|
||||
|
||||
func (e FrameEnvelope) VehicleKey() string {
|
||||
if key := strings.TrimSpace(e.VIN); key != "" {
|
||||
return key
|
||||
}
|
||||
if key := strings.TrimSpace(e.VehicleKeyHint); key != "" {
|
||||
return key
|
||||
}
|
||||
if key := strings.TrimSpace(e.Phone); key != "" {
|
||||
return string(e.Protocol) + ":" + key
|
||||
}
|
||||
if key := strings.TrimSpace(e.DeviceID); key != "" {
|
||||
return string(e.Protocol) + ":" + key
|
||||
}
|
||||
return string(e.Protocol) + ":unknown"
|
||||
}
|
||||
|
||||
func (e FrameEnvelope) StableEventID() string {
|
||||
if strings.TrimSpace(e.EventID) != "" {
|
||||
return e.EventID
|
||||
}
|
||||
input := fmt.Sprintf("%s|%s|%s|%d|%d|%s",
|
||||
e.Protocol, e.MessageID, e.VehicleKey(), e.Sequence, e.EventTimeMS, e.RawHex)
|
||||
sum := sha256.Sum256([]byte(input))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
func (e FrameEnvelope) MarshalJSONBytes() ([]byte, error) {
|
||||
if e.EventID == "" {
|
||||
e.EventID = e.StableEventID()
|
||||
}
|
||||
if e.ParseStatus == "" {
|
||||
e.ParseStatus = ParseOK
|
||||
}
|
||||
return json.Marshal(e)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify tests pass**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/envelope
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Implement JT808 Frame and 0200 Parser
|
||||
|
||||
**Files:**
|
||||
- Create: `go/vehicle-gateway/internal/protocol/jt808/parser.go`
|
||||
- Create: `go/vehicle-gateway/internal/protocol/jt808/parser_test.go`
|
||||
|
||||
- [ ] **Step 1: Add sample frame tests**
|
||||
|
||||
Use the production sample provided in the thread:
|
||||
|
||||
```text
|
||||
7E020000320133077954250001000000000048000301D2C4C707376139000A00E6004F26063016235701040001900C2504000000000202000030011F31010F867E
|
||||
```
|
||||
|
||||
Expected assertions:
|
||||
|
||||
- message id is `0x0200`
|
||||
- phone keeps normalized BCD string `013307795425`
|
||||
- sequence is `1`
|
||||
- `fields.total_mileage_km` is parsed from additional item `0x01`
|
||||
- latitude, longitude, speed, direction, alarm, status, and device time are present
|
||||
|
||||
- [ ] **Step 2: Implement parser**
|
||||
|
||||
Implementation rules:
|
||||
|
||||
- strip `0x7e` start/end delimiters
|
||||
- unescape `0x7d 0x02 -> 0x7e`
|
||||
- unescape `0x7d 0x01 -> 0x7d`
|
||||
- verify XOR checksum
|
||||
- parse 2011/2013 common header
|
||||
- parse BCD phone from 6 bytes and keep both raw and normalized values in `parsed.header`
|
||||
- parse 0200 fixed body
|
||||
- parse additional item list as `id,length,value_hex`
|
||||
- parse additional `0x01` as `total_mileage_km = uint32 / 10`
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/protocol/jt808
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Implement GB32960 Frame and Data Unit Parser
|
||||
|
||||
**Files:**
|
||||
- Create: `go/vehicle-gateway/internal/protocol/gb32960/parser.go`
|
||||
- Create: `go/vehicle-gateway/internal/protocol/gb32960/parser_test.go`
|
||||
|
||||
- [ ] **Step 1: Add parser tests**
|
||||
|
||||
Tests must cover:
|
||||
|
||||
- `##` frame boundary
|
||||
- command id
|
||||
- response flag
|
||||
- 17-byte VIN
|
||||
- encryption flag
|
||||
- payload length
|
||||
- BCC verification
|
||||
- realtime data command `0x02`
|
||||
- reissue data command `0x03`
|
||||
- data unit `0x01` vehicle status fields
|
||||
- data unit `0x05` position fields
|
||||
|
||||
- [ ] **Step 2: Implement parser**
|
||||
|
||||
Implementation rules:
|
||||
|
||||
- parse header without allocating large temporary buffers
|
||||
- keep RAW as hex in envelope
|
||||
- write all recognized data units to `Parsed`
|
||||
- write core fields to `Fields`
|
||||
- unsupported data unit stays in `Parsed["unknown_units"]`
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/protocol/gb32960
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Implement Kafka Sink
|
||||
|
||||
**Files:**
|
||||
- Create: `go/vehicle-gateway/internal/eventbus/kafka_sink.go`
|
||||
- Create: `go/vehicle-gateway/internal/eventbus/kafka_sink_test.go`
|
||||
|
||||
- [ ] **Step 1: Add sink interface**
|
||||
|
||||
Create a sink interface:
|
||||
|
||||
```go
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
type Sink interface {
|
||||
PublishRaw(context.Context, envelope.FrameEnvelope) error
|
||||
PublishUnified(context.Context, envelope.FrameEnvelope) error
|
||||
Close() error
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement Kafka topic routing**
|
||||
|
||||
Rules:
|
||||
|
||||
- `GB32960 -> vehicle.raw.gb32960.v1`
|
||||
- `JT808 -> vehicle.raw.jt808.v1`
|
||||
- `YUTONG_MQTT -> vehicle.raw.yutong-mqtt.v1`
|
||||
- unified topic is `vehicle.event.unified.v1`
|
||||
- message key is `env.VehicleKey()`
|
||||
|
||||
- [ ] **Step 3: Verify routing with unit tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/eventbus
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Implement TDengine History Writer
|
||||
|
||||
**Files:**
|
||||
- Create: `go/vehicle-gateway/internal/history/schema.go`
|
||||
- Create: `go/vehicle-gateway/internal/history/writer.go`
|
||||
- Create: `go/vehicle-gateway/internal/history/writer_test.go`
|
||||
- Create: `go/vehicle-gateway/cmd/history-writer/main.go`
|
||||
|
||||
- [ ] **Step 1: Add schema bootstrap SQL**
|
||||
|
||||
Implement schema strings for:
|
||||
|
||||
- database `lingniu_vehicle_ts`
|
||||
- stable `raw_frames`
|
||||
- stable `vehicle_locations`
|
||||
- stable `vehicle_mileage_points`
|
||||
|
||||
- [ ] **Step 2: Implement writer**
|
||||
|
||||
Rules:
|
||||
|
||||
- `AppendRawFrame` always writes one raw row.
|
||||
- `AppendLocation` writes only when longitude and latitude exist.
|
||||
- `AppendMileagePoint` writes only when `total_mileage_km` exists.
|
||||
- child table name is deterministic hash of `protocol + vehicle_key`.
|
||||
- escape tag values.
|
||||
|
||||
- [ ] **Step 3: Use TDengine official driver**
|
||||
|
||||
Import WebSocket driver in command:
|
||||
|
||||
```go
|
||||
import _ "github.com/taosdata/driver-go/v3/taosWS"
|
||||
```
|
||||
|
||||
Default driver name:
|
||||
|
||||
```text
|
||||
TDENGINE_DRIVER=taosWS
|
||||
```
|
||||
|
||||
Short-term compatibility:
|
||||
|
||||
```text
|
||||
TDENGINE_DRIVER=taosSql
|
||||
```
|
||||
|
||||
Only use `taosSql` when ECS TDengine WebSocket is not available.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/history
|
||||
go build ./cmd/history-writer
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Implement MySQL Daily Metric Writer
|
||||
|
||||
**Files:**
|
||||
- Create: `go/vehicle-gateway/internal/stats/schema.go`
|
||||
- Create: `go/vehicle-gateway/internal/stats/daily_metric.go`
|
||||
- Create: `go/vehicle-gateway/internal/stats/daily_metric_test.go`
|
||||
- Create: `go/vehicle-gateway/cmd/stat-writer/main.go`
|
||||
|
||||
- [ ] **Step 1: Add schema bootstrap**
|
||||
|
||||
Implement `vehicle_daily_metric` schema from the design spec.
|
||||
|
||||
- [ ] **Step 2: Add metric derivation tests**
|
||||
|
||||
Test cases:
|
||||
|
||||
- no `total_mileage_km` produces no metric
|
||||
- one sample produces:
|
||||
- `daily_mileage_km = 0`
|
||||
- `daily_total_mileage_km = sample`
|
||||
- later larger sample updates:
|
||||
- `latest_total_mileage_km`
|
||||
- `daily_mileage_km`
|
||||
- `daily_total_mileage_km`
|
||||
- out-of-order smaller sample updates:
|
||||
- `first_total_mileage_km`
|
||||
- `daily_mileage_km`
|
||||
|
||||
- [ ] **Step 3: Implement MySQL upsert**
|
||||
|
||||
Use one table and one idempotent upsert:
|
||||
|
||||
```sql
|
||||
INSERT INTO vehicle_daily_metric
|
||||
(vin, stat_date, protocol, metric_key, metric_value, metric_unit,
|
||||
first_total_mileage_km, latest_total_mileage_km, sample_count, calculation_method)
|
||||
VALUES (?, ?, ?, ?, ?, 'km', ?, ?, 1, 'TOTAL_MILEAGE_DIFF')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
first_total_mileage_km = LEAST(first_total_mileage_km, VALUES(first_total_mileage_km)),
|
||||
latest_total_mileage_km = GREATEST(latest_total_mileage_km, VALUES(latest_total_mileage_km)),
|
||||
metric_value = CASE
|
||||
WHEN metric_key = 'daily_mileage_km'
|
||||
THEN GREATEST(latest_total_mileage_km, VALUES(latest_total_mileage_km))
|
||||
- LEAST(first_total_mileage_km, VALUES(first_total_mileage_km))
|
||||
WHEN metric_key = 'daily_total_mileage_km'
|
||||
THEN GREATEST(latest_total_mileage_km, VALUES(latest_total_mileage_km))
|
||||
ELSE VALUES(metric_value)
|
||||
END,
|
||||
sample_count = sample_count + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/stats
|
||||
go build ./cmd/stat-writer
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Implement Redis Realtime API
|
||||
|
||||
**Files:**
|
||||
- Create: `go/vehicle-gateway/internal/realtime/repository.go`
|
||||
- Create: `go/vehicle-gateway/internal/realtime/repository_test.go`
|
||||
- Create: `go/vehicle-gateway/internal/realtime/http.go`
|
||||
- Create: `go/vehicle-gateway/cmd/realtime-api/main.go`
|
||||
|
||||
- [ ] **Step 1: Add repository contract**
|
||||
|
||||
Methods:
|
||||
|
||||
- `Update(ctx, envelope.FrameEnvelope) error`
|
||||
- `GetMerged(ctx, vin string) (Snapshot, error)`
|
||||
- `GetProtocol(ctx, vin string, protocol envelope.Protocol) (Snapshot, error)`
|
||||
- `IsOnline(ctx, vin string) (OnlineStatus, error)`
|
||||
|
||||
- [ ] **Step 2: Implement merge logic**
|
||||
|
||||
Rules:
|
||||
|
||||
- update `vehicle:latest:{vin}:{protocol}`
|
||||
- update `vehicle:latest:{vin}` with newest fields
|
||||
- update `vehicle:online:{vin}`
|
||||
- update sorted set `vehicle:last_seen`
|
||||
|
||||
- [ ] **Step 3: Implement HTTP API**
|
||||
|
||||
Routes:
|
||||
|
||||
- `GET /api/realtime/vehicles/{vin}`
|
||||
- `GET /api/realtime/vehicles/{vin}/online`
|
||||
- `GET /api/realtime/vehicles/{vin}/protocols/{protocol}`
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/realtime
|
||||
go build ./cmd/realtime-api
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Wire Gateway Runtime
|
||||
|
||||
**Files:**
|
||||
- Create: `go/vehicle-gateway/internal/gateway/tcp_server.go`
|
||||
- Create: `go/vehicle-gateway/internal/gateway/mqtt_client.go`
|
||||
- Modify: `go/vehicle-gateway/cmd/gateway/main.go`
|
||||
|
||||
- [ ] **Step 1: Implement TCP server**
|
||||
|
||||
Rules:
|
||||
|
||||
- one goroutine per accepted connection
|
||||
- bounded max connections
|
||||
- read timeout and idle timeout
|
||||
- protocol-specific frame extractor
|
||||
- structured peer endpoint
|
||||
- graceful shutdown on SIGTERM
|
||||
|
||||
- [ ] **Step 2: Implement MQTT client**
|
||||
|
||||
Rules:
|
||||
|
||||
- connect with official production config from environment
|
||||
- subscribe configured topic list
|
||||
- convert each message into envelope
|
||||
- publish raw and unified events
|
||||
- reconnect with backoff
|
||||
|
||||
- [ ] **Step 3: Verify local JSON mode**
|
||||
|
||||
Run without Kafka:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
GB32960_TCP_ADDR=:132960 JT808_TCP_ADDR=:18080 go run ./cmd/gateway
|
||||
```
|
||||
|
||||
Expected: service starts and logs configured listeners.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Docker and ECS Deployment
|
||||
|
||||
**Files:**
|
||||
- Create: `go/vehicle-gateway/Dockerfile`
|
||||
- Create: `deploy/portainer/docker-compose-go.yml`
|
||||
- Modify: `docs/operations/current-ecs-deployment.md`
|
||||
|
||||
- [ ] **Step 1: Add multi-stage Dockerfile**
|
||||
|
||||
Build all commands:
|
||||
|
||||
- `gateway`
|
||||
- `history-writer`
|
||||
- `stat-writer`
|
||||
- `realtime-api`
|
||||
|
||||
- [ ] **Step 2: Add Portainer compose**
|
||||
|
||||
Services:
|
||||
|
||||
- `go-vehicle-gateway`
|
||||
- `go-history-writer`
|
||||
- `go-stat-writer`
|
||||
- `go-realtime-api`
|
||||
|
||||
Each service must include:
|
||||
|
||||
- restart policy
|
||||
- memory limit
|
||||
- Kafka env
|
||||
- MySQL/TDengine/Redis env as needed
|
||||
- logging options
|
||||
|
||||
- [ ] **Step 3: Verify Linux build**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
GOOS=linux GOARCH=amd64 go build ./cmd/gateway
|
||||
GOOS=linux GOARCH=amd64 go build ./cmd/history-writer
|
||||
GOOS=linux GOARCH=amd64 go build ./cmd/stat-writer
|
||||
GOOS=linux GOARCH=amd64 go build ./cmd/realtime-api
|
||||
```
|
||||
|
||||
Expected: all commands exit `0`.
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Production Verification
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/operations/go-vehicle-gateway-verification.md`
|
||||
|
||||
- [ ] **Step 1: Record test commands**
|
||||
|
||||
Document commands to verify:
|
||||
|
||||
- gateway process health
|
||||
- Kafka topic consumption
|
||||
- TDengine row counts
|
||||
- MySQL daily metric rows
|
||||
- Redis realtime lookup
|
||||
|
||||
- [ ] **Step 2: Validate real traffic**
|
||||
|
||||
Evidence required:
|
||||
|
||||
- one real 32960 VIN with RAW, location, mileage point, daily metric, Redis snapshot
|
||||
- one real JT808 phone/VIN with RAW, location, mileage point, daily metric, Redis snapshot
|
||||
- one real Yutong MQTT VIN with RAW and Redis snapshot
|
||||
|
||||
- [ ] **Step 3: Keep old Java services until evidence is captured**
|
||||
|
||||
Only disable Java equivalents after the evidence file contains successful command outputs and timestamps.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
- The plan creates one new Go module instead of extending scattered prototypes.
|
||||
- The plan covers GB32960, JT808, and Yutong MQTT ingress.
|
||||
- The plan covers Kafka, TDengine, MySQL, and Redis.
|
||||
- The plan includes 32960 and 808 daily mileage and daily total mileage.
|
||||
- The plan includes local and ECS verification.
|
||||
- The plan does not restore Xinda Push.
|
||||
- The plan keeps Java services untouched until Go evidence exists.
|
||||
489
docs/superpowers/plans/2026-07-02-nats-kafka-ingest-refactor.md
Normal file
489
docs/superpowers/plans/2026-07-02-nats-kafka-ingest-refactor.md
Normal file
@@ -0,0 +1,489 @@
|
||||
# NATS Kafka Ingest Refactor Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Move vehicle ingress publishing from direct Kafka writes to NATS JetStream, then bridge NATS to existing Kafka topics so downstream history, realtime, and stats services keep working.
|
||||
|
||||
**Architecture:** `gateway` publishes parsed RAW and UNIFIED envelopes to NATS JetStream subjects. A new `nats-kafka-bridge` durable pull consumer reads from JetStream, writes batches to Kafka, and only ACKs NATS after Kafka succeeds. Existing Kafka consumers remain unchanged in this phase.
|
||||
|
||||
**Tech Stack:** Go 1.26, `github.com/nats-io/nats.go`, NATS JetStream, Kafka via `segmentio/kafka-go`, existing `eventbus.Sink`, systemd single-binary deployment on ECS.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `go/vehicle-gateway/internal/eventbus/nats_sink.go`: NATS JetStream implementation of `eventbus.Sink`.
|
||||
- Create `go/vehicle-gateway/internal/eventbus/nats_sink_test.go`: topic/subject routing and envelope serialization tests.
|
||||
- Modify `go/vehicle-gateway/cmd/gateway/main.go`: choose NATS sink when `NATS_URL` is configured; keep Kafka path as fallback.
|
||||
- Create `go/vehicle-gateway/cmd/nats-kafka-bridge/main.go`: bridge process that creates JetStream stream/consumer, pulls NATS messages, writes Kafka batches, then ACKs.
|
||||
- Create `go/vehicle-gateway/cmd/nats-kafka-bridge/main_test.go`: bridge batch ACK behavior and topic routing tests.
|
||||
- Modify `go/vehicle-gateway/go.mod`: add `github.com/nats-io/nats.go`.
|
||||
- Create `deploy/nats/nats-server.conf`: single-node JetStream config for Kafka ECS.
|
||||
- Modify ECS deploy process: include `nats-kafka-bridge` binary and systemd service.
|
||||
|
||||
## Task 1: Add NATS JetStream Sink
|
||||
|
||||
**Files:**
|
||||
- Create: `go/vehicle-gateway/internal/eventbus/nats_sink.go`
|
||||
- Create: `go/vehicle-gateway/internal/eventbus/nats_sink_test.go`
|
||||
- Modify: `go/vehicle-gateway/go.mod`
|
||||
|
||||
- [x] **Step 1: Write failing subject routing test**
|
||||
|
||||
Create `go/vehicle-gateway/internal/eventbus/nats_sink_test.go` with a fake JetStream publisher:
|
||||
|
||||
```go
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
func TestNATSSinkRoutesRawAndUnifiedSubjects(t *testing.T) {
|
||||
publisher := &recordingNATSPublisher{}
|
||||
sink := newNATSSinkWithPublisher(publisher, NATSConfig{
|
||||
RawSubjects: map[envelope.Protocol]string{
|
||||
envelope.ProtocolJT808: "vehicle.raw.jt808.v1",
|
||||
},
|
||||
UnifiedSubject: "vehicle.event.unified.v1",
|
||||
})
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
|
||||
|
||||
if err := sink.PublishRaw(context.Background(), env); err != nil {
|
||||
t.Fatalf("PublishRaw() error = %v", err)
|
||||
}
|
||||
if err := sink.PublishUnified(context.Background(), env); err != nil {
|
||||
t.Fatalf("PublishUnified() error = %v", err)
|
||||
}
|
||||
|
||||
if got, want := publisher.messages[0].subject, "vehicle.raw.jt808.v1"; got != want {
|
||||
t.Fatalf("raw subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := publisher.messages[1].subject, "vehicle.event.unified.v1"; got != want {
|
||||
t.Fatalf("unified subject = %q, want %q", got, want)
|
||||
}
|
||||
var decoded envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(publisher.messages[0].data, &decoded); err != nil {
|
||||
t.Fatalf("raw payload is not envelope json: %v", err)
|
||||
}
|
||||
if decoded.Phone != "13307795425" {
|
||||
t.Fatalf("decoded phone = %q", decoded.Phone)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingNATSPublisher struct {
|
||||
messages []recordedNATSMessage
|
||||
}
|
||||
|
||||
type recordedNATSMessage struct {
|
||||
subject string
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (p *recordingNATSPublisher) Publish(_ context.Context, subject string, data []byte, _ ...NATSPublishOption) error {
|
||||
p.messages = append(p.messages, recordedNATSMessage{subject: subject, data: append([]byte(nil), data...)})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
Run: `go test ./internal/eventbus -run TestNATSSinkRoutesRawAndUnifiedSubjects -count=1`
|
||||
|
||||
Expected: FAIL because `NATSConfig`, `newNATSSinkWithPublisher`, and `NATSPublishOption` do not exist.
|
||||
|
||||
- [x] **Step 2: Implement NATS sink**
|
||||
|
||||
Create `go/vehicle-gateway/internal/eventbus/nats_sink.go`:
|
||||
|
||||
```go
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
type NATSConfig struct {
|
||||
URL string
|
||||
Stream string
|
||||
RawSubjects map[envelope.Protocol]string
|
||||
UnifiedSubject string
|
||||
}
|
||||
|
||||
type NATSSink struct {
|
||||
conn *nats.Conn
|
||||
publisher natsPublisher
|
||||
rawSubjects map[envelope.Protocol]string
|
||||
unifiedSubject string
|
||||
}
|
||||
|
||||
type NATSPublishOption = nats.PubOpt
|
||||
|
||||
type natsPublisher interface {
|
||||
Publish(context.Context, string, []byte, ...NATSPublishOption) error
|
||||
}
|
||||
|
||||
func NewNATSSink(cfg NATSConfig) (*NATSSink, error) {
|
||||
if cfg.URL == "" {
|
||||
return nil, errors.New("nats url is required")
|
||||
}
|
||||
conn, err := nats.Connect(cfg.URL, nats.Name("lingniu-vehicle-gateway"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
js, err := conn.JetStream()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
sink := newNATSSinkWithPublisher(natsJetStreamPublisher{js: js}, cfg)
|
||||
sink.conn = conn
|
||||
return sink, nil
|
||||
}
|
||||
|
||||
func newNATSSinkWithPublisher(publisher natsPublisher, cfg NATSConfig) *NATSSink {
|
||||
rawSubjects := map[envelope.Protocol]string{
|
||||
envelope.ProtocolGB32960: "vehicle.raw.gb32960.v1",
|
||||
envelope.ProtocolJT808: "vehicle.raw.jt808.v1",
|
||||
envelope.ProtocolYutongMQTT: "vehicle.raw.yutong-mqtt.v1",
|
||||
}
|
||||
for protocol, subject := range cfg.RawSubjects {
|
||||
if subject != "" {
|
||||
rawSubjects[protocol] = subject
|
||||
}
|
||||
}
|
||||
unifiedSubject := cfg.UnifiedSubject
|
||||
if unifiedSubject == "" {
|
||||
unifiedSubject = "vehicle.event.unified.v1"
|
||||
}
|
||||
return &NATSSink{publisher: publisher, rawSubjects: rawSubjects, unifiedSubject: unifiedSubject}
|
||||
}
|
||||
|
||||
func (s *NATSSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
subject, ok := s.rawSubjects[env.Protocol]
|
||||
if !ok || subject == "" {
|
||||
return fmt.Errorf("raw subject not configured for protocol %s", env.Protocol)
|
||||
}
|
||||
return s.publish(ctx, subject, env)
|
||||
}
|
||||
|
||||
func (s *NATSSink) PublishUnified(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
return s.publish(ctx, s.unifiedSubject, env)
|
||||
}
|
||||
|
||||
func (s *NATSSink) Close() error {
|
||||
if s != nil && s.conn != nil {
|
||||
s.conn.Drain()
|
||||
s.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *NATSSink) publish(ctx context.Context, subject string, env envelope.FrameEnvelope) error {
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.publisher.Publish(ctx, subject, payload, nats.MsgId(env.StableEventID()))
|
||||
}
|
||||
|
||||
type natsJetStreamPublisher struct {
|
||||
js nats.JetStreamContext
|
||||
}
|
||||
|
||||
func (p natsJetStreamPublisher) Publish(ctx context.Context, subject string, data []byte, opts ...NATSPublishOption) error {
|
||||
_, err := p.js.Publish(subject, data, append(opts, nats.Context(ctx))...)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 3: Run eventbus tests**
|
||||
|
||||
Run: `go get github.com/nats-io/nats.go@latest && go test ./internal/eventbus -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 2: Gateway Sink Selection
|
||||
|
||||
**Files:**
|
||||
- Modify: `go/vehicle-gateway/cmd/gateway/main.go`
|
||||
|
||||
- [x] **Step 1: Write failing buildSink test**
|
||||
|
||||
Create `go/vehicle-gateway/cmd/gateway/main_test.go` if it does not exist, or add:
|
||||
|
||||
```go
|
||||
func TestBuildSinkPrefersNATSWhenConfigured(t *testing.T) {
|
||||
t.Setenv("NATS_URL", "nats://127.0.0.1:4222")
|
||||
t.Setenv("KAFKA_BROKERS", "127.0.0.1:9092")
|
||||
if got := chooseSinkMode(); got != "nats" {
|
||||
t.Fatalf("sink mode = %q, want nats", got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run: `go test ./cmd/gateway -run TestBuildSinkPrefersNATSWhenConfigured -count=1`
|
||||
|
||||
Expected: FAIL because `chooseSinkMode` does not exist.
|
||||
|
||||
- [x] **Step 2: Implement mode choice and NATS sink branch**
|
||||
|
||||
In `cmd/gateway/main.go`, add:
|
||||
|
||||
```go
|
||||
func chooseSinkMode() string {
|
||||
if strings.TrimSpace(os.Getenv("NATS_URL")) != "" {
|
||||
return "nats"
|
||||
}
|
||||
return "kafka"
|
||||
}
|
||||
```
|
||||
|
||||
At the top of `buildSink`, add:
|
||||
|
||||
```go
|
||||
if chooseSinkMode() == "nats" {
|
||||
sink, err := eventbus.NewNATSSink(eventbus.NATSConfig{
|
||||
URL: env("NATS_URL", ""),
|
||||
Stream: env("NATS_STREAM", "vehicle-ingest"),
|
||||
RawSubjects: map[envelope.Protocol]string{
|
||||
envelope.ProtocolGB32960: env("NATS_SUBJECT_GB32960_RAW", "vehicle.raw.gb32960.v1"),
|
||||
envelope.ProtocolJT808: env("NATS_SUBJECT_JT808_RAW", "vehicle.raw.jt808.v1"),
|
||||
envelope.ProtocolYutongMQTT: env("NATS_SUBJECT_YUTONG_MQTT_RAW", "vehicle.raw.yutong-mqtt.v1"),
|
||||
},
|
||||
UnifiedSubject: env("NATS_SUBJECT_UNIFIED", "vehicle.event.unified.v1"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Info("nats jetstream sink enabled", "url", env("NATS_URL", ""), "stream", env("NATS_STREAM", "vehicle-ingest"))
|
||||
return eventbus.NewAsyncSink(sink, eventbus.AsyncConfig{
|
||||
QueueSize: envInt("NATS_ASYNC_QUEUE_SIZE", 100000),
|
||||
Workers: envInt("NATS_ASYNC_WORKERS", 8),
|
||||
OperationTimeout: time.Duration(envInt("NATS_PUBLISH_TIMEOUT_MS", 30000)) * time.Millisecond,
|
||||
OnError: func(err error) {
|
||||
logger.Warn("nats async publish failed", "error", err)
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 3: Run gateway tests**
|
||||
|
||||
Run: `go test ./cmd/gateway -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 3: NATS to Kafka Bridge
|
||||
|
||||
**Files:**
|
||||
- Create: `go/vehicle-gateway/cmd/nats-kafka-bridge/main.go`
|
||||
- Create: `go/vehicle-gateway/cmd/nats-kafka-bridge/main_test.go`
|
||||
|
||||
- [x] **Step 1: Write failing bridge test**
|
||||
|
||||
Create a test with fake NATS messages and fake Kafka writer. It must prove that the bridge ACKs only after Kafka write succeeds:
|
||||
|
||||
```go
|
||||
func TestBridgeAcksAfterKafkaWrite(t *testing.T) {
|
||||
nats := &fakeNATSBatchReader{messages: []bridgeMessage{
|
||||
{subject: "vehicle.raw.jt808.v1", data: []byte(`{"protocol":"JT808","phone":"13307795425"}`)},
|
||||
}}
|
||||
kafka := &fakeKafkaBatchWriter{}
|
||||
bridge := bridgeProcessor{nats: nats, kafka: kafka, topics: defaultBridgeTopics()}
|
||||
|
||||
if err := bridge.processBatch(context.Background(), 10); err != nil {
|
||||
t.Fatalf("processBatch() error = %v", err)
|
||||
}
|
||||
if kafka.writeCalls != 1 {
|
||||
t.Fatalf("kafka write calls = %d, want 1", kafka.writeCalls)
|
||||
}
|
||||
if !nats.messages[0].acked {
|
||||
t.Fatal("nats message was not acked")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run: `go test ./cmd/nats-kafka-bridge -run TestBridgeAcksAfterKafkaWrite -count=1`
|
||||
|
||||
Expected: FAIL because bridge types do not exist.
|
||||
|
||||
- [x] **Step 2: Implement minimal bridge processor**
|
||||
|
||||
Implement small interfaces in `main.go`:
|
||||
|
||||
```go
|
||||
type bridgeMessage struct {
|
||||
subject string
|
||||
data []byte
|
||||
ack func() error
|
||||
}
|
||||
|
||||
type bridgeNATSReader interface {
|
||||
Fetch(context.Context, int) ([]bridgeMessage, error)
|
||||
}
|
||||
|
||||
type bridgeKafkaWriter interface {
|
||||
Write(context.Context, []kafka.Message) error
|
||||
}
|
||||
|
||||
type bridgeProcessor struct {
|
||||
nats bridgeNATSReader
|
||||
kafka bridgeKafkaWriter
|
||||
topics map[string]string
|
||||
}
|
||||
|
||||
func (p bridgeProcessor) processBatch(ctx context.Context, batchSize int) error {
|
||||
messages, err := p.nats.Fetch(ctx, batchSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kafkaMessages := make([]kafka.Message, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
topic, ok := p.topics[msg.subject]
|
||||
if !ok {
|
||||
return fmt.Errorf("no kafka topic for nats subject %s", msg.subject)
|
||||
}
|
||||
key := kafkaKeyFromEnvelope(msg.data)
|
||||
kafkaMessages = append(kafkaMessages, kafka.Message{Topic: topic, Key: key, Value: msg.data})
|
||||
}
|
||||
if len(kafkaMessages) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := p.kafka.Write(ctx, kafkaMessages); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, msg := range messages {
|
||||
if msg.ack != nil {
|
||||
if err := msg.ack(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 3: Implement real NATS and Kafka adapters**
|
||||
|
||||
Use `nats.PullSubscribe`, `Fetch`, `Ack`, and `kafka.Writer.WriteMessages`. The bridge must create or reuse a durable consumer named by `NATS_CONSUMER`, default `nats-kafka-bridge`.
|
||||
|
||||
- [x] **Step 4: Run bridge tests**
|
||||
|
||||
Run: `go test ./cmd/nats-kafka-bridge -count=1`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
## Task 4: NATS Server Deployment
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy/nats/nats-server.conf`
|
||||
|
||||
- [x] **Step 1: Create NATS config**
|
||||
|
||||
Create:
|
||||
|
||||
```conf
|
||||
server_name: lingniu-nats-01
|
||||
port: 4222
|
||||
http_port: 8222
|
||||
|
||||
jetstream {
|
||||
store_dir: "/data/nats/jetstream"
|
||||
max_file_store: 20GB
|
||||
max_mem_store: 512MB
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: Deploy on Kafka ECS**
|
||||
|
||||
On Kafka ECS, install or run `nats:2` with:
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/lingniu-nats /data/nats/jetstream
|
||||
docker run -d --name lingniu-nats --restart unless-stopped \
|
||||
-p 4222:4222 -p 8222:8222 \
|
||||
-v /opt/lingniu-nats/nats-server.conf:/etc/nats/nats-server.conf:ro \
|
||||
-v /data/nats:/data/nats \
|
||||
nats:2 -c /etc/nats/nats-server.conf
|
||||
```
|
||||
|
||||
- [x] **Step 3: Verify NATS**
|
||||
|
||||
Run from gateway ECS:
|
||||
|
||||
```bash
|
||||
timeout 3 bash -c '</dev/tcp/172.17.111.56/4222'
|
||||
```
|
||||
|
||||
Expected: exit 0.
|
||||
|
||||
## Task 5: ECS Cutover and Verification
|
||||
|
||||
**Files:**
|
||||
- Modify ECS env files only.
|
||||
|
||||
- [x] **Step 1: Deploy binaries**
|
||||
|
||||
Build and upload: `gateway`, `nats-kafka-bridge`, `history-writer`, `stat-writer`, `realtime-api`.
|
||||
|
||||
- [x] **Step 2: Configure gateway**
|
||||
|
||||
Add to `/opt/lingniu-go-native/env/gateway.env`:
|
||||
|
||||
```bash
|
||||
NATS_URL=nats://172.17.111.56:4222
|
||||
NATS_STREAM=vehicle-ingest
|
||||
NATS_ASYNC_QUEUE_SIZE=100000
|
||||
NATS_ASYNC_WORKERS=8
|
||||
NATS_PUBLISH_TIMEOUT_MS=30000
|
||||
```
|
||||
|
||||
- [x] **Step 3: Configure bridge service**
|
||||
|
||||
Create `/opt/lingniu-go-native/env/nats-kafka-bridge.env` with:
|
||||
|
||||
```bash
|
||||
NATS_URL=nats://172.17.111.56:4222
|
||||
NATS_STREAM=vehicle-ingest
|
||||
NATS_CONSUMER=nats-kafka-bridge
|
||||
NATS_BATCH_SIZE=500
|
||||
KAFKA_BROKERS=172.17.111.56:9092
|
||||
KAFKA_TOPIC_GB32960_RAW=vehicle.raw.go.gb32960.v1
|
||||
KAFKA_TOPIC_JT808_RAW=vehicle.raw.go.jt808.v1
|
||||
KAFKA_TOPIC_YUTONG_MQTT_RAW=vehicle.raw.go.yutong-mqtt.v1
|
||||
KAFKA_TOPIC_UNIFIED=vehicle.event.go.unified.v1
|
||||
```
|
||||
|
||||
- [x] **Step 4: Verify production**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
systemctl is-active lingniu-go-gateway.service lingniu-go-nats-kafka-bridge.service lingniu-go-history-writer.service lingniu-go-realtime-api.service
|
||||
ss -Htan state established "( sport = :32960 )"
|
||||
ss -Htan state established "( sport = :808 )" | wc -l
|
||||
journalctl -u lingniu-go-gateway.service --since '2 minutes ago' --no-pager | grep -E 'ERROR|deadline|nats async publish failed'
|
||||
journalctl -u lingniu-go-nats-kafka-bridge.service --since '2 minutes ago' --no-pager | grep -E 'ERROR|failed'
|
||||
```
|
||||
|
||||
Expected:
|
||||
- All services active.
|
||||
- 32960 Recv-Q remains 0 or near 0.
|
||||
- 808 connections remain stable.
|
||||
- No gateway publish deadline errors.
|
||||
- Kafka downstream consumers continue updating TDengine and Redis.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: gateway NATS publish, bridge to Kafka, ECS NATS deployment, and production verification are covered.
|
||||
- Scope intentionally excludes rewriting history/stat/realtime to consume NATS directly; they remain Kafka consumers in this phase.
|
||||
- No placeholders remain; later downlink `notice/reply` subjects are intentionally deferred because they are not needed for first reliable ingest cutover.
|
||||
336
docs/superpowers/plans/2026-07-02-storage-simplification.md
Normal file
336
docs/superpowers/plans/2026-07-02-storage-simplification.md
Normal file
@@ -0,0 +1,336 @@
|
||||
# Storage Simplification Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 精简车辆接入落库模型,去掉重复持久化,同时保持 raw 可回放、实时可查、历史位置和日指标可用。
|
||||
|
||||
**Architecture:** TDengine 只保留 raw 证据和位置历史两类高写入时间序列;MySQL 只保留身份映射、实时当前态和日指标。先兼容 API,再停止重复写入,最后在生产验证后删除旧表/旧列。
|
||||
|
||||
**Tech Stack:** Go, TDengine, MySQL, Redis, Kafka, NATS JetStream, `go test`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `go/vehicle-gateway/internal/history/schema.go`
|
||||
- Stop creating `vehicle_mileage_points` after API compatibility is in place.
|
||||
- Modify: `go/vehicle-gateway/internal/history/writer.go`
|
||||
- Stop calling `AppendMileagePoint`; keep total mileage in `vehicle_locations`.
|
||||
- Modify: `go/vehicle-gateway/internal/history/query.go`
|
||||
- Make `MileagePointRepository` read from `vehicle_locations` with `total_mileage_km IS NOT NULL`.
|
||||
- Modify: `go/vehicle-gateway/internal/history/query_test.go`
|
||||
- Update mileage point query expectations to `vehicle_locations`.
|
||||
- Modify: `go/vehicle-gateway/internal/history/writer_test.go`
|
||||
- Prove no `vehicle_mileage_points` stable/table is created or written.
|
||||
- Modify: `go/vehicle-gateway/internal/history/schema.go`
|
||||
- In a later task, remove `fields_json` from new `raw_frames` schema.
|
||||
- Modify: `go/vehicle-gateway/internal/history/writer.go`
|
||||
- In a later task, stop writing `fields_json`.
|
||||
- Modify: `go/vehicle-gateway/internal/history/query.go`
|
||||
- In a later task, make raw query tolerate schemas without `fields_json`.
|
||||
- Modify: `docs/architecture/storage-minimal-contract.md`
|
||||
- Keep the target storage contract updated as implementation lands.
|
||||
|
||||
### Task 1: Move mileage point queries onto `vehicle_locations`
|
||||
|
||||
**Files:**
|
||||
- Modify: `go/vehicle-gateway/internal/history/query.go`
|
||||
- Modify: `go/vehicle-gateway/internal/history/query_test.go`
|
||||
|
||||
- [x] **Step 1: Write the failing test**
|
||||
|
||||
Update `TestMileagePointHandlerReturnsMileageByVehicleKey` in `go/vehicle-gateway/internal/history/query_test.go` so it expects `vehicle_locations`, not `vehicle_mileage_points`:
|
||||
|
||||
```go
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM lingniu_vehicle_ts.vehicle_locations").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(17))
|
||||
mock.ExpectQuery("SELECT ts, event_id, frame_id, received_at, total_mileage_km, speed_kmh, longitude, latitude, protocol, vehicle_key, vin, phone, device_id FROM lingniu_vehicle_ts.vehicle_locations").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"ts", "event_id", "frame_id", "received_at", "total_mileage_km", "speed_kmh", "longitude", "latitude",
|
||||
"protocol", "vehicle_key", "vin", "phone", "device_id",
|
||||
}).AddRow("2026-07-02 10:00:00.000", "event-1", "frame-1", "2026-07-02 10:00:01.000", 8792.8, 8.0, 121.07764, 31.22436, "JT808", "JT808:13307811350", "VIN001", "13307811350", "dev001"))
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/history -run 'TestMileagePointHandlerReturnsMileageByVehicleKey|TestBuildMileagePointSQLUsesLiteralsForTDengine' -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL because `MileagePointRepository.tableName()` still returns `vehicle_mileage_points`.
|
||||
|
||||
- [x] **Step 3: Point mileage repository at location table**
|
||||
|
||||
Change `MileagePointRepository.tableName()` in `go/vehicle-gateway/internal/history/query.go`:
|
||||
|
||||
```go
|
||||
func (r *MileagePointRepository) tableName() string {
|
||||
if r.database == "" {
|
||||
return "vehicle_locations"
|
||||
}
|
||||
return r.database + ".vehicle_locations"
|
||||
}
|
||||
```
|
||||
|
||||
Change `mileagePointWhere` so it filters only rows with total mileage:
|
||||
|
||||
```go
|
||||
func mileagePointWhere(query MileagePointQuery) []string {
|
||||
where := []string{"total_mileage_km IS NOT NULL"}
|
||||
add := func(condition string) {
|
||||
where = append(where, condition)
|
||||
}
|
||||
if query.Protocol != "" {
|
||||
add("protocol = '" + quote(query.Protocol) + "'")
|
||||
}
|
||||
if query.VehicleKey != "" {
|
||||
add("vehicle_key = '" + quote(query.VehicleKey) + "'")
|
||||
}
|
||||
if query.VIN != "" {
|
||||
add("vin = '" + quote(query.VIN) + "'")
|
||||
}
|
||||
if query.Phone != "" {
|
||||
add("phone = '" + quote(query.Phone) + "'")
|
||||
}
|
||||
if query.DeviceID != "" {
|
||||
add("device_id = '" + quote(query.DeviceID) + "'")
|
||||
}
|
||||
if query.DateFrom != "" {
|
||||
add("ts >= '" + quote(normalizeDateTimeLiteral(query.DateFrom)) + "'")
|
||||
}
|
||||
if query.DateTo != "" {
|
||||
add("ts <= '" + quote(normalizeDateTimeLiteral(query.DateTo)) + "'")
|
||||
}
|
||||
return where
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 4: Run tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/history -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add go/vehicle-gateway/internal/history/query.go go/vehicle-gateway/internal/history/query_test.go
|
||||
git commit -m "refactor(go): read mileage points from locations"
|
||||
```
|
||||
|
||||
### Task 2: Stop writing duplicate mileage point table
|
||||
|
||||
**Files:**
|
||||
- Modify: `go/vehicle-gateway/internal/history/schema.go`
|
||||
- Modify: `go/vehicle-gateway/internal/history/writer.go`
|
||||
- Modify: `go/vehicle-gateway/internal/history/writer_test.go`
|
||||
|
||||
- [x] **Step 1: Write the failing tests**
|
||||
|
||||
Update `go/vehicle-gateway/internal/history/writer_test.go`:
|
||||
|
||||
```go
|
||||
for _, want := range []string{"CREATE DATABASE IF NOT EXISTS test_ts", "raw_frames", "vehicle_locations"} {
|
||||
if got := countSQL(exec.calls, want); got == 0 {
|
||||
t.Fatalf("expected schema statement containing %q", want)
|
||||
}
|
||||
}
|
||||
if got := countSQL(exec.calls, "vehicle_mileage_points"); got != 0 {
|
||||
t.Fatalf("schema should not create vehicle_mileage_points, got %d", got)
|
||||
}
|
||||
if got := countSQL(exec.calls, "INSERT INTO mil_"); got != 0 {
|
||||
t.Fatalf("history writer should not insert duplicate mileage point rows, got %d", got)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/history -run 'TestWriter' -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL because schema and writer still create/write `vehicle_mileage_points`.
|
||||
|
||||
- [x] **Step 3: Remove duplicate write path**
|
||||
|
||||
Change `AppendAll` in `go/vehicle-gateway/internal/history/writer.go`:
|
||||
|
||||
```go
|
||||
func (w *Writer) AppendAll(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
if err := w.AppendRawFrame(ctx, env); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.AppendLocation(ctx, env)
|
||||
}
|
||||
```
|
||||
|
||||
Remove `vehicle_mileage_points` from `SchemaStatements` in `go/vehicle-gateway/internal/history/schema.go`. Keep `AppendMileagePoint` only if tests still use it directly; otherwise remove the function and its helper after compile errors guide cleanup.
|
||||
|
||||
- [x] **Step 4: Run tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/history ./cmd/history-writer -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add go/vehicle-gateway/internal/history/schema.go go/vehicle-gateway/internal/history/writer.go go/vehicle-gateway/internal/history/writer_test.go
|
||||
git commit -m "refactor(go): stop writing duplicate mileage history"
|
||||
```
|
||||
|
||||
### Task 3: Remove `fields_json` from new raw writes
|
||||
|
||||
**Files:**
|
||||
- Modify: `go/vehicle-gateway/internal/history/schema.go`
|
||||
- Modify: `go/vehicle-gateway/internal/history/writer.go`
|
||||
- Modify: `go/vehicle-gateway/internal/history/writer_test.go`
|
||||
- Modify: `go/vehicle-gateway/internal/history/query.go`
|
||||
- Modify: `go/vehicle-gateway/internal/history/query_test.go`
|
||||
|
||||
- [x] **Step 1: Write failing writer test**
|
||||
|
||||
Add this assertion in the raw insert test:
|
||||
|
||||
```go
|
||||
rawInsert := findSQL(exec.calls, "INSERT INTO raw_")
|
||||
if strings.Contains(rawInsert, "fields_json") {
|
||||
t.Fatalf("raw insert should not write duplicate fields_json: %s", rawInsert)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/history -run 'TestWriter' -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL because raw insert still includes `fields_json`.
|
||||
|
||||
- [x] **Step 3: Remove `fields_json` from schema and writer**
|
||||
|
||||
In `go/vehicle-gateway/internal/history/schema.go`, remove:
|
||||
|
||||
```sql
|
||||
fields_json BINARY(4096),
|
||||
```
|
||||
|
||||
In `go/vehicle-gateway/internal/history/writer.go`, remove the `fieldsJSON` variable and `fields_json` insert column. Keep `parsed_json` complete.
|
||||
|
||||
- [x] **Step 4: Make raw query backward compatible**
|
||||
|
||||
Query only columns that exist in both old and new schemas. Old production tables may still contain `fields_json`, but the API no longer selects or returns it:
|
||||
|
||||
```go
|
||||
func buildRawFrameSQL(table string, query RawFrameQuery) (string, []any) {
|
||||
sqlText := `SELECT ts, frame_id, event_id, message_id, event_time, received_at, raw_size_bytes, raw_hex, raw_text, parsed_json, parse_status, parse_error, source_endpoint, protocol, vehicle_key, vin, phone, device_id FROM ` + table
|
||||
where := rawFrameWhere(query)
|
||||
if len(where) > 0 {
|
||||
sqlText += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
sqlText += " ORDER BY " + rawFrameOrderColumn(query.OrderBy) + " DESC LIMIT " + strconv.Itoa(query.Limit) + " OFFSET " + strconv.Itoa(query.Offset)
|
||||
return sqlText, nil
|
||||
}
|
||||
```
|
||||
|
||||
The new-schema test scans a row without `fields_json` and asserts the JSON response omits `fields_json`. This also works on old schemas because selecting a subset of columns is valid when the table has extra columns.
|
||||
|
||||
- [x] **Step 5: Run tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/history ./cmd/history-writer ./cmd/realtime-api -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add go/vehicle-gateway/internal/history/schema.go go/vehicle-gateway/internal/history/writer.go go/vehicle-gateway/internal/history/writer_test.go go/vehicle-gateway/internal/history/query.go go/vehicle-gateway/internal/history/query_test.go
|
||||
git commit -m "refactor(go): remove duplicate raw fields json"
|
||||
```
|
||||
|
||||
### Task 4: Production migration and cleanup gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/architecture/storage-minimal-contract.md`
|
||||
- Modify: `docs/ops/vehicle-ingest-runbook.md`
|
||||
|
||||
- [x] **Step 1: Deploy the code changes**
|
||||
|
||||
Built Linux amd64 binaries for `history-writer` and `realtime-api`, uploaded them to the current ECS release, and restarted:
|
||||
|
||||
```bash
|
||||
systemctl restart lingniu-go-history-writer.service
|
||||
systemctl restart lingniu-go-realtime-api.service
|
||||
```
|
||||
|
||||
- [x] **Step 2: Verify service health**
|
||||
|
||||
Run on ECS:
|
||||
|
||||
```bash
|
||||
for port in 20212 20200; do
|
||||
curl -fsS "http://127.0.0.1:${port}/readyz"
|
||||
echo
|
||||
done
|
||||
curl -fsS http://127.0.0.1:20212/metrics | grep vehicle_history_kafka_lag
|
||||
curl -fsS http://127.0.0.1:20212/metrics | grep vehicle_history_writes_total
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- both readiness endpoints return `status=ok`
|
||||
- history lag drains to `0` or remains bounded
|
||||
- history writes continue increasing
|
||||
|
||||
- [x] **Step 3: Verify no new duplicate mileage table writes**
|
||||
|
||||
Because the system is not live yet and historical data can be discarded, the TDengine history database was dropped and recreated from the new schema:
|
||||
|
||||
```sql
|
||||
DROP DATABASE IF EXISTS lingniu_vehicle_ts;
|
||||
DESCRIBE lingniu_vehicle_ts.raw_frames;
|
||||
SHOW lingniu_vehicle_ts.STABLES;
|
||||
```
|
||||
|
||||
Observed stables after recreation: `raw_frames`, `raw_frame_payload_chunks`, `vehicle_locations`. `vehicle_mileage_points` is gone.
|
||||
|
||||
- [x] **Step 4: Drop old table only after observation**
|
||||
|
||||
Old history data was intentionally discarded before launch. No separate observation window is required for `vehicle_mileage_points`.
|
||||
|
||||
- [x] **Step 5: Commit docs update**
|
||||
|
||||
```bash
|
||||
git add docs/architecture/storage-minimal-contract.md docs/ops/vehicle-ingest-runbook.md
|
||||
git commit -m "docs: record storage simplification migration"
|
||||
```
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: The plan addresses minimal storage, duplicate mileage storage, raw field duplication, production verification, and delayed destructive cleanup.
|
||||
- Red-flag scan: The plan has no open-ended task. Task 3 specifies the compatibility option and the new/old schema test expectations.
|
||||
- Type consistency: The plan keeps existing public API types and changes only their backing tables.
|
||||
@@ -0,0 +1,550 @@
|
||||
# 100K Vehicle Ingest Hardening Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Move the Go vehicle ingest platform toward stable production operation for 100,000 vehicles.
|
||||
|
||||
**Architecture:** Keep the current Gateway -> NATS -> Kafka -> TDengine/Redis/MySQL architecture, but make capacity explicit, add reproducible load testing, harden OS/systemd/network limits, and remove single-threaded or per-message bottlenecks from storage projectors. The first target is predictable behavior under high connection count and high frame rate; horizontal scaling comes after measurable single-node limits.
|
||||
|
||||
**Tech Stack:** Go 1.26, systemd, Linux TCP tuning, NATS JetStream, Kafka, Redis, TDengine, MySQL, Prometheus-style `/metrics`.
|
||||
|
||||
---
|
||||
|
||||
## Current Baseline
|
||||
|
||||
Observed on 2026-07-03 from ECS `115.29.187.205`:
|
||||
|
||||
| Item | Current Observation |
|
||||
| --- | --- |
|
||||
| Gateway active connections | JT808 about `242`, GB32960 about `2` |
|
||||
| ECS load | about `0.09 / 0.15 / 0.21` |
|
||||
| Memory | about `7.5 GB total`, `6.6 GB available` |
|
||||
| Disk | `/` about `37%` used |
|
||||
| NATS bridge ack pending | `0` |
|
||||
| NATS bridge pending | about `26` |
|
||||
| Kafka lag | `0` across history/realtime/stat sampled metrics |
|
||||
| Redis projector latency | about `0 ms` gauge |
|
||||
| MySQL projector latency | about `1-2 ms` gauge |
|
||||
| Gateway file limit | `1048576` |
|
||||
| Gateway max processes | `30123` |
|
||||
| Kernel `net.core.somaxconn` | `128` |
|
||||
| Gateway listen backlog | Go `net.Listen` default, bounded by `somaxconn` |
|
||||
| Gateway default max connections | env default adjusted to `TCP_MAX_CONNECTIONS=120000` |
|
||||
|
||||
Key gap: production is healthy at current load, but it has not proved 100K connection or high burst behavior. The current OS accept backlog and gateway default connection cap are below the target.
|
||||
|
||||
## Capacity Assumptions
|
||||
|
||||
Use these as starting targets. Revise after load test evidence.
|
||||
|
||||
| Scenario | Target |
|
||||
| --- | --- |
|
||||
| Connected vehicles | `100,000` total |
|
||||
| Average frame interval | 10-30 seconds depending protocol |
|
||||
| Average ingress FPS | `3,000-10,000 frames/s` |
|
||||
| Burst ingress FPS | `20,000 frames/s` for short periods |
|
||||
| Gateway p99 parse + enqueue | `< 50 ms` |
|
||||
| Gateway publish queue drops | `0` under target load |
|
||||
| NATS ack pending | sustained `0`, short spikes acceptable |
|
||||
| Kafka lag | bounded and decreasing after burst |
|
||||
| Redis current-state lag | `< 3 seconds` under normal load |
|
||||
| TDengine history lag | bounded and observable |
|
||||
| MySQL snapshot lag | can be lower priority and async; must not block Redis |
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Responsibility |
|
||||
| --- | --- |
|
||||
| `docs/superpowers/plans/2026-07-03-100k-vehicle-ingest-hardening.md` | This implementation plan |
|
||||
| `docs/ops/go-vehicle-ingest-memory.md` | Operational memory, update after each production change |
|
||||
| `docs/ops/vehicle-ingest-runbook.md` | Production runbook, add capacity and tuning commands |
|
||||
| `go/vehicle-gateway/cmd/load-sim/main.go` | New load simulator for JT808/GB32960 TCP and MQTT-like payload replay |
|
||||
| `go/vehicle-gateway/internal/loadsim/*` | New reusable load simulation helpers |
|
||||
| `go/vehicle-gateway/internal/gateway/tcp_server.go` | Gateway connection handling, metrics, connection admission |
|
||||
| `go/vehicle-gateway/cmd/gateway/main.go` | Gateway env defaults for 100K mode |
|
||||
| `go/vehicle-gateway/internal/eventbus/async_sink.go` | Async publish queue metrics/backpressure behavior |
|
||||
| `go/vehicle-gateway/cmd/nats-fast-writer/main.go` | Fast writer batching and worker behavior |
|
||||
| `go/vehicle-gateway/cmd/history-writer/main.go` | Kafka/TDengine consume/write batching |
|
||||
| `go/vehicle-gateway/internal/history/writer.go` | TDengine write path; later batch insert |
|
||||
| `go/vehicle-gateway/cmd/realtime-api/main.go` | Redis-first and MySQL async projector behavior |
|
||||
| `deploy/systemd/*.conf` or `docs/ops/*.md` | Documented sysctl/systemd tuning until deployment templates exist |
|
||||
|
||||
## Task 1: Capacity Baseline and Production Guardrails
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/ops/go-vehicle-ingest-memory.md`
|
||||
- Modify: `docs/ops/vehicle-ingest-runbook.md`
|
||||
- Create: `docs/ops/100k-capacity-baseline.md`
|
||||
|
||||
- [x] **Step 1: Create the capacity baseline document**
|
||||
|
||||
Create `docs/ops/100k-capacity-baseline.md` with:
|
||||
|
||||
```markdown
|
||||
# 100K 车辆接入容量基线
|
||||
|
||||
更新时间:2026-07-03
|
||||
|
||||
## 目标
|
||||
|
||||
支撑 100,000 台车辆接入,入口服务在高连接数和高帧率下保持可观测、可背压、可恢复。
|
||||
|
||||
## 初始容量假设
|
||||
|
||||
| 项 | 目标 |
|
||||
| --- | --- |
|
||||
| 连接车辆数 | 100,000 |
|
||||
| 平均帧间隔 | 10-30 秒 |
|
||||
| 平均入口 FPS | 3,000-10,000 |
|
||||
| 短时 burst FPS | 20,000 |
|
||||
| Gateway p99 parse + enqueue | < 50ms |
|
||||
| Redis 当前态延迟 | < 3s |
|
||||
| TDengine 历史写入 lag | 可观测且 burst 后下降 |
|
||||
| MySQL 当前态 | 异步,不能阻塞 Redis |
|
||||
|
||||
## 当前生产观测
|
||||
|
||||
当前 ECS `115.29.187.205` 在低负载下健康:
|
||||
|
||||
- JT808 连接约 242。
|
||||
- GB32960 连接约 2。
|
||||
- Kafka lag 为 0。
|
||||
- NATS ack pending 为 0。
|
||||
- Redis 写入约 0ms。
|
||||
- MySQL 投影约 1-2ms。
|
||||
|
||||
## 当前已知缺口
|
||||
|
||||
- `net.core.somaxconn=128`,无法作为 100K 连接生产基线。
|
||||
- Gateway 默认 `TCP_MAX_CONNECTIONS` 已调整为 `120000`,生产仍可通过环境变量覆盖。
|
||||
- 已新增可重复的 TCP 连接压测工具,后续需要用它跑 10K/50K/100K 阶段测试并记录结果。
|
||||
- 仍缺按协议维度的连接生命周期、读超时、parse/publish p95/p99 直方图。
|
||||
- TDengine history writer 当前逐消息插入,后续需要批量写入。
|
||||
- Kafka topic 当前 12 分区,100K 目标下需要结合实际 FPS 再评估分区数。
|
||||
```
|
||||
|
||||
- [x] **Step 2: Update runbook with capacity baseline link**
|
||||
|
||||
Add this line near the top of `docs/ops/vehicle-ingest-runbook.md` after the production inventory link:
|
||||
|
||||
```markdown
|
||||
10W 车辆容量目标、当前缺口和压测口径见 [100K 车辆接入容量基线](100k-capacity-baseline.md)。
|
||||
```
|
||||
|
||||
- [x] **Step 3: Verify docs render**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "100K|10W|容量基线|somaxconn|TCP_MAX_CONNECTIONS" docs/ops docs/architecture
|
||||
```
|
||||
|
||||
Expected: the new baseline and runbook link are found.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/ops/100k-capacity-baseline.md docs/ops/vehicle-ingest-runbook.md docs/ops/go-vehicle-ingest-memory.md
|
||||
git commit -m "docs: add 100k ingest capacity baseline"
|
||||
```
|
||||
|
||||
## Task 2: Load Simulator Skeleton
|
||||
|
||||
**Files:**
|
||||
- Create: `go/vehicle-gateway/internal/loadsim/config.go`
|
||||
- Create: `go/vehicle-gateway/internal/loadsim/config_test.go`
|
||||
- Create: `go/vehicle-gateway/internal/loadsim/templates.go`
|
||||
- Create: `go/vehicle-gateway/internal/loadsim/template_test.go`
|
||||
- Create: `go/vehicle-gateway/internal/loadsim/schedule.go`
|
||||
- Create: `go/vehicle-gateway/internal/loadsim/schedule_test.go`
|
||||
- Create: `go/vehicle-gateway/internal/loadsim/runner.go`
|
||||
- Create: `go/vehicle-gateway/internal/loadsim/runner_test.go`
|
||||
- Create: `go/vehicle-gateway/cmd/load-sim/main.go`
|
||||
- Create: `go/vehicle-gateway/cmd/load-sim/main_test.go`
|
||||
|
||||
- [x] **Step 1: Write failing tests for loadsim config, templates, schedule, runner and CLI stats**
|
||||
|
||||
Implemented tests:
|
||||
|
||||
- `TestConfigFromFlagsParsesCapacityKnobs`
|
||||
- `TestConfigFromFlagsRejectsUnsafeValues`
|
||||
- `TestFrameTemplateReturnsParsableJT808LocationFrame`
|
||||
- `TestFrameTemplateReturnsParsableGB32960RealtimeFrame`
|
||||
- `TestConnectionBatchesSpreadsConnectionsByRate`
|
||||
- `TestRunnerDialsTargetConnectionsAndWritesFrames`
|
||||
- `TestFormatStatsIncludesCapacityCounters`
|
||||
|
||||
- [x] **Step 2: Run tests to verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/loadsim
|
||||
```
|
||||
|
||||
Expected: FAIL because package or symbols do not exist.
|
||||
|
||||
- [x] **Step 3: Implement minimal load simulator**
|
||||
|
||||
Implemented:
|
||||
|
||||
- flag config validation
|
||||
- parsable JT808 0x0200 and GB32960 realtime frame templates
|
||||
- connection batch scheduling
|
||||
- TCP runner with dial injection for tests
|
||||
- `cmd/load-sim` CLI
|
||||
|
||||
- [x] **Step 4: Run test to verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/loadsim
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [x] **Step 5: Add CLI skeleton**
|
||||
|
||||
Implemented `go/vehicle-gateway/cmd/load-sim/main.go`.
|
||||
|
||||
- [x] **Step 6: Verify CLI builds**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./cmd/load-sim ./internal/loadsim
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add go/vehicle-gateway/internal/loadsim go/vehicle-gateway/cmd/load-sim
|
||||
git commit -m "test(go): add vehicle ingest load simulator skeleton"
|
||||
```
|
||||
|
||||
## Task 3: Gateway Capacity Configuration
|
||||
|
||||
**Files:**
|
||||
- Modify: `go/vehicle-gateway/cmd/gateway/main.go`
|
||||
- Modify: `docs/ops/100k-capacity-baseline.md`
|
||||
- Modify: `docs/ops/vehicle-ingest-runbook.md`
|
||||
|
||||
- [x] **Step 1: Write test for 100K connection env default**
|
||||
|
||||
Added `TestGatewayDefaultsTo100KConnectionCeiling`.
|
||||
|
||||
- [x] **Step 2: Run test to verify RED before changing default**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./cmd/gateway
|
||||
```
|
||||
|
||||
Expected: FAIL while call site still used `20000`.
|
||||
|
||||
- [x] **Step 3: Change gateway default**
|
||||
|
||||
In `go/vehicle-gateway/cmd/gateway/main.go`, change the TCP server config default:
|
||||
|
||||
```go
|
||||
MaxConnections: envInt("TCP_MAX_CONNECTIONS", 120_000),
|
||||
```
|
||||
|
||||
- [x] **Step 4: Document required sysctl**
|
||||
|
||||
Add to `docs/ops/100k-capacity-baseline.md`:
|
||||
|
||||
```markdown
|
||||
## ECS OS 参数建议
|
||||
|
||||
100K 连接目标需要至少以下系统参数作为起点:
|
||||
|
||||
```bash
|
||||
sysctl -w net.core.somaxconn=65535
|
||||
sysctl -w net.ipv4.tcp_max_syn_backlog=65535
|
||||
sysctl -w net.ipv4.ip_local_port_range="10000 65000"
|
||||
sysctl -w net.ipv4.tcp_tw_reuse=1
|
||||
```
|
||||
|
||||
systemd gateway 已配置 `LimitNOFILE=1048576`,需要持续保持。
|
||||
```
|
||||
```
|
||||
|
||||
- [x] **Step 5: Run tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./cmd/gateway ./internal/gateway
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add go/vehicle-gateway/cmd/gateway/main.go go/vehicle-gateway/cmd/gateway/main_test.go docs/ops/100k-capacity-baseline.md docs/ops/vehicle-ingest-runbook.md
|
||||
git commit -m "chore(go): raise gateway connection target"
|
||||
```
|
||||
|
||||
## Task 4: Gateway Runtime Metrics for 100K Operations
|
||||
|
||||
**Files:**
|
||||
- Modify: `go/vehicle-gateway/internal/gateway/tcp_server.go`
|
||||
- Modify: `go/vehicle-gateway/internal/metrics/metrics.go`
|
||||
- Test: `go/vehicle-gateway/internal/gateway/tcp_server_test.go`
|
||||
- Test: `go/vehicle-gateway/internal/metrics/metrics_test.go`
|
||||
|
||||
- [x] **Step 1: Add metrics test for rejected connections**
|
||||
|
||||
Added `TestTCPServerRecordsConnectionRejectionMetric` in `go/vehicle-gateway/internal/gateway/tcp_server_test.go`.
|
||||
|
||||
- [x] **Step 2: Run RED or confirm helper coverage**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/gateway -run TestTCPServerRecordsConnectionRejectionMetric
|
||||
```
|
||||
|
||||
Expected: FAIL before `recordConnectionRejection` exists.
|
||||
|
||||
- [x] **Step 3: Record max connection rejections**
|
||||
|
||||
In `go/vehicle-gateway/internal/gateway/tcp_server.go`, when the semaphore default case rejects a connection, add:
|
||||
|
||||
```go
|
||||
server.recordConnectionRejection("max_connections")
|
||||
```
|
||||
|
||||
Implemented `recordConnectionRejection`.
|
||||
|
||||
- [x] **Step 4: Run gateway tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./internal/gateway ./internal/metrics
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add go/vehicle-gateway/internal/gateway/tcp_server.go go/vehicle-gateway/internal/gateway/tcp_server_test.go go/vehicle-gateway/internal/metrics/metrics.go go/vehicle-gateway/internal/metrics/metrics_test.go
|
||||
git commit -m "feat(go): expose gateway connection rejection metrics"
|
||||
```
|
||||
|
||||
## Task 5: Redis-First Realtime Projector Safety
|
||||
|
||||
**Files:**
|
||||
- Modify: `go/vehicle-gateway/cmd/realtime-api/main.go`
|
||||
- Test: `go/vehicle-gateway/cmd/realtime-api/main_test.go`
|
||||
|
||||
- [x] **Step 1: Add test proving MySQL queue drops do not fail Redis update**
|
||||
|
||||
Add a test around `asyncSecondaryRealtimeUpdater` in `go/vehicle-gateway/cmd/realtime-api/main_test.go` using existing test patterns. The test should:
|
||||
|
||||
1. Create an updater with queue size `1`.
|
||||
2. Fill the queue.
|
||||
3. Call `Update` with a new envelope.
|
||||
4. Assert `Update` returns `nil`.
|
||||
5. Assert metric `vehicle_realtime_async_queue_total{status="dropped"}` increments.
|
||||
|
||||
- [x] **Step 2: Run test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./cmd/realtime-api -run TestAsyncSecondaryQueueDropDoesNotFailPrimaryUpdate
|
||||
```
|
||||
|
||||
Actual: PASS. The behavior already existed; this test locks it as regression coverage.
|
||||
|
||||
- [x] **Step 3: Ensure queue drop is observable and non-fatal**
|
||||
|
||||
In `go/vehicle-gateway/cmd/realtime-api/main.go`, preserve current Redis-first behavior:
|
||||
|
||||
- Redis update remains synchronous and primary.
|
||||
- MySQL update remains async secondary.
|
||||
- Full secondary queue increments dropped metric and does not return error to Kafka processing.
|
||||
|
||||
- [x] **Step 4: Run tests**
|
||||
|
||||
```bash
|
||||
cd go/vehicle-gateway
|
||||
go test ./cmd/realtime-api ./internal/realtime
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add go/vehicle-gateway/cmd/realtime-api/main.go go/vehicle-gateway/cmd/realtime-api/main_test.go
|
||||
git commit -m "test(go): lock redis-first realtime projector behavior"
|
||||
```
|
||||
|
||||
## Task 6: TDengine History Writer Batching Design
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/architecture/tdengine-batch-writer-design.md`
|
||||
- Later Modify: `go/vehicle-gateway/cmd/history-writer/main.go`
|
||||
- Later Modify: `go/vehicle-gateway/internal/history/writer.go`
|
||||
|
||||
- [x] **Step 1: Document the batch writer decision**
|
||||
|
||||
Created `docs/architecture/tdengine-batch-writer-design.md`.
|
||||
|
||||
- [ ] **Step 2: Commit design before code**
|
||||
|
||||
```bash
|
||||
git add docs/architecture/tdengine-batch-writer-design.md
|
||||
git commit -m "docs: design tdengine batch writer"
|
||||
```
|
||||
|
||||
## Task 7: Production Sysctl and Service Guardrail Rollout
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/ops/vehicle-ingest-runbook.md`
|
||||
- Modify: `docs/ops/go-vehicle-ingest-memory.md`
|
||||
|
||||
- [x] **Step 1: Apply sysctl changes on ECS**
|
||||
|
||||
Run on ECS:
|
||||
|
||||
```bash
|
||||
cat >/etc/sysctl.d/zz-lingniu-vehicle-ingest.conf <<'EOF'
|
||||
net.core.somaxconn = 65535
|
||||
net.ipv4.tcp_max_syn_backlog = 65535
|
||||
net.ipv4.ip_local_port_range = 10000 65000
|
||||
net.ipv4.tcp_tw_reuse = 1
|
||||
EOF
|
||||
sysctl --system
|
||||
```
|
||||
|
||||
- [x] **Step 2: Verify sysctl**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog net.ipv4.ip_local_port_range net.ipv4.tcp_tw_reuse
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
net.core.somaxconn = 65535
|
||||
net.ipv4.tcp_max_syn_backlog = 65535
|
||||
net.ipv4.ip_local_port_range = 10000 65000
|
||||
net.ipv4.tcp_tw_reuse = 1
|
||||
```
|
||||
|
||||
- [x] **Step 3: Restart gateway**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
systemctl restart lingniu-go-gateway.service
|
||||
curl -fsS http://127.0.0.1:20211/readyz
|
||||
ss -ltnp | grep -E ':(808|32960|20211) '
|
||||
```
|
||||
|
||||
- [x] **Step 4: Document rollout**
|
||||
|
||||
Add the applied sysctl values and timestamp to `docs/ops/go-vehicle-ingest-memory.md`.
|
||||
|
||||
Actual release: `/opt/lingniu-go-native/releases/100k-hardening-20260703175730`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/ops/go-vehicle-ingest-memory.md docs/ops/vehicle-ingest-runbook.md
|
||||
git commit -m "ops: document 100k gateway sysctl rollout"
|
||||
```
|
||||
|
||||
## Task 8: First 10K Connection Test
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/ops/100k-capacity-baseline.md`
|
||||
- Modify: `go/vehicle-gateway/cmd/load-sim/main.go`
|
||||
- Modify: `go/vehicle-gateway/internal/loadsim/*`
|
||||
|
||||
- [x] **Step 1: Implement JT808 connection hold mode**
|
||||
|
||||
Extended load-sim with `-send=false` to open N TCP connections and keep them open for `duration`, without sending frames.
|
||||
|
||||
Expected CLI:
|
||||
|
||||
```bash
|
||||
go run ./cmd/load-sim -protocol jt808 -addr 115.29.187.205:808 -connections 10000 -send-interval 30s -duration 5m -send=false
|
||||
```
|
||||
|
||||
- [x] **Step 2: Run from non-production client**
|
||||
|
||||
Run a small smoke first:
|
||||
|
||||
```bash
|
||||
go run ./cmd/load-sim -protocol jt808 -addr 115.29.187.205:808 -connections 100 -duration 30s -send=false
|
||||
```
|
||||
|
||||
Actual: ran on ECS loopback `127.0.0.1:808` to avoid external network limits and avoid business frame pollution.
|
||||
|
||||
- [x] **Step 3: Capture metrics during test**
|
||||
|
||||
On ECS:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:20211/metrics | grep vehicle_gateway_active_connections
|
||||
uptime
|
||||
free -m
|
||||
ss -tn sport = :808 | wc -l
|
||||
journalctl -u lingniu-go-gateway.service --since '10 minutes ago' --no-pager | grep -Ei 'error|failed|panic|fatal' || true
|
||||
```
|
||||
|
||||
- [x] **Step 4: Update capacity baseline**
|
||||
|
||||
Record:
|
||||
|
||||
- max active connections reached
|
||||
- CPU/load
|
||||
- memory
|
||||
- connection rejection count
|
||||
- errors
|
||||
|
||||
Actual: recorded 100 / 1,000 / 10,000 / 50,000 / 100,000 total hold-only results in `docs/ops/100k-capacity-baseline.md`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/ops/100k-capacity-baseline.md go/vehicle-gateway/cmd/load-sim go/vehicle-gateway/internal/loadsim
|
||||
git commit -m "test(go): record 10k gateway connection baseline"
|
||||
```
|
||||
|
||||
## Self-Review
|
||||
|
||||
Spec coverage:
|
||||
|
||||
- 10W production objective: covered by capacity assumptions and phased tasks.
|
||||
- Gateway connection limit and OS tuning: Tasks 3, 4, 7, 8.
|
||||
- NATS/Kafka stability: baseline metrics and future batching notes; bridge-specific tuning remains a follow-up after first load test.
|
||||
- Redis/MySQL projector stability: Task 5.
|
||||
- TDengine write bottleneck: Task 6 sets design; implementation should follow once load evidence shows the threshold.
|
||||
- ECS operations memory: Tasks 1 and 7.
|
||||
|
||||
Known intentional gaps:
|
||||
|
||||
- Full 100K test is not first. The first safe milestone is 10K connection test, then 50K, then 100K.
|
||||
- Multi-ECS horizontal sharding is not implemented in this plan. It should follow after single-node evidence.
|
||||
- Kafka partition changes are not included yet; changing partitions without measured FPS and consumer bottlenecks would be premature.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,481 +0,0 @@
|
||||
> **Superseded:** This 2026-06-23 DuckDB-based design is historical context.
|
||||
> Use `docs/target-architecture.md` and
|
||||
> `docs/superpowers/specs/2026-06-29-vehicle-ingest-redesign.md` for the
|
||||
> current production architecture: TDengine is the default hot history store,
|
||||
> `event-file-store` and Xinda Push have been removed from the production codebase.
|
||||
|
||||
# GB32960 Production Readiness Design
|
||||
|
||||
## Goal
|
||||
|
||||
Make the GB32960 ingestion, history, and analytics services production-ready:
|
||||
|
||||
- receive and parse GB32960 traffic quickly and accurately;
|
||||
- maximize VIN-to-platform/vendor-profile association through platform login and local mappings;
|
||||
- store complete RAW frames and queryable telemetry history with DuckDB;
|
||||
- support full frame replay from persisted RAW bytes;
|
||||
- compute real-time daily vehicle metrics and alarm timelines into MySQL;
|
||||
- reduce service coupling and improve cohesion inside each module.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not put database credentials in code, YAML files, tests, docs, or commits.
|
||||
- Do not make MySQL the source for full telemetry history. MySQL is for derived daily metrics and alarm timelines.
|
||||
- Do not require every private GB32960 extension to be perfect before storing data. RAW bytes must always be kept when a valid frame is received.
|
||||
|
||||
## Production Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
tcp["GB32960 TCP:32960"] --> ingest["gb32960-ingest-app"]
|
||||
mapping["local vin-platform-profile.jsonl"] --> ingest
|
||||
ingest --> kafkaEvent["Kafka vehicle.event.gb32960.v1"]
|
||||
ingest --> kafkaRaw["Kafka vehicle.raw.gb32960.v1"]
|
||||
kafkaEvent --> history["vehicle-history-app"]
|
||||
kafkaRaw --> history
|
||||
history --> duckdb["DuckDB hot history"]
|
||||
history --> raw["RAW frame archive"]
|
||||
kafkaEvent --> analytics["vehicle-analytics-app"]
|
||||
analytics --> mysql["MySQL daily stats + alarms"]
|
||||
```
|
||||
|
||||
## Service Boundaries
|
||||
|
||||
### 1. `gb32960-ingest-app`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Netty TCP accept, frame splitting, GB32960 decode, auth, ACK, and Kafka production.
|
||||
- Platform login handling and connection-local `platformAccount`.
|
||||
- VIN-to-platform/vendor-profile resolution.
|
||||
- Produce structured telemetry, session, alarm, diagnostics, and RAW envelope messages.
|
||||
|
||||
It must not:
|
||||
|
||||
- write DuckDB, MySQL, or local archive files;
|
||||
- run historical queries;
|
||||
- run daily statistics.
|
||||
|
||||
ACK policy:
|
||||
|
||||
- login/report-like GB32960 commands use durable ACK: ACK only after Kafka dispatch succeeds;
|
||||
- malformed bytes that cannot form a valid frame are dropped with diagnostics;
|
||||
- valid frames with partial private-block parse failures still go to Kafka with `parseStatus=PARTIAL`.
|
||||
|
||||
VIN/profile resolution priority:
|
||||
|
||||
1. active platform login on the current TCP channel;
|
||||
2. exact VIN entry from local mapping file;
|
||||
3. VIN prefix entry from local mapping file;
|
||||
4. configured platform extension rule;
|
||||
5. standard GB32960 profile.
|
||||
|
||||
Local mapping file format:
|
||||
|
||||
```jsonl
|
||||
{"vin":"LNXNEGRR2SR321390","platformAccount":"Hyundai","vendorProfile":"guangdong-fc","enabled":true}
|
||||
{"vinPrefix":"LNXNEGRR","platformAccount":"Hyundai","vendorProfile":"guangdong-fc","enabled":true}
|
||||
```
|
||||
|
||||
The mapping file is loaded at startup and can be reloaded by an actuator endpoint or file watcher in a later phase.
|
||||
|
||||
### 2. `vehicle-history-app`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Consume Kafka event and RAW topics.
|
||||
- Persist RAW bytes to an append-only archive.
|
||||
- Persist query indexes and compact telemetry points to DuckDB.
|
||||
- Serve high-performance query APIs:
|
||||
- full frame list by `vin + eventTime`;
|
||||
- telemetry fields by `vin + eventTime`;
|
||||
- one-frame replay by `rawArchiveUri`;
|
||||
- parse diagnostics and missing-profile troubleshooting.
|
||||
|
||||
It must not:
|
||||
|
||||
- listen on GB32960 TCP;
|
||||
- run MySQL statistics;
|
||||
- mutate daily business metrics.
|
||||
|
||||
### 3. `vehicle-analytics-app`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Consume Kafka event topic.
|
||||
- Maintain real-time daily stats in MySQL.
|
||||
- Maintain full alarm timelines in MySQL.
|
||||
- Expose metrics query APIs and operational health.
|
||||
|
||||
It must not:
|
||||
|
||||
- parse RAW frames for normal operation;
|
||||
- write DuckDB history;
|
||||
- own GB32960 TCP connectivity.
|
||||
|
||||
## Kafka Contract
|
||||
|
||||
Keep Protobuf `VehicleEnvelope`, but make the producer contract stricter:
|
||||
|
||||
- `eventId`: stable idempotency key, deterministic from protocol, VIN, command, event time, raw checksum, and sequence when available.
|
||||
- `vin`: required for vehicle frames; platform-only commands use `_platform` or empty plus `platformAccount`.
|
||||
- `eventTimeMs`: GB32960 data collection time when available.
|
||||
- `ingestTimeMs`: service receive time.
|
||||
- `metadata.platformAccount`: resolved platform account.
|
||||
- `metadata.vendorProfile`: selected parser profile.
|
||||
- `metadata.parseStatus`: `OK`, `PARTIAL`, or `FAILED`.
|
||||
- `metadata.parseErrorCode`: stable code for failures.
|
||||
- `raw_archive.data`: present on RAW topic only; event topic keeps `rawArchiveUri` pointer.
|
||||
- `telemetry_snapshot`: standard field projection for downstream services.
|
||||
|
||||
Consumer rule:
|
||||
|
||||
- History consumes both event and RAW topics.
|
||||
- Analytics consumes only event topic.
|
||||
- Consumer offset is committed only after the target store commit succeeds.
|
||||
- Reprocessing must be safe through deterministic ids and database upsert/ignore semantics.
|
||||
|
||||
## DuckDB History Design
|
||||
|
||||
Use DuckDB as the hot historical database, not a Parquet-rewrite sidecar.
|
||||
|
||||
DuckDB official guidance says row-by-row insert loops are inefficient for bulk loading, and Appender/batched writes are the intended high-throughput path. Therefore history writes use a single writer worker with bounded batches and one DuckDB connection.
|
||||
|
||||
### Tables
|
||||
|
||||
`gb32960_frame_index`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS gb32960_frame_index (
|
||||
event_id VARCHAR PRIMARY KEY,
|
||||
vin VARCHAR NOT NULL,
|
||||
platform_account VARCHAR,
|
||||
vendor_profile VARCHAR,
|
||||
command VARCHAR NOT NULL,
|
||||
command_code INTEGER NOT NULL,
|
||||
event_time TIMESTAMP NOT NULL,
|
||||
event_time_ms BIGINT NOT NULL,
|
||||
ingest_time TIMESTAMP NOT NULL,
|
||||
ingest_time_ms BIGINT NOT NULL,
|
||||
partition_date DATE NOT NULL,
|
||||
raw_archive_uri VARCHAR NOT NULL,
|
||||
raw_checksum VARCHAR,
|
||||
raw_size_bytes BIGINT NOT NULL,
|
||||
parse_status VARCHAR NOT NULL,
|
||||
parse_error_code VARCHAR,
|
||||
block_count INTEGER NOT NULL,
|
||||
metadata_json VARCHAR NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS gb32960_frame_vin_time_idx
|
||||
ON gb32960_frame_index(vin, event_time_ms);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS gb32960_frame_raw_uri_idx
|
||||
ON gb32960_frame_index(raw_archive_uri);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS gb32960_frame_partition_time_idx
|
||||
ON gb32960_frame_index(partition_date, event_time_ms);
|
||||
```
|
||||
|
||||
`gb32960_telemetry_point`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS gb32960_telemetry_point (
|
||||
event_id VARCHAR NOT NULL,
|
||||
vin VARCHAR NOT NULL,
|
||||
event_time TIMESTAMP NOT NULL,
|
||||
event_time_ms BIGINT NOT NULL,
|
||||
ingest_time TIMESTAMP NOT NULL,
|
||||
platform_account VARCHAR,
|
||||
vendor_profile VARCHAR,
|
||||
field_key VARCHAR NOT NULL,
|
||||
value_type VARCHAR NOT NULL,
|
||||
value_text VARCHAR NOT NULL,
|
||||
value_num DOUBLE,
|
||||
unit VARCHAR,
|
||||
quality VARCHAR NOT NULL,
|
||||
raw_archive_uri VARCHAR NOT NULL,
|
||||
PRIMARY KEY(event_id, field_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS gb32960_point_vin_field_time_idx
|
||||
ON gb32960_telemetry_point(vin, field_key, event_time_ms);
|
||||
```
|
||||
|
||||
`gb32960_alarm_frame`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS gb32960_alarm_frame (
|
||||
event_id VARCHAR PRIMARY KEY,
|
||||
vin VARCHAR NOT NULL,
|
||||
event_time TIMESTAMP NOT NULL,
|
||||
event_time_ms BIGINT NOT NULL,
|
||||
level VARCHAR NOT NULL,
|
||||
active_bits_json VARCHAR NOT NULL,
|
||||
fault_codes_json VARCHAR NOT NULL,
|
||||
raw_archive_uri VARCHAR NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS gb32960_alarm_vin_time_idx
|
||||
ON gb32960_alarm_frame(vin, event_time_ms);
|
||||
```
|
||||
|
||||
### RAW Archive
|
||||
|
||||
RAW archive remains file/object based:
|
||||
|
||||
```text
|
||||
archive://yyyy/MM/dd/GB32960/{vin}/{event_id}.bin
|
||||
```
|
||||
|
||||
The archive writer verifies:
|
||||
|
||||
- stored byte length equals envelope `raw_archive.size_bytes`;
|
||||
- checksum matches;
|
||||
- URI is unique for the deterministic event id.
|
||||
|
||||
Frame replay path:
|
||||
|
||||
1. query `gb32960_frame_index` by `rawArchiveUri`;
|
||||
2. read RAW bytes from archive;
|
||||
3. resolve `platformAccount/vendorProfile` from index metadata;
|
||||
4. decode using current parser;
|
||||
5. return decoded frame plus original indexed parse diagnostics.
|
||||
|
||||
## Query APIs
|
||||
|
||||
Keep existing endpoints but back them by the new DuckDB tables:
|
||||
|
||||
- `GET /api/event-history/gb32960/frames`
|
||||
- full frame query by VIN/time.
|
||||
- `GET /api/event-history/gb32960/frame`
|
||||
- one RAW frame replay by `rawArchiveUri`.
|
||||
- `GET /api/event-history/gb32960/telemetry-snapshots`
|
||||
- compact telemetry snapshot by VIN/time.
|
||||
- `GET /api/event-history/gb32960/telemetry-fields`
|
||||
- field projection optimized by `gb32960_telemetry_point`.
|
||||
- `GET /api/event-history/gb32960/diagnostics`
|
||||
- parse status and missing-profile investigation by VIN/time.
|
||||
|
||||
Time semantics:
|
||||
|
||||
- `eventTime` is business query time.
|
||||
- `ingestTime` is kept for operational replay, late-arrival detection, and Kafka lag investigation.
|
||||
- Dates without timezone are interpreted in `Asia/Shanghai`.
|
||||
|
||||
## MySQL Analytics Design
|
||||
|
||||
Credentials are supplied only by environment variables:
|
||||
|
||||
```text
|
||||
MYSQL_HOST
|
||||
MYSQL_PORT
|
||||
MYSQL_DATABASE
|
||||
MYSQL_USERNAME
|
||||
MYSQL_PASSWORD
|
||||
```
|
||||
|
||||
Daily derived metrics are stored in the common metric table, not in a
|
||||
protocol-specific daily-mileage table.
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS vehicle_stat_metric (
|
||||
vin VARCHAR(64) NOT NULL,
|
||||
stat_date DATE NOT NULL,
|
||||
metric_key VARCHAR(64) NOT NULL,
|
||||
metric_value DECIMAL(18,6) NULL,
|
||||
metric_unit VARCHAR(16) NOT NULL,
|
||||
calculation_method VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (vin, stat_date, metric_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
`vehicle_alarm_timeline`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS vehicle_alarm_timeline (
|
||||
alarm_id VARCHAR(128) NOT NULL,
|
||||
vin VARCHAR(32) NOT NULL,
|
||||
alarm_key VARCHAR(128) NOT NULL,
|
||||
level VARCHAR(32) NOT NULL,
|
||||
start_time DATETIME(3) NOT NULL,
|
||||
end_time DATETIME(3),
|
||||
last_seen_time DATETIME(3) NOT NULL,
|
||||
start_raw_archive_uri VARCHAR(512),
|
||||
last_raw_archive_uri VARCHAR(512),
|
||||
status VARCHAR(16) NOT NULL,
|
||||
details_json JSON,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (alarm_id),
|
||||
KEY vehicle_alarm_vin_time_idx (vin, start_time),
|
||||
KEY vehicle_alarm_open_idx (vin, status, alarm_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
`vehicle_stat_event_dedup`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS vehicle_stat_event_dedup (
|
||||
event_id VARCHAR(128) NOT NULL,
|
||||
consumer_group VARCHAR(128) NOT NULL,
|
||||
processed_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (event_id, consumer_group)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
Writes use `INSERT ... ON DUPLICATE KEY UPDATE` so replayed Kafka messages update the same daily row instead of creating duplicates.
|
||||
|
||||
## Daily Metric Algorithms
|
||||
|
||||
All daily statistics are keyed by `eventTime` in `Asia/Shanghai`.
|
||||
|
||||
### Total Mileage
|
||||
|
||||
Use the latest valid `totalMileageKm` seen for the day.
|
||||
|
||||
Validation:
|
||||
|
||||
- reject negative values;
|
||||
- reject impossible jumps based on configurable max speed and elapsed time;
|
||||
- keep the sample as diagnostic if rejected.
|
||||
|
||||
### Daily Mileage
|
||||
|
||||
JT808 daily mileage uses the GPS total mileage reported in location additional
|
||||
information. The local-day minimum and maximum GPS total mileage values rewrite
|
||||
the same metric row, so ordered streaming and historical replay use one
|
||||
calculation path.
|
||||
|
||||
```text
|
||||
dailyMileage = today.maxTotalMileage - today.minTotalMileage
|
||||
calculationMethod = JT808_TOTAL_MILEAGE_DIFF
|
||||
```
|
||||
|
||||
The result is stored in `vehicle_stat_metric` with
|
||||
`metric_key=daily_mileage_km`. There is no JT808-specific daily mileage table,
|
||||
no Redis mileage state, and no previous-day baseline calculation.
|
||||
|
||||
### Daily Power Consumption
|
||||
|
||||
Preferred:
|
||||
|
||||
- vendor cumulative power-consumption field if available.
|
||||
|
||||
Fallback:
|
||||
|
||||
```text
|
||||
deltaKwh = sum(max(0, totalVoltageV * totalCurrentA) * deltaSeconds / 3600000)
|
||||
```
|
||||
|
||||
The sign convention is configurable per vendor profile because GB32960 vehicle current may be positive or negative depending on charging/discharging representation.
|
||||
|
||||
### Daily Hydrogen Consumption
|
||||
|
||||
Use a tiered market-practical algorithm instead of a single fixed formula:
|
||||
|
||||
1. `DIRECT_COUNTER`: vendor cumulative hydrogen consumption or remaining hydrogen mass delta.
|
||||
2. `PVT_MASS_DELTA`: pressure/temperature/tank-volume mass estimate when storage data is available.
|
||||
3. `FUEL_CELL_ENERGY_MODEL`: integrate fuel-cell electric output and divide by calibrated efficiency.
|
||||
4. `UNKNOWN`: keep null when required signals are missing.
|
||||
|
||||
The PVT approach follows the industry pattern behind compressed hydrogen consumption tests, where pressure, temperature, and tank volume can be used to estimate hydrogen mass. The implementation must expose calibration constants per vehicle model/profile:
|
||||
|
||||
```json
|
||||
{
|
||||
"vinPrefix": "LNXNEGRR",
|
||||
"tankVolumeLiter": 140,
|
||||
"hydrogenModel": "PVT_MASS_DELTA",
|
||||
"fuelCellEfficiency": 0.52,
|
||||
"currentDischargeSign": "POSITIVE"
|
||||
}
|
||||
```
|
||||
|
||||
### Daily Hydrogen Consumption Rate
|
||||
|
||||
```text
|
||||
dailyHydrogenKgPer100km = dailyHydrogenKg / dailyMileageKm * 100
|
||||
```
|
||||
|
||||
If mileage is missing or less than the configured minimum distance, keep the rate null and set quality to `INSUFFICIENT_DISTANCE`.
|
||||
|
||||
### Alarm Timeline
|
||||
|
||||
For each telemetry frame:
|
||||
|
||||
- derive active alarm keys from alarm bits and fault code arrays;
|
||||
- open a timeline row when a key first appears;
|
||||
- update `last_seen_time` while it remains active;
|
||||
- close open alarms when the key disappears after a configurable grace window;
|
||||
- keep start/end raw archive URIs for replay.
|
||||
|
||||
## Reliability and Observability
|
||||
|
||||
Required metrics:
|
||||
|
||||
- TCP active connections;
|
||||
- frames received per command;
|
||||
- decode failures by code;
|
||||
- missing platform login rejections;
|
||||
- vendor profile resolution source;
|
||||
- Kafka send latency and failures;
|
||||
- history write batch size, latency, failure count;
|
||||
- DuckDB query latency by endpoint;
|
||||
- MySQL upsert latency and failure count;
|
||||
- consumer lag by group/topic/partition;
|
||||
- late sample count by VIN/date.
|
||||
|
||||
Required logs:
|
||||
|
||||
- one structured line for every dropped or rejected valid frame;
|
||||
- no per-frame INFO spam for successful reports after first frame per VIN/profile unless debug is enabled;
|
||||
- include `vin`, `platformAccount`, `command`, `eventId`, `reasonCode`, and `peer`.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Phase 1: History foundation
|
||||
|
||||
- Replace Parquet rewrite store with DuckDB hot tables and batch writer.
|
||||
- Keep RAW bytes archive.
|
||||
- Move existing history endpoints onto the new store.
|
||||
- Add diagnostic endpoint for missing VIN/profile cases.
|
||||
|
||||
Phase 2: Ingest association and diagnostics
|
||||
|
||||
- Add local `vin-platform-profile.jsonl` loader.
|
||||
- Use resolved profile before private block parsing.
|
||||
- Add structured parse/reject diagnostics to Kafka metadata and logs.
|
||||
- Add tests for platform login, mapping fallback, and partial private parsing.
|
||||
|
||||
Phase 3: MySQL analytics
|
||||
|
||||
- Add MySQL repository and migrations.
|
||||
- Implement daily stat accumulator with idempotent upsert.
|
||||
- Implement alarm timeline open/update/close logic.
|
||||
- Add integration tests using Testcontainers or a local MySQL profile.
|
||||
|
||||
Phase 4: Production hardening
|
||||
|
||||
- Add backfill/replay commands from DuckDB RAW archive to analytics.
|
||||
- Add retention/export from DuckDB to Parquet for cold storage.
|
||||
- Add dashboards and operational runbook.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- A valid GB32960 frame received on port 32960 appears in Kafka with RAW bytes or RAW URI.
|
||||
- A valid frame is queryable by `vin + eventTime` from history.
|
||||
- A returned frame can be replayed from `rawArchiveUri` and decoded with the stored profile.
|
||||
- A VIN with only local mapping, and no current platform login, can still use the correct private parser profile.
|
||||
- A private block parse failure does not drop the frame.
|
||||
- Daily MySQL rows update in real time for mileage, total mileage, power consumption, hydrogen consumption, hydrogen rate, and sample metadata.
|
||||
- Alarm timeline rows preserve full alarm start, update, and close history.
|
||||
- Replaying the same Kafka event does not duplicate daily stats or alarm rows.
|
||||
- No secret appears in committed files.
|
||||
|
||||
## References
|
||||
|
||||
- DuckDB Appender: https://duckdb.org/docs/current/data/appender
|
||||
- DuckDB INSERT performance guidance: https://duckdb.org/docs/current/data/insert
|
||||
- MySQL `INSERT ... ON DUPLICATE KEY UPDATE`: https://dev.mysql.com/doc/refman/9.7/en/insert-on-duplicate.html
|
||||
- SAE J2572 overview: https://h2tools.org/fuel-cell-codes-and-standards/sae-j2572-measuring-exhaust-emissions-energy-consumption-and-range
|
||||
@@ -1,411 +0,0 @@
|
||||
> **Superseded:** This 2026-06-23 split design is historical context.
|
||||
> Use `docs/target-architecture.md` and
|
||||
> `docs/superpowers/specs/2026-06-29-vehicle-ingest-redesign.md` for the
|
||||
> current production architecture: TDengine is the default hot history store,
|
||||
> `sink-archive` owns raw bytes, and `event-file-store` is optional
|
||||
> compatibility only.
|
||||
|
||||
# GB32960 Service Split Design
|
||||
|
||||
Date: 2026-06-23
|
||||
|
||||
## Goal
|
||||
|
||||
Split the current all-in-one vehicle ingest runtime into lower-coupled, higher-cohesion services. The first production slice focuses on GB/T 32960 because it is the active high-pressure path: persistent TCP connections, protocol ACKs, raw frame volume, history queries, and downstream analytics are currently assembled in one `bootstrap-all` process.
|
||||
|
||||
The target outcome is:
|
||||
|
||||
- Keep GB32960 ingest reliable and fast.
|
||||
- Move disk writes and history queries out of the protocol process.
|
||||
- Move statistics and analysis out of the protocol process.
|
||||
- Use Kafka as the durable boundary between ingest, history, and analytics.
|
||||
- Preserve `bootstrap-all` during migration as a local development and rollback entry point.
|
||||
|
||||
## Decision
|
||||
|
||||
Use ACK semantics A:
|
||||
|
||||
> A GB32960 frame is ACKed successfully after the protocol service parses/accepts it and writes the required Kafka message(s) successfully.
|
||||
|
||||
The protocol service must not wait for raw archive, DuckDB/Parquet writes, snapshot generation, or statistics computation before sending the GB32960 response frame.
|
||||
|
||||
## Service Boundaries
|
||||
|
||||
### `gb32960-ingest-app`
|
||||
|
||||
Purpose: own GB32960 connection handling and Kafka production.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Listen on the GB32960 TCP port.
|
||||
- Handle platform login, VIN authorization, TLS if enabled, and session state required for protocol responses.
|
||||
- Decode GB32960 frames and apply vendor extension parsing.
|
||||
- Produce raw frame records and normalized event records to Kafka.
|
||||
- ACK successful reports only after Kafka production succeeds.
|
||||
- Publish malformed or rejected frames to a DLQ when possible.
|
||||
- Expose operational health and metrics for connections, decode rate, Kafka latency, ACK latency, and DLQ count.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No local raw archive writes.
|
||||
- No DuckDB or Parquet history writes.
|
||||
- No snapshot query API.
|
||||
- No vehicle statistics or analysis.
|
||||
- No long-running analytical calculations.
|
||||
|
||||
Initial module dependencies:
|
||||
|
||||
- `ingest-api`
|
||||
- `ingest-core`
|
||||
- `ingest-codec-common`
|
||||
- `session-core`
|
||||
- `protocol-gb32960`
|
||||
- `sink-kafka`
|
||||
- `observability`
|
||||
|
||||
### `vehicle-history-app`
|
||||
|
||||
Purpose: own raw storage, historical index storage, and history query APIs.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Consume GB32960 raw records and normalized event records from Kafka.
|
||||
- Write raw `.bin` payloads through `sink-archive`.
|
||||
- Write queryable event indexes through `event-file-store`.
|
||||
- Provide APIs for raw frame lookup, decoded frame lookup, history query, and snapshot/source-frame lookup.
|
||||
- Rebuild or backfill the DuckDB sidecar index from stored Parquet files when needed.
|
||||
- Track consumer lag, write latency, archive failures, index failures, and query latency.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No TCP protocol listener.
|
||||
- No GB32960 ACK decisions.
|
||||
- No online statistics computation.
|
||||
- No direct coupling to `gb32960-ingest-app` internals.
|
||||
|
||||
Initial module dependencies:
|
||||
|
||||
- `ingest-api`
|
||||
- `sink-kafka`
|
||||
- `sink-archive`
|
||||
- `event-file-store`
|
||||
- `event-history-service`
|
||||
- `protocol-gb32960` only for raw-frame decode/query support
|
||||
- `observability`
|
||||
|
||||
### `vehicle-analytics-app`
|
||||
|
||||
Purpose: own vehicle state, daily statistics, alarms, and later analytics.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Consume normalized vehicle event records from Kafka.
|
||||
- Maintain latest vehicle state if enabled.
|
||||
- Compute daily vehicle statistics and alarm-derived metrics.
|
||||
- Persist analytical outputs to the selected backend.
|
||||
- Support reprocessing from Kafka offsets when analytical logic changes.
|
||||
- Track consumer lag, per-VIN processing latency, aggregation failures, and output write latency.
|
||||
|
||||
Non-responsibilities:
|
||||
|
||||
- No protocol listener.
|
||||
- No GB32960 ACK decisions.
|
||||
- No raw archive writes.
|
||||
- No normal-path dependency on raw archive or history APIs.
|
||||
|
||||
Initial module dependencies:
|
||||
|
||||
- `ingest-api`
|
||||
- `sink-kafka`
|
||||
- `vehicle-state-service`
|
||||
- `vehicle-stat-service`
|
||||
- `observability`
|
||||
|
||||
## Kafka Contract
|
||||
|
||||
Kafka is the service boundary. Messages must be stable enough that `vehicle-history-app` and `vehicle-analytics-app` do not depend on protocol service internals.
|
||||
|
||||
### Topics
|
||||
|
||||
Use versioned topics:
|
||||
|
||||
- `vehicle.raw.gb32960.v1`
|
||||
- `vehicle.event.gb32960.v1`
|
||||
- `vehicle.dlq.gb32960.v1`
|
||||
|
||||
The current topic names under `lingniu.ingest.sink.kafka.topics` can remain temporarily for compatibility, but the split apps should converge on versioned topic names.
|
||||
|
||||
### Keys
|
||||
|
||||
Use VIN as the Kafka key whenever a VIN is available.
|
||||
|
||||
Benefits:
|
||||
|
||||
- Preserves per-vehicle ordering within a partition.
|
||||
- Lets history and analytics scale by partition.
|
||||
- Keeps stateful analytics simpler.
|
||||
|
||||
When VIN is unavailable, use a stable fallback key such as platform account plus peer address plus receive time bucket.
|
||||
|
||||
### Raw Record
|
||||
|
||||
Topic: `vehicle.raw.gb32960.v1`
|
||||
|
||||
Minimum fields:
|
||||
|
||||
- `schemaVersion`
|
||||
- `protocol = GB32960`
|
||||
- `eventId`
|
||||
- `receiveTime`
|
||||
- `eventTime` if decoded
|
||||
- `peer`
|
||||
- `platformAccount`
|
||||
- `vin` if decoded
|
||||
- `command`
|
||||
- `protocolVersion`
|
||||
- `rawBytes`
|
||||
- `checksumStatus`
|
||||
- `decodeStatus`
|
||||
- `metadata`
|
||||
|
||||
Consumers:
|
||||
|
||||
- `vehicle-history-app` writes archive `.bin` files and raw-frame query indexes.
|
||||
- Future replay tools can regenerate normalized events from raw records.
|
||||
|
||||
### Normalized Event Record
|
||||
|
||||
Topic: `vehicle.event.gb32960.v1`
|
||||
|
||||
Minimum fields:
|
||||
|
||||
- `schemaVersion`
|
||||
- `protocol = GB32960`
|
||||
- `eventId`
|
||||
- `sourceRawEventId`
|
||||
- `receiveTime`
|
||||
- `eventTime`
|
||||
- `platformAccount`
|
||||
- `vin`
|
||||
- `command`
|
||||
- `eventType`
|
||||
- `normalizedFields`
|
||||
- `vendorFields`
|
||||
- `alarmFields`
|
||||
- `metadata`
|
||||
|
||||
Consumers:
|
||||
|
||||
- `vehicle-history-app` writes queryable history records.
|
||||
- `vehicle-analytics-app` updates state and statistics.
|
||||
|
||||
### DLQ Record
|
||||
|
||||
Topic: `vehicle.dlq.gb32960.v1`
|
||||
|
||||
Minimum fields:
|
||||
|
||||
- `schemaVersion`
|
||||
- `stage`
|
||||
- `errorCode`
|
||||
- `errorMessage`
|
||||
- `receiveTime`
|
||||
- `peer`
|
||||
- `platformAccount` if known
|
||||
- `vin` if known
|
||||
- `rawBytes` when available
|
||||
- `metadata`
|
||||
|
||||
Typical stages:
|
||||
|
||||
- `FRAME_DECODE`
|
||||
- `BODY_PARSE`
|
||||
- `AUTH`
|
||||
- `KAFKA_PRODUCE`
|
||||
- `ACK_WRITE`
|
||||
|
||||
## ACK and Failure Semantics
|
||||
|
||||
### Success Path
|
||||
|
||||
1. Receive TCP bytes.
|
||||
2. Decode a complete GB32960 frame.
|
||||
3. Validate checksum/auth/session rules.
|
||||
4. Build raw and normalized Kafka records.
|
||||
5. Produce required Kafka records successfully.
|
||||
6. Send GB32960 success ACK.
|
||||
|
||||
### Kafka Produce Failure
|
||||
|
||||
If required Kafka production fails, the service must not send a success ACK.
|
||||
|
||||
Allowed behavior:
|
||||
|
||||
- Retry within a bounded timeout.
|
||||
- Return a GB32960 failure response when the protocol allows it.
|
||||
- Close the channel after repeated produce failures.
|
||||
- Emit local error logs and metrics.
|
||||
|
||||
Do not silently ACK frames that have not crossed the Kafka durability boundary.
|
||||
|
||||
### History or Analytics Failure
|
||||
|
||||
Failures in `vehicle-history-app` or `vehicle-analytics-app` must not affect GB32960 ACKs. They are handled by Kafka offset retry, DLQ, operational alerting, and backfill/replay.
|
||||
|
||||
## Current Coupling to Remove
|
||||
|
||||
`bootstrap-all` currently assembles protocol modules, Kafka producer/consumer, archive sink, event file store, history API, vehicle state, vehicle statistics, command gateway, and multiple inbound protocols in one application.
|
||||
|
||||
The split should remove these production couplings:
|
||||
|
||||
- Protocol runtime directly containing archive and event-file-store writers.
|
||||
- Protocol runtime directly containing statistics processors.
|
||||
- History query API sharing the same process as TCP ingest.
|
||||
- Kafka consumer workers living in the same all-in-one app as protocol listeners.
|
||||
- Operational scaling tied to one JVM for ingest, storage, queries, and analytics.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Add Split App Entrypoints
|
||||
|
||||
Add new app modules:
|
||||
|
||||
- `modules/apps/gb32960-ingest-app`
|
||||
- `modules/apps/vehicle-history-app`
|
||||
- `modules/apps/vehicle-analytics-app`
|
||||
|
||||
Keep `modules/apps/bootstrap-all` unchanged as the fallback runtime.
|
||||
|
||||
### Phase 2: GB32960 Ingest App
|
||||
|
||||
Create a production profile for `gb32960-ingest-app`:
|
||||
|
||||
- Enable `protocol-gb32960`.
|
||||
- Enable Kafka producer.
|
||||
- Disable local archive.
|
||||
- Disable event-file-store.
|
||||
- Disable history API.
|
||||
- Disable vehicle state/stat services.
|
||||
- Disable unrelated protocols.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- GB32960 TCP port starts.
|
||||
- Platform login works.
|
||||
- Realtime report frames are parsed.
|
||||
- Kafka records are produced with VIN keys.
|
||||
- ACK is sent only after Kafka success.
|
||||
|
||||
### Phase 3: History App
|
||||
|
||||
Create `vehicle-history-app`:
|
||||
|
||||
- Enable Kafka consumer.
|
||||
- Enable archive sink.
|
||||
- Enable event-file-store.
|
||||
- Enable event-history HTTP API.
|
||||
- Disable protocol listeners.
|
||||
- Disable analytics processors.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Consumes `vehicle.raw.gb32960.v1`.
|
||||
- Writes raw `.bin` files.
|
||||
- Consumes `vehicle.event.gb32960.v1`.
|
||||
- Writes Parquet/DuckDB indexes.
|
||||
- Queries can locate raw frames and decoded snapshots.
|
||||
|
||||
### Phase 4: Analytics App
|
||||
|
||||
Create `vehicle-analytics-app`:
|
||||
|
||||
- Enable Kafka consumer.
|
||||
- Enable vehicle state if a state backend is configured.
|
||||
- Enable vehicle statistics.
|
||||
- Disable protocol listeners.
|
||||
- Disable archive and event-file-store.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Consumes `vehicle.event.gb32960.v1`.
|
||||
- Maintains expected per-VIN state/stat outputs.
|
||||
- Can be restarted and resume from Kafka offsets.
|
||||
- Does not affect ingest ACK latency.
|
||||
|
||||
### Phase 5: Parallel Run and Cutover
|
||||
|
||||
Run old and new paths in parallel for a bounded validation window.
|
||||
|
||||
Compare:
|
||||
|
||||
- Received frame count.
|
||||
- Kafka produced record count.
|
||||
- Raw archive file count.
|
||||
- Unique VIN count.
|
||||
- History query count and sample query correctness.
|
||||
- Analytics output count and sample values.
|
||||
- GB32960 ACK latency before and after split.
|
||||
|
||||
Cut over only after counts and sample queries match within the agreed tolerance.
|
||||
|
||||
## Operational Guidance
|
||||
|
||||
Scale services independently:
|
||||
|
||||
- Scale `gb32960-ingest-app` by connection count and Kafka produce latency.
|
||||
- Scale `vehicle-history-app` by disk throughput, consumer lag, and query latency.
|
||||
- Scale `vehicle-analytics-app` by consumer lag and computation latency.
|
||||
|
||||
Monitor:
|
||||
|
||||
- Kafka producer error rate and p99 produce latency.
|
||||
- ACK p99 latency.
|
||||
- TCP active connections.
|
||||
- Decode failures and DLQ counts.
|
||||
- Consumer lag per group.
|
||||
- Archive write latency and failure rate.
|
||||
- Event-file-store flush latency and failure rate.
|
||||
- Analytics processing latency.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
Risk: Kafka outage blocks GB32960 ACKs.
|
||||
|
||||
Mitigation: bounded producer retry, clear failure ACK/close behavior, Kafka cluster monitoring, and optional local emergency spool only as a later explicit design.
|
||||
|
||||
Risk: Two Kafka topics for raw and normalized events can diverge.
|
||||
|
||||
Mitigation: include `eventId` and `sourceRawEventId`, produce records transactionally if required, or publish a single envelope containing both raw and normalized sections in the first implementation if transaction support is not ready.
|
||||
|
||||
Risk: History app reprocessing duplicates archive/index records.
|
||||
|
||||
Mitigation: deterministic archive keys and idempotent index writes keyed by `eventId` or `rawArchiveUri`.
|
||||
|
||||
Risk: Analytics logic changes require backfill.
|
||||
|
||||
Mitigation: keep raw and normalized topics with sufficient retention; allow analytics consumers to reset offsets or run a backfill group.
|
||||
|
||||
Risk: Query APIs accidentally depend on ingest internals.
|
||||
|
||||
Mitigation: history APIs should depend on `event-file-store`, `sink-archive`, and decode libraries only, not `gb32960-ingest-app`.
|
||||
|
||||
## Open Decisions
|
||||
|
||||
These are intentionally left for implementation planning:
|
||||
|
||||
- Whether Kafka production uses two independent sends or a transaction for raw + normalized records.
|
||||
- Exact protobuf/JSON schema shape for `vehicle.raw.gb32960.v1` and `vehicle.event.gb32960.v1`.
|
||||
- Whether latest snapshot state belongs only to `vehicle-analytics-app` or is also materialized by `vehicle-history-app` for query convenience.
|
||||
- Initial Kafka partition count and retention duration.
|
||||
- Whether command downlink stays with `command-gateway` or gets a separate command service later.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
The split is complete when:
|
||||
|
||||
- `gb32960-ingest-app`, `vehicle-history-app`, and `vehicle-analytics-app` are separate runnable app modules.
|
||||
- `gb32960-ingest-app` can receive live GB32960 data and ACK after Kafka success.
|
||||
- `vehicle-history-app` can consume Kafka and provide raw/history/snapshot query APIs.
|
||||
- `vehicle-analytics-app` can consume Kafka and compute state/stat outputs.
|
||||
- `bootstrap-all` remains available for development and rollback.
|
||||
- Verification covers build, app startup, Kafka production/consumption, raw archive writes, history queries, analytics outputs, and ACK latency.
|
||||
@@ -1,621 +0,0 @@
|
||||
# Vehicle Ingest Redesign Design
|
||||
|
||||
## Background
|
||||
|
||||
Current GB32960 and JT808 ingestion can receive, decode, publish, and query data, but the responsibilities are split across several partially overlapping paths:
|
||||
|
||||
- Netty handlers produce `RawFrame` and the dispatcher emits both raw archive events and normalized vehicle events.
|
||||
- Kafka envelopes carry raw archive references, while raw bytes are stored separately by the archive sink.
|
||||
- GB32960 historical queries use raw archive records plus file replay decoding.
|
||||
- JT808 location history is materialized separately into TDengine.
|
||||
- DuckDB/Parquet, local raw archive, Kafka, and TDengine each own part of the story, so query and durability semantics are hard to reason about.
|
||||
|
||||
The new design intentionally keeps useful protocol parsers and app deployment knowledge, but replaces the ingestion, durability, storage, and query boundaries with a first-principles model.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Store every original inbound protocol frame, including malformed frames when bytes are available.
|
||||
2. Query historical uploaded data for GB32960 and JT808 by vehicle, protocol, message type, and time range.
|
||||
3. Extend to new fields, protocol messages, and vendor profiles without rewriting the core pipeline.
|
||||
4. Sustain production load for 10,000 vehicles and 1,000 concurrent historical query requests per second.
|
||||
5. Make ACK and durability rules explicit enough that an acknowledged frame is not silently lost.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not make Kafka the long-term historical query database.
|
||||
- Do not store large raw byte arrays directly in Kafka topics.
|
||||
- Do not keep DuckDB/Parquet as the production hot query store for GB32960 and JT808.
|
||||
- Do not build one generic API that hides protocol-specific semantics. Shared internals are preferred; external APIs may remain protocol-aware.
|
||||
|
||||
## Recommended Architecture
|
||||
|
||||
Use `Raw Archive + Kafka + TDengine` as the core architecture.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
GB["GB32960 TCP"] --> Edge["Protocol Edge"]
|
||||
JT["JT808 TCP"] --> Edge
|
||||
Edge --> RawStore["Raw Archive"]
|
||||
RawStore --> RawTopic["Kafka vehicle.raw-frame.v1"]
|
||||
Edge --> Decode["Protocol Decoder"]
|
||||
Decode --> FactTopic["Kafka vehicle.decoded-fact.v1"]
|
||||
RawTopic --> Writer["History Writer"]
|
||||
FactTopic --> Writer
|
||||
Writer --> TD["TDengine"]
|
||||
Writer --> Redis["Realtime State"]
|
||||
API["History API"] --> TD
|
||||
API --> RawStore
|
||||
RealtimeAPI["Realtime API"] --> Redis
|
||||
```
|
||||
|
||||
The architecture has three facts:
|
||||
|
||||
- `RawFrameFact`: one row per inbound frame, whether parsing succeeds or fails.
|
||||
- `DecodedFact`: zero or more normalized facts derived from a raw frame.
|
||||
- `RawArchiveObject`: immutable raw bytes addressed by `raw_uri`.
|
||||
|
||||
The raw archive is the forensic source of truth. TDengine is the query source of truth. Kafka is the decoupling and replay pipe.
|
||||
|
||||
## Module Boundaries
|
||||
|
||||
### Protocol Edge Apps
|
||||
|
||||
Apps:
|
||||
|
||||
- `gb32960-ingest-app`
|
||||
- `jt808-ingest-app`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Accept TCP connections.
|
||||
- Split frames.
|
||||
- Perform protocol-level validation, authentication, session binding, and ACK.
|
||||
- Create `RawFrameFact`.
|
||||
- Persist raw bytes through `RawArchiveWriter`.
|
||||
- Publish raw frame fact to Kafka.
|
||||
- Decode frames and publish decoded facts.
|
||||
|
||||
They do not query history, export data, or write business tables directly.
|
||||
|
||||
### Ingest Core
|
||||
|
||||
New module:
|
||||
|
||||
- `modules/core/ingest-facts`
|
||||
|
||||
Core types:
|
||||
|
||||
```java
|
||||
public record RawFrameFact(
|
||||
String frameId,
|
||||
ProtocolId protocol,
|
||||
String vehicleKey,
|
||||
String vin,
|
||||
String phone,
|
||||
int messageId,
|
||||
int subType,
|
||||
Instant eventTime,
|
||||
Instant receivedAt,
|
||||
String peer,
|
||||
String rawUri,
|
||||
String checksum,
|
||||
long rawSizeBytes,
|
||||
ParseStatus parseStatus,
|
||||
String parseError,
|
||||
Map<String, String> metadata
|
||||
) {}
|
||||
```
|
||||
|
||||
```java
|
||||
public sealed interface DecodedFact permits
|
||||
DecodedFact.Location,
|
||||
DecodedFact.Realtime,
|
||||
DecodedFact.Alarm,
|
||||
DecodedFact.Session,
|
||||
DecodedFact.Register,
|
||||
DecodedFact.Extension {
|
||||
|
||||
String factId();
|
||||
String frameId();
|
||||
ProtocolId protocol();
|
||||
String vehicleKey();
|
||||
String vin();
|
||||
Instant eventTime();
|
||||
Instant receivedAt();
|
||||
String rawUri();
|
||||
Map<String, String> metadata();
|
||||
}
|
||||
```
|
||||
|
||||
`vehicleKey` is the stable partition key:
|
||||
|
||||
- GB32960: VIN.
|
||||
- JT808: resolved VIN when known, otherwise `jt808:<phone>`.
|
||||
- Unknown/malformed: `unknown:<protocol>:<hash>`.
|
||||
|
||||
### Raw Archive
|
||||
|
||||
New or refactored module:
|
||||
|
||||
- `modules/sinks/raw-archive-store`
|
||||
|
||||
Interface:
|
||||
|
||||
```java
|
||||
public interface RawArchiveWriter {
|
||||
RawArchiveReceipt write(RawArchiveWriteRequest request) throws IOException;
|
||||
}
|
||||
```
|
||||
|
||||
Receipt:
|
||||
|
||||
```java
|
||||
public record RawArchiveReceipt(
|
||||
String rawUri,
|
||||
String checksum,
|
||||
long sizeBytes
|
||||
) {}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Raw bytes are immutable once written.
|
||||
- Write path is content-addressed or deterministic by `protocol/date/vehicleKey/frameId.bin`.
|
||||
- Write must be fsync-capable for local storage and pluggable for object storage.
|
||||
- `rawUri` must be resolvable by history API for single-frame replay.
|
||||
- Malformed frames are written with `parseStatus=FAILED` when bytes are available.
|
||||
|
||||
Deployment constraints:
|
||||
|
||||
- Raw archive, TDengine, and the history service may run on different ECS instances, but should use private-network access in the same VPC and availability zone.
|
||||
- High-frequency historical queries only read TDengine and must not read raw archive.
|
||||
- Raw archive is only used by single-frame detail, troubleshooting, replay, and asynchronous export paths.
|
||||
- `rawUri` must be globally resolvable by services and must not be a private local absolute path on one ECS instance.
|
||||
- The first local raw archive implementation must stay behind a replaceable interface so it can later move to OSS, MinIO, or another object store.
|
||||
|
||||
### Kafka Topics
|
||||
|
||||
Use stable versioned topics:
|
||||
|
||||
- `vehicle.raw-frame.v1`
|
||||
- `vehicle.decoded-fact.v1`
|
||||
- `vehicle.dlq.v1`
|
||||
|
||||
Kafka key:
|
||||
|
||||
```text
|
||||
<protocol>:<vehicleKey>
|
||||
```
|
||||
|
||||
`vehicle.raw-frame.v1` payload contains `RawFrameFact` without raw bytes. It carries `rawUri`, checksum, byte size, message id, parse status, and metadata.
|
||||
|
||||
`vehicle.decoded-fact.v1` payload contains one `DecodedFact`. It always references `frameId` and `rawUri`.
|
||||
|
||||
`vehicle.dlq.v1` carries failed store, publish, decode, and write attempts with enough metadata to replay from raw archive when possible.
|
||||
|
||||
### History Writer
|
||||
|
||||
New app or refactored app:
|
||||
|
||||
- `vehicle-history-app`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Consume `vehicle.raw-frame.v1`.
|
||||
- Consume `vehicle.decoded-fact.v1`.
|
||||
- Batch write TDengine.
|
||||
- Update realtime state store for latest vehicle state.
|
||||
- Write DLQ on invalid payloads or storage failures.
|
||||
|
||||
The writer is idempotent by `(frame_id)` for raw facts and `(fact_id)` for decoded facts.
|
||||
|
||||
## TDengine Model
|
||||
|
||||
TDengine is the production historical query store.
|
||||
|
||||
### Raw Frames Super Table
|
||||
|
||||
```sql
|
||||
CREATE STABLE IF NOT EXISTS raw_frames (
|
||||
ts TIMESTAMP,
|
||||
frame_id NCHAR(64),
|
||||
received_at TIMESTAMP,
|
||||
message_id INT,
|
||||
sub_type INT,
|
||||
event_time TIMESTAMP,
|
||||
raw_uri NCHAR(512),
|
||||
checksum NCHAR(128),
|
||||
raw_size_bytes BIGINT,
|
||||
parse_status NCHAR(16),
|
||||
parse_error NCHAR(512),
|
||||
peer NCHAR(128),
|
||||
metadata_json NCHAR(4096)
|
||||
) TAGS (
|
||||
protocol NCHAR(16),
|
||||
vehicle_key NCHAR(128),
|
||||
vin NCHAR(64),
|
||||
phone NCHAR(32)
|
||||
);
|
||||
```
|
||||
|
||||
Child table:
|
||||
|
||||
```text
|
||||
raw_<protocol>_<hash(vehicleKey)>
|
||||
```
|
||||
|
||||
Primary query patterns:
|
||||
|
||||
- Raw frames by vehicle and time.
|
||||
- Raw frames by protocol, message id, parse status, and time.
|
||||
- Single raw frame by `frame_id` or `raw_uri`.
|
||||
|
||||
### Location Super Table
|
||||
|
||||
```sql
|
||||
CREATE STABLE IF NOT EXISTS vehicle_locations (
|
||||
ts TIMESTAMP,
|
||||
fact_id NCHAR(64),
|
||||
frame_id NCHAR(64),
|
||||
received_at TIMESTAMP,
|
||||
longitude DOUBLE,
|
||||
latitude DOUBLE,
|
||||
altitude_m DOUBLE,
|
||||
speed_kmh DOUBLE,
|
||||
direction_deg DOUBLE,
|
||||
alarm_flag BIGINT,
|
||||
status_flag BIGINT,
|
||||
total_mileage_km DOUBLE,
|
||||
raw_uri NCHAR(512),
|
||||
metadata_json NCHAR(4096)
|
||||
) TAGS (
|
||||
protocol NCHAR(16),
|
||||
vehicle_key NCHAR(128),
|
||||
vin NCHAR(64),
|
||||
phone NCHAR(32)
|
||||
);
|
||||
```
|
||||
|
||||
### Realtime Super Table
|
||||
|
||||
```sql
|
||||
CREATE STABLE IF NOT EXISTS vehicle_realtime (
|
||||
ts TIMESTAMP,
|
||||
fact_id NCHAR(64),
|
||||
frame_id NCHAR(64),
|
||||
received_at TIMESTAMP,
|
||||
speed_kmh DOUBLE,
|
||||
total_mileage_km DOUBLE,
|
||||
battery_soc DOUBLE,
|
||||
total_voltage_v DOUBLE,
|
||||
total_current_a DOUBLE,
|
||||
running_mode NCHAR(64),
|
||||
vehicle_state NCHAR(64),
|
||||
charging_state NCHAR(64),
|
||||
raw_uri NCHAR(512),
|
||||
fields_json NCHAR(8192),
|
||||
metadata_json NCHAR(4096)
|
||||
) TAGS (
|
||||
protocol NCHAR(16),
|
||||
vehicle_key NCHAR(128),
|
||||
vin NCHAR(64),
|
||||
phone NCHAR(32)
|
||||
);
|
||||
```
|
||||
|
||||
`fields_json` stores sparse full-field values for low-frequency inspection. High-frequency queried fields should be promoted to explicit columns or a dedicated extension table.
|
||||
|
||||
### Alarm Super Table
|
||||
|
||||
```sql
|
||||
CREATE STABLE IF NOT EXISTS vehicle_alarms (
|
||||
ts TIMESTAMP,
|
||||
fact_id NCHAR(64),
|
||||
frame_id NCHAR(64),
|
||||
received_at TIMESTAMP,
|
||||
alarm_level NCHAR(32),
|
||||
alarm_code BIGINT,
|
||||
alarm_name NCHAR(128),
|
||||
longitude DOUBLE,
|
||||
latitude DOUBLE,
|
||||
raw_uri NCHAR(512),
|
||||
fields_json NCHAR(8192),
|
||||
metadata_json NCHAR(4096)
|
||||
) TAGS (
|
||||
protocol NCHAR(16),
|
||||
vehicle_key NCHAR(128),
|
||||
vin NCHAR(64),
|
||||
phone NCHAR(32)
|
||||
);
|
||||
```
|
||||
|
||||
### Session and Registration Tables
|
||||
|
||||
```sql
|
||||
CREATE STABLE IF NOT EXISTS vehicle_sessions (
|
||||
ts TIMESTAMP,
|
||||
fact_id NCHAR(64),
|
||||
frame_id NCHAR(64),
|
||||
received_at TIMESTAMP,
|
||||
session_type NCHAR(32),
|
||||
result_code INT,
|
||||
raw_uri NCHAR(512),
|
||||
fields_json NCHAR(8192),
|
||||
metadata_json NCHAR(4096)
|
||||
) TAGS (
|
||||
protocol NCHAR(16),
|
||||
vehicle_key NCHAR(128),
|
||||
vin NCHAR(64),
|
||||
phone NCHAR(32)
|
||||
);
|
||||
```
|
||||
|
||||
JT808 registration details go into `vehicle_sessions` first and may later be projected into MySQL identity bindings for durable identity lookup.
|
||||
|
||||
## ACK and Durability Rules
|
||||
|
||||
For frames that require a protocol ACK:
|
||||
|
||||
1. Frame bytes are accepted from Netty.
|
||||
2. Raw bytes are written to raw archive.
|
||||
3. `RawFrameFact` is published to Kafka.
|
||||
4. Protocol-specific ACK is sent.
|
||||
5. Decoding and `DecodedFact` publication may run before or after ACK, but failures must emit a DLQ event and update raw frame parse status.
|
||||
|
||||
For GB32960 realtime/report frames, this prevents acknowledging a frame whose original bytes cannot be recovered.
|
||||
|
||||
For JT808 register/auth/heartbeat, ACK follows the same raw archive and raw fact boundary. Register ACK can still include token generation before raw fact publish, but must not be flushed to the channel until the durability boundary succeeds.
|
||||
|
||||
If raw archive write fails, close or keep the channel according to protocol tolerance, but do not ACK success.
|
||||
|
||||
If Kafka raw fact publish fails, do not ACK success for durable-ACK frames. Non-durable frames go to local DLQ when configured.
|
||||
|
||||
## Protocol Decoding and Extension
|
||||
|
||||
Use a plugin contract:
|
||||
|
||||
```java
|
||||
public interface ProtocolFrameDecoder {
|
||||
ProtocolId protocol();
|
||||
DecodeResult decode(RawFrameFact rawFact, byte[] rawBytes);
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
public record DecodeResult(
|
||||
ParseStatus status,
|
||||
List<DecodedFact> facts,
|
||||
String errorMessage,
|
||||
Map<String, String> metadata
|
||||
) {}
|
||||
```
|
||||
|
||||
GB32960:
|
||||
|
||||
- Frame command maps to session, realtime, location, alarm, and extension facts.
|
||||
- Vendor extensions are selected by platform account, VIN, or configured profile.
|
||||
- Full raw replay remains possible by reading `rawUri` and running the current decoder.
|
||||
|
||||
JT808:
|
||||
|
||||
- Message id maps to register, auth, heartbeat, location, batch location, media metadata, and extension facts.
|
||||
- Identity resolution updates `vehicleKey`.
|
||||
- Registration facts carry province, city, maker, device id, plate, plate color, and auth token metadata.
|
||||
|
||||
New protocol messages do not require TDengine schema changes unless they become high-frequency query dimensions. Unknown fields are stored as extension facts with `fields_json`.
|
||||
|
||||
## Query APIs
|
||||
|
||||
Keep API surfaces explicit.
|
||||
|
||||
Raw frame APIs:
|
||||
|
||||
- `GET /api/history/raw-frames`
|
||||
- Filters: `protocol`, `vin`, `phone`, `vehicleKey`, `messageId`, `parseStatus`, `dateFrom`, `dateTo`, `pageSize`, `cursor`.
|
||||
- Returns frame metadata, raw URI, checksum, parse status, and compact decoded summary when available.
|
||||
|
||||
Single raw frame API:
|
||||
|
||||
- `GET /api/history/raw-frames/{frameId}`
|
||||
- Returns raw metadata and decoded detail.
|
||||
- Supports `includeRawHex=true` with a strict size limit.
|
||||
|
||||
Location APIs:
|
||||
|
||||
- `GET /api/history/locations`
|
||||
- Filters: `protocol`, `vin`, `phone`, `vehicleKey`, time range, cursor.
|
||||
|
||||
Realtime APIs:
|
||||
|
||||
- `GET /api/history/realtime`
|
||||
- Historical time-series query.
|
||||
|
||||
Realtime state APIs:
|
||||
|
||||
- `GET /api/realtime/vehicles/{vehicleKey}`
|
||||
- Reads Redis latest state, not TDengine.
|
||||
|
||||
Export:
|
||||
|
||||
- Export is asynchronous.
|
||||
- Request creates an export job.
|
||||
- Worker streams TDengine query results to object storage or local export files.
|
||||
- API returns job status and download URI.
|
||||
|
||||
## Pagination
|
||||
|
||||
Use cursor pagination, not deep offset, for high-QPS history queries.
|
||||
|
||||
Cursor fields:
|
||||
|
||||
```text
|
||||
ts, received_at, fact_id
|
||||
```
|
||||
|
||||
Descending query predicate:
|
||||
|
||||
```sql
|
||||
WHERE (
|
||||
ts < :cursorTs
|
||||
OR (ts = :cursorTs AND received_at < :cursorReceivedAt)
|
||||
OR (ts = :cursorTs AND received_at = :cursorReceivedAt AND fact_id < :cursorFactId)
|
||||
)
|
||||
```
|
||||
|
||||
Page size defaults to 100 and is capped at 1000.
|
||||
|
||||
## Capacity Design
|
||||
|
||||
Assumptions:
|
||||
|
||||
- 10,000 vehicles.
|
||||
- Normal report interval: 5 to 10 seconds.
|
||||
- Expected writes: 1,000 to 2,000 frames per second.
|
||||
- Peak burst factor: 3x.
|
||||
- Historical query target: 1,000 requests per second.
|
||||
|
||||
Write path:
|
||||
|
||||
- Netty worker threads stay CPU-light.
|
||||
- Raw archive writes use bounded async workers.
|
||||
- Kafka producer uses compression, batching, idempotence, and vehicle-key partitioning.
|
||||
- History writer batches TDengine inserts by super table and child table.
|
||||
- TDengine child tables are partitioned by vehicle key to make single-vehicle queries cheap.
|
||||
|
||||
Read path:
|
||||
|
||||
- Most high-QPS queries require vehicle key and time range.
|
||||
- Unbounded cross-vehicle queries are admin-only and capped.
|
||||
- Realtime latest state is served from Redis latest-state cache.
|
||||
- Raw detail replay reads one raw object and decodes on demand.
|
||||
- Common dictionary and metadata responses are cached in memory.
|
||||
|
||||
Backpressure:
|
||||
|
||||
- Raw archive worker queue has a fixed bound.
|
||||
- Kafka publish futures are tracked.
|
||||
- If durability boundary cannot complete within timeout, durable ACK fails and the channel is closed or throttled.
|
||||
- History writer lag is observable by Kafka consumer lag and TDengine batch latency.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Phase 1: Build new fact model beside existing code.
|
||||
|
||||
- Add `ingest-facts`.
|
||||
- Add raw archive receipt contract.
|
||||
- Add serializers for raw frame and decoded fact Kafka messages.
|
||||
- Add tests for stable ids, vehicle key, and raw URI generation.
|
||||
|
||||
Phase 2: Harden raw durability boundary.
|
||||
|
||||
- Refactor GB32960 and JT808 handlers so ACK waits for raw archive and raw fact publish.
|
||||
- Keep existing parsers.
|
||||
- Emit decoded facts to the new Kafka topic.
|
||||
|
||||
Phase 3: Build TDengine writer.
|
||||
|
||||
- Create TDengine schema manager.
|
||||
- Write raw frame facts.
|
||||
- Write location, realtime, alarm, session facts.
|
||||
- Add idempotent insert behavior by stable ids.
|
||||
|
||||
Phase 4: Build query APIs.
|
||||
|
||||
- Raw frame query.
|
||||
- Single frame replay.
|
||||
- Location history query.
|
||||
- Realtime history query.
|
||||
- Latest realtime state query.
|
||||
|
||||
Phase 5: Cut over and retire old hot paths.
|
||||
|
||||
- Stop using DuckDB/Parquet as production hot history for GB32960/JT808.
|
||||
- Keep raw replay compatibility while historical data migrates.
|
||||
- Remove protocol-specific ad hoc history storage once TDengine coverage is verified.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Unit tests:
|
||||
|
||||
- `RawFrameFact` validation and vehicle key derivation.
|
||||
- Raw archive path generation and checksum.
|
||||
- GB32960 decoder facts from golden frames.
|
||||
- JT808 decoder facts from sample frames.
|
||||
- ACK boundary success and failure cases.
|
||||
- TDengine SQL generation.
|
||||
|
||||
Integration tests:
|
||||
|
||||
- Start app with embedded or mocked Kafka producer and fake raw archive.
|
||||
- Send GB32960 sample frame and verify raw fact, decoded fact, raw archive receipt.
|
||||
- Send JT808 register/auth/location frames and verify registration/session/location facts.
|
||||
- Simulate raw archive failure and verify no success ACK.
|
||||
- Simulate Kafka publish failure and verify durable ACK failure.
|
||||
|
||||
Storage tests:
|
||||
|
||||
- TDengine schema initialization.
|
||||
- Batch insert raw frames and decoded facts.
|
||||
- Query by vehicle key and time range.
|
||||
- Cursor pagination correctness.
|
||||
- Single raw frame replay by `rawUri`.
|
||||
|
||||
Load tests:
|
||||
|
||||
- 2,000 frames/s sustained write for at least 30 minutes.
|
||||
- 6,000 frames/s burst for 5 minutes.
|
||||
- 1,000 QPS location/realtime history queries with bounded p95 latency.
|
||||
- Verify no raw frame count gap between raw archive receipts, Kafka raw facts, and TDengine raw frame rows.
|
||||
|
||||
## Operational Requirements
|
||||
|
||||
Metrics:
|
||||
|
||||
- TCP connections.
|
||||
- Frame receive rate.
|
||||
- Raw archive write latency and failure count.
|
||||
- Kafka publish latency and failure count.
|
||||
- Decoder success/failure count by protocol and message id.
|
||||
- TDengine batch size, latency, and failure count.
|
||||
- Query QPS and latency by endpoint.
|
||||
- Kafka consumer lag.
|
||||
|
||||
Logs:
|
||||
|
||||
- One structured log per failed frame with `frameId`, protocol, vehicle key, message id, raw URI, and error.
|
||||
- No full raw bytes in normal logs.
|
||||
- Raw hex only in explicit debug tools with size limits.
|
||||
|
||||
Alerts:
|
||||
|
||||
- Raw archive write failure.
|
||||
- Durable ACK timeout.
|
||||
- Kafka producer failure.
|
||||
- History writer lag.
|
||||
- TDengine write failure.
|
||||
- Raw archive count and TDengine raw frame row count divergence.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
The redesign is complete only when all of the following are true:
|
||||
|
||||
1. Every accepted GB32960 and JT808 frame creates a raw archive object and a TDengine `raw_frames` row.
|
||||
2. Malformed frames with bytes are queryable from raw history with parse status and error reason.
|
||||
3. GB32960 and JT808 location history can be queried by vehicle and time range with cursor pagination.
|
||||
4. GB32960 realtime history can be queried by vehicle and time range.
|
||||
5. JT808 registration/session data is queryable and can update identity binding.
|
||||
6. Single-frame detail can reload raw bytes by `rawUri` and decode with the current decoder.
|
||||
7. ACK-required frames do not receive success ACK before raw archive and raw fact publication succeed.
|
||||
8. New protocol fields can be added by implementing a decoder/projector and, only when needed, a TDengine table migration.
|
||||
9. Load tests demonstrate sustained 2,000 frames/s writes and 1,000 QPS bounded historical queries.
|
||||
10. Old DuckDB/Parquet hot-history paths are no longer required for GB32960/JT808 production queries.
|
||||
|
||||
## Open Decisions
|
||||
|
||||
The following choices are intentionally fixed for the first implementation:
|
||||
|
||||
- Use local raw archive first, with the interface shaped for object storage later.
|
||||
- Use TDengine as the only production hot historical query store for GB32960/JT808.
|
||||
- Use explicit protocol-aware query APIs instead of a single generic query API.
|
||||
- Use cursor pagination for all high-QPS historical queries.
|
||||
- Keep Kafka payloads byte-light by sending raw references instead of raw bytes.
|
||||
@@ -1,624 +0,0 @@
|
||||
# 车辆接入重设计方案
|
||||
|
||||
## 背景
|
||||
|
||||
当前 GB32960 和 JT808 链路已经能够完成接收、解析、发布和查询,但职责边界被拆散在多条路径里:
|
||||
|
||||
- Netty Handler 生成 `RawFrame`,Dispatcher 同时发出原始归档事件和标准化车辆事件。
|
||||
- Kafka Envelope 只携带 raw archive 引用,原始 bytes 由 archive sink 另行存储。
|
||||
- GB32960 历史查询依赖 raw archive 记录,再回读文件重新解析。
|
||||
- JT808 位置历史又单独物化到 TDengine。
|
||||
- DuckDB/Parquet、本地 raw archive、Kafka、TDengine 分别承担一部分职责,导致查询语义和持久化边界不好推理。
|
||||
|
||||
新设计保留已有协议解析器和部署经验,但重新定义接入、持久化、存储和查询边界。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 保存每一条入站协议原始帧,包括可拿到 bytes 的异常帧。
|
||||
2. 支持按车辆、协议、消息类型、时间范围查询 GB32960 和 JT808 历史上报数据。
|
||||
3. 新增字段、协议消息、厂商扩展时,不需要重写核心管线。
|
||||
4. 支撑 10,000 辆车生产写入,以及 1,000 QPS 历史查询。
|
||||
5. 明确 ACK 和持久化规则,避免“已经 ACK 但数据悄悄丢失”。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不把 Kafka 作为长期历史查询数据库。
|
||||
- 不把大块原始 bytes 直接塞进 Kafka topic。
|
||||
- 不继续把 DuckDB/Parquet 作为 GB32960/JT808 的生产热查询库。
|
||||
- 不做一个掩盖协议差异的万能接口。内部可以统一,外部 API 保持协议语义清晰。
|
||||
|
||||
## 推荐架构
|
||||
|
||||
采用 `Raw Archive + Kafka + TDengine` 作为核心架构。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
GB["GB32960 TCP"] --> Edge["协议接入边界"]
|
||||
JT["JT808 TCP"] --> Edge
|
||||
Edge --> RawStore["Raw Archive"]
|
||||
RawStore --> RawTopic["Kafka vehicle.raw-frame.v1"]
|
||||
Edge --> Decode["协议解析器"]
|
||||
Decode --> FactTopic["Kafka vehicle.decoded-fact.v1"]
|
||||
RawTopic --> Writer["History Writer"]
|
||||
FactTopic --> Writer
|
||||
Writer --> TD["TDengine"]
|
||||
Writer --> Redis["实时状态"]
|
||||
API["历史 API"] --> TD
|
||||
API --> RawStore
|
||||
RealtimeAPI["实时 API"] --> Redis
|
||||
```
|
||||
|
||||
架构里只有三类事实:
|
||||
|
||||
- `RawFrameFact`:每一条入站帧一条记录,不管解析成功还是失败。
|
||||
- `DecodedFact`:从 raw frame 派生出来的零条或多条标准事实。
|
||||
- `RawArchiveObject`:不可变原始 bytes,通过 `raw_uri` 寻址。
|
||||
|
||||
raw archive 是排障和回放的事实源。TDengine 是查询事实源。Kafka 是解耦和重放管道。
|
||||
|
||||
## 模块边界
|
||||
|
||||
### 协议接入 APP
|
||||
|
||||
APP:
|
||||
|
||||
- `gb32960-ingest-app`
|
||||
- `jt808-ingest-app`
|
||||
|
||||
职责:
|
||||
|
||||
- 接收 TCP 连接。
|
||||
- 完成帧切分。
|
||||
- 执行协议层校验、鉴权、会话绑定和 ACK。
|
||||
- 创建 `RawFrameFact`。
|
||||
- 通过 `RawArchiveWriter` 持久化原始 bytes。
|
||||
- 发布 raw frame fact 到 Kafka。
|
||||
- 解析协议帧并发布 decoded facts。
|
||||
|
||||
接入 APP 不负责历史查询、导出,也不直接写业务查询表。
|
||||
|
||||
### Ingest Core
|
||||
|
||||
新增模块:
|
||||
|
||||
- `modules/core/ingest-facts`
|
||||
|
||||
核心类型:
|
||||
|
||||
```java
|
||||
public record RawFrameFact(
|
||||
String frameId,
|
||||
ProtocolId protocol,
|
||||
String vehicleKey,
|
||||
String vin,
|
||||
String phone,
|
||||
int messageId,
|
||||
int subType,
|
||||
Instant eventTime,
|
||||
Instant receivedAt,
|
||||
String peer,
|
||||
String rawUri,
|
||||
String checksum,
|
||||
long rawSizeBytes,
|
||||
ParseStatus parseStatus,
|
||||
String parseError,
|
||||
Map<String, String> metadata
|
||||
) {}
|
||||
```
|
||||
|
||||
```java
|
||||
public sealed interface DecodedFact permits
|
||||
DecodedFact.Location,
|
||||
DecodedFact.Realtime,
|
||||
DecodedFact.Alarm,
|
||||
DecodedFact.Session,
|
||||
DecodedFact.Register,
|
||||
DecodedFact.Extension {
|
||||
|
||||
String factId();
|
||||
String frameId();
|
||||
ProtocolId protocol();
|
||||
String vehicleKey();
|
||||
String vin();
|
||||
Instant eventTime();
|
||||
Instant receivedAt();
|
||||
String rawUri();
|
||||
Map<String, String> metadata();
|
||||
}
|
||||
```
|
||||
|
||||
`vehicleKey` 是稳定分区键:
|
||||
|
||||
- GB32960:VIN。
|
||||
- JT808:已解析到 VIN 时使用 VIN,否则使用 `jt808:<phone>`。
|
||||
- 未知或异常帧:使用 `unknown:<protocol>:<hash>`。
|
||||
|
||||
### Raw Archive
|
||||
|
||||
新增或重构模块:
|
||||
|
||||
- `modules/sinks/raw-archive-store`
|
||||
|
||||
接口:
|
||||
|
||||
```java
|
||||
public interface RawArchiveWriter {
|
||||
RawArchiveReceipt write(RawArchiveWriteRequest request) throws IOException;
|
||||
}
|
||||
```
|
||||
|
||||
写入回执:
|
||||
|
||||
```java
|
||||
public record RawArchiveReceipt(
|
||||
String rawUri,
|
||||
String checksum,
|
||||
long sizeBytes
|
||||
) {}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 原始 bytes 一旦写入,不允许原地修改。
|
||||
- 写入路径可以按内容寻址,也可以按 `protocol/date/vehicleKey/frameId.bin` 生成。
|
||||
- 本地存储需要支持 fsync 能力,接口要预留对象存储实现。
|
||||
- `rawUri` 必须能被历史 API 解析,用于单帧回放。
|
||||
- 异常帧只要有 bytes,也必须写入,并记录 `parseStatus=FAILED`。
|
||||
|
||||
部署约束:
|
||||
|
||||
- Raw archive、TDengine、history 服务可以不在同一台 ECS,但应在同一 VPC 和同一可用区内通过内网访问。
|
||||
- 高频历史查询只访问 TDengine,不访问 raw archive。
|
||||
- Raw archive 只进入单帧详情、问题排查、回放和异步导出路径。
|
||||
- `rawUri` 必须是全局可解析地址,不能是某台 ECS 私有的本地绝对路径。
|
||||
- 第一版本地 raw archive 需要通过可替换接口封装,后续可以切换到 OSS、MinIO 或其它对象存储。
|
||||
|
||||
### Kafka Topic
|
||||
|
||||
使用稳定的版本化 topic:
|
||||
|
||||
- `vehicle.raw-frame.v1`
|
||||
- `vehicle.decoded-fact.v1`
|
||||
- `vehicle.dlq.v1`
|
||||
|
||||
Kafka key:
|
||||
|
||||
```text
|
||||
<protocol>:<vehicleKey>
|
||||
```
|
||||
|
||||
`vehicle.raw-frame.v1` 的 payload 是不含 raw bytes 的 `RawFrameFact`,携带 `rawUri`、checksum、byte size、message id、parse status 和 metadata。
|
||||
|
||||
`vehicle.decoded-fact.v1` 的 payload 是单条 `DecodedFact`,必须引用 `frameId` 和 `rawUri`。
|
||||
|
||||
`vehicle.dlq.v1` 保存存储失败、发布失败、解析失败和写入失败事件,并携带足够元数据,尽量支持从 raw archive 重放。
|
||||
|
||||
### History Writer
|
||||
|
||||
新增或重构 APP:
|
||||
|
||||
- `vehicle-history-app`
|
||||
|
||||
职责:
|
||||
|
||||
- 消费 `vehicle.raw-frame.v1`。
|
||||
- 消费 `vehicle.decoded-fact.v1`。
|
||||
- 批量写 TDengine。
|
||||
- 更新实时状态存储。
|
||||
- 对非法 payload 或存储失败写 DLQ。
|
||||
|
||||
Writer 写入需要幂等:
|
||||
|
||||
- raw fact 以 `(frame_id)` 幂等。
|
||||
- decoded fact 以 `(fact_id)` 幂等。
|
||||
|
||||
## TDengine 模型
|
||||
|
||||
TDengine 是生产历史查询主库。
|
||||
|
||||
### 原始帧超级表
|
||||
|
||||
```sql
|
||||
CREATE STABLE IF NOT EXISTS raw_frames (
|
||||
ts TIMESTAMP,
|
||||
frame_id NCHAR(64),
|
||||
received_at TIMESTAMP,
|
||||
message_id INT,
|
||||
sub_type INT,
|
||||
event_time TIMESTAMP,
|
||||
raw_uri NCHAR(512),
|
||||
checksum NCHAR(128),
|
||||
raw_size_bytes BIGINT,
|
||||
parse_status NCHAR(16),
|
||||
parse_error NCHAR(512),
|
||||
peer NCHAR(128),
|
||||
metadata_json NCHAR(4096)
|
||||
) TAGS (
|
||||
protocol NCHAR(16),
|
||||
vehicle_key NCHAR(128),
|
||||
vin NCHAR(64),
|
||||
phone NCHAR(32)
|
||||
);
|
||||
```
|
||||
|
||||
子表命名:
|
||||
|
||||
```text
|
||||
raw_<protocol>_<hash(vehicleKey)>
|
||||
```
|
||||
|
||||
主要查询模式:
|
||||
|
||||
- 按车辆和时间查询原始帧。
|
||||
- 按协议、消息 ID、解析状态和时间查询原始帧。
|
||||
- 按 `frame_id` 或 `raw_uri` 查询单帧。
|
||||
|
||||
### 位置超级表
|
||||
|
||||
```sql
|
||||
CREATE STABLE IF NOT EXISTS vehicle_locations (
|
||||
ts TIMESTAMP,
|
||||
fact_id NCHAR(64),
|
||||
frame_id NCHAR(64),
|
||||
received_at TIMESTAMP,
|
||||
longitude DOUBLE,
|
||||
latitude DOUBLE,
|
||||
altitude_m DOUBLE,
|
||||
speed_kmh DOUBLE,
|
||||
direction_deg DOUBLE,
|
||||
alarm_flag BIGINT,
|
||||
status_flag BIGINT,
|
||||
total_mileage_km DOUBLE,
|
||||
raw_uri NCHAR(512),
|
||||
metadata_json NCHAR(4096)
|
||||
) TAGS (
|
||||
protocol NCHAR(16),
|
||||
vehicle_key NCHAR(128),
|
||||
vin NCHAR(64),
|
||||
phone NCHAR(32)
|
||||
);
|
||||
```
|
||||
|
||||
### 实时数据超级表
|
||||
|
||||
```sql
|
||||
CREATE STABLE IF NOT EXISTS vehicle_realtime (
|
||||
ts TIMESTAMP,
|
||||
fact_id NCHAR(64),
|
||||
frame_id NCHAR(64),
|
||||
received_at TIMESTAMP,
|
||||
speed_kmh DOUBLE,
|
||||
total_mileage_km DOUBLE,
|
||||
battery_soc DOUBLE,
|
||||
total_voltage_v DOUBLE,
|
||||
total_current_a DOUBLE,
|
||||
running_mode NCHAR(64),
|
||||
vehicle_state NCHAR(64),
|
||||
charging_state NCHAR(64),
|
||||
raw_uri NCHAR(512),
|
||||
fields_json NCHAR(8192),
|
||||
metadata_json NCHAR(4096)
|
||||
) TAGS (
|
||||
protocol NCHAR(16),
|
||||
vehicle_key NCHAR(128),
|
||||
vin NCHAR(64),
|
||||
phone NCHAR(32)
|
||||
);
|
||||
```
|
||||
|
||||
`fields_json` 存放稀疏全字段值,用于低频排查。高频查询字段应提升为明确列,或放入专用扩展表。
|
||||
|
||||
### 报警超级表
|
||||
|
||||
```sql
|
||||
CREATE STABLE IF NOT EXISTS vehicle_alarms (
|
||||
ts TIMESTAMP,
|
||||
fact_id NCHAR(64),
|
||||
frame_id NCHAR(64),
|
||||
received_at TIMESTAMP,
|
||||
alarm_level NCHAR(32),
|
||||
alarm_code BIGINT,
|
||||
alarm_name NCHAR(128),
|
||||
longitude DOUBLE,
|
||||
latitude DOUBLE,
|
||||
raw_uri NCHAR(512),
|
||||
fields_json NCHAR(8192),
|
||||
metadata_json NCHAR(4096)
|
||||
) TAGS (
|
||||
protocol NCHAR(16),
|
||||
vehicle_key NCHAR(128),
|
||||
vin NCHAR(64),
|
||||
phone NCHAR(32)
|
||||
);
|
||||
```
|
||||
|
||||
### 会话和注册表
|
||||
|
||||
```sql
|
||||
CREATE STABLE IF NOT EXISTS vehicle_sessions (
|
||||
ts TIMESTAMP,
|
||||
fact_id NCHAR(64),
|
||||
frame_id NCHAR(64),
|
||||
received_at TIMESTAMP,
|
||||
session_type NCHAR(32),
|
||||
result_code INT,
|
||||
raw_uri NCHAR(512),
|
||||
fields_json NCHAR(8192),
|
||||
metadata_json NCHAR(4096)
|
||||
) TAGS (
|
||||
protocol NCHAR(16),
|
||||
vehicle_key NCHAR(128),
|
||||
vin NCHAR(64),
|
||||
phone NCHAR(32)
|
||||
);
|
||||
```
|
||||
|
||||
JT808 注册详情先进入 `vehicle_sessions`,后续可以投影到 MySQL 车辆身份绑定表,用于持久身份解析。
|
||||
|
||||
## ACK 和持久化规则
|
||||
|
||||
对需要协议 ACK 的帧:
|
||||
|
||||
1. Netty 接收到 frame bytes。
|
||||
2. 原始 bytes 写入 raw archive。
|
||||
3. `RawFrameFact` 发布到 Kafka。
|
||||
4. 发送协议 ACK。
|
||||
5. 解码和 `DecodedFact` 发布可以在 ACK 前或 ACK 后执行,但失败必须发 DLQ,并更新 raw frame 解析状态。
|
||||
|
||||
对 GB32960 实时上报和补发帧,这条规则保证不会 ACK 一条无法恢复原始 bytes 的帧。
|
||||
|
||||
对 JT808 注册、鉴权、心跳,也遵循相同 raw archive 和 raw fact 边界。注册 ACK 可以先生成 token,但在 durability boundary 成功前不能 flush 到 channel。
|
||||
|
||||
如果 raw archive 写入失败,不返回成功 ACK。具体是关闭连接还是保留连接,由协议容忍度决定。
|
||||
|
||||
如果 Kafka raw fact 发布失败,durable ACK 帧不返回成功 ACK。非 durable 帧可以写本地 DLQ。
|
||||
|
||||
## 协议解析和扩展
|
||||
|
||||
使用插件契约:
|
||||
|
||||
```java
|
||||
public interface ProtocolFrameDecoder {
|
||||
ProtocolId protocol();
|
||||
DecodeResult decode(RawFrameFact rawFact, byte[] rawBytes);
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
public record DecodeResult(
|
||||
ParseStatus status,
|
||||
List<DecodedFact> facts,
|
||||
String errorMessage,
|
||||
Map<String, String> metadata
|
||||
) {}
|
||||
```
|
||||
|
||||
GB32960:
|
||||
|
||||
- 命令帧映射为会话、实时、位置、报警和扩展事实。
|
||||
- 厂商扩展按平台账号、VIN 或配置 profile 选择。
|
||||
- 全量 raw replay 仍通过读取 `rawUri` 并运行当前解析器完成。
|
||||
|
||||
JT808:
|
||||
|
||||
- 消息 ID 映射为注册、鉴权、心跳、位置、批量位置、多媒体元数据和扩展事实。
|
||||
- 身份解析会更新 `vehicleKey`。
|
||||
- 注册事实携带省、市、厂商、设备 ID、车牌、车牌颜色和 auth token metadata。
|
||||
|
||||
新增协议消息通常不需要改 TDengine schema,除非它成为高频查询维度。未知字段先作为 extension fact 存入 `fields_json`。
|
||||
|
||||
## 查询 API
|
||||
|
||||
API 保持明确,不做一个含糊的统一接口。
|
||||
|
||||
原始帧查询:
|
||||
|
||||
- `GET /api/history/raw-frames`
|
||||
- 过滤条件:`protocol`、`vin`、`phone`、`vehicleKey`、`messageId`、`parseStatus`、`dateFrom`、`dateTo`、`pageSize`、`cursor`。
|
||||
- 返回 frame metadata、raw URI、checksum、parse status,以及可选的紧凑解析摘要。
|
||||
|
||||
单帧详情:
|
||||
|
||||
- `GET /api/history/raw-frames/{frameId}`
|
||||
- 返回 raw metadata 和解码详情。
|
||||
- 支持 `includeRawHex=true`,但必须有严格大小限制。
|
||||
|
||||
位置历史:
|
||||
|
||||
- `GET /api/history/locations`
|
||||
- 过滤条件:`protocol`、`vin`、`phone`、`vehicleKey`、时间范围、cursor。
|
||||
|
||||
实时历史:
|
||||
|
||||
- `GET /api/history/realtime`
|
||||
- 查询历史时序数据。
|
||||
|
||||
实时状态:
|
||||
|
||||
- `GET /api/realtime/vehicles/{vehicleKey}`
|
||||
- 从 Redis 或内存 latest state 读取,不扫 TDengine。
|
||||
|
||||
导出:
|
||||
|
||||
- 导出必须异步。
|
||||
- 请求创建 export job。
|
||||
- Worker 将 TDengine 查询结果流式写入对象存储或本地导出文件。
|
||||
- API 返回 job 状态和下载 URI。
|
||||
|
||||
## 分页
|
||||
|
||||
高 QPS 历史查询使用 cursor pagination,不使用深 offset。
|
||||
|
||||
Cursor 字段:
|
||||
|
||||
```text
|
||||
ts, received_at, fact_id
|
||||
```
|
||||
|
||||
倒序查询谓词:
|
||||
|
||||
```sql
|
||||
WHERE (
|
||||
ts < :cursorTs
|
||||
OR (ts = :cursorTs AND received_at < :cursorReceivedAt)
|
||||
OR (ts = :cursorTs AND received_at = :cursorReceivedAt AND fact_id < :cursorFactId)
|
||||
)
|
||||
```
|
||||
|
||||
默认 page size 为 100,上限为 1000。
|
||||
|
||||
## 容量设计
|
||||
|
||||
假设:
|
||||
|
||||
- 10,000 辆车。
|
||||
- 常规上报间隔 5 到 10 秒。
|
||||
- 预计写入 1,000 到 2,000 frames/s。
|
||||
- 峰值突发系数 3 倍。
|
||||
- 历史查询目标 1,000 requests/s。
|
||||
|
||||
写路径:
|
||||
|
||||
- Netty worker 保持 CPU-light。
|
||||
- Raw archive 写入使用有界异步 worker。
|
||||
- Kafka producer 开启压缩、批量、幂等,并按 vehicle key 分区。
|
||||
- History writer 按超级表和子表批量写 TDengine。
|
||||
- TDengine 子表按 vehicle key 分区,使单车查询足够便宜。
|
||||
|
||||
读路径:
|
||||
|
||||
- 高频查询必须带 vehicle key 和时间范围。
|
||||
- 无边界跨车查询仅限管理接口,并强制限流和限制返回量。
|
||||
- 实时 latest state 从 Redis 或内存状态读取。
|
||||
- Raw 详情只读取单个 raw object 并按需解析。
|
||||
- 字典和元数据接口使用内存缓存。
|
||||
|
||||
背压:
|
||||
|
||||
- Raw archive worker queue 固定上限。
|
||||
- Kafka publish future 必须被跟踪。
|
||||
- Durability boundary 超时时,durable ACK 失败,channel 关闭或被限流。
|
||||
- History writer lag 通过 Kafka consumer lag 和 TDengine batch latency 观察。
|
||||
|
||||
## 迁移计划
|
||||
|
||||
阶段 1:在现有代码旁边建立新的 fact model。
|
||||
|
||||
- 新增 `ingest-facts`。
|
||||
- 新增 raw archive receipt contract。
|
||||
- 新增 raw frame 和 decoded fact 的 Kafka 序列化。
|
||||
- 为 stable id、vehicle key、raw URI 生成规则加测试。
|
||||
|
||||
阶段 2:强化 raw durability boundary。
|
||||
|
||||
- 重构 GB32960 和 JT808 handler,让 ACK 等待 raw archive 和 raw fact publish。
|
||||
- 保留现有解析器。
|
||||
- 向新 Kafka topic 发 decoded facts。
|
||||
|
||||
阶段 3:构建 TDengine writer。
|
||||
|
||||
- 创建 TDengine schema manager。
|
||||
- 写入 raw frame facts。
|
||||
- 写入 location、realtime、alarm、session facts。
|
||||
- 按 stable id 实现幂等写入。
|
||||
|
||||
阶段 4:构建查询 API。
|
||||
|
||||
- Raw frame 查询。
|
||||
- 单帧回放。
|
||||
- 位置历史查询。
|
||||
- 实时历史查询。
|
||||
- 最新实时状态查询。
|
||||
|
||||
阶段 5:切换并下线旧热路径。
|
||||
|
||||
- GB32960/JT808 生产热历史不再依赖 DuckDB/Parquet。
|
||||
- 历史迁移期间保留 raw replay 兼容。
|
||||
- TDengine 覆盖验证完成后,移除协议特定的临时历史存储。
|
||||
|
||||
## 测试策略
|
||||
|
||||
单元测试:
|
||||
|
||||
- `RawFrameFact` 校验和 vehicle key 派生。
|
||||
- Raw archive 路径生成和 checksum。
|
||||
- GB32960 golden frame 到 decoded facts。
|
||||
- JT808 sample frame 到 decoded facts。
|
||||
- ACK boundary 成功和失败场景。
|
||||
- TDengine SQL 生成。
|
||||
|
||||
集成测试:
|
||||
|
||||
- 使用 mocked Kafka producer 和 fake raw archive 启动 APP。
|
||||
- 发送 GB32960 sample frame,验证 raw fact、decoded fact 和 raw archive receipt。
|
||||
- 发送 JT808 注册、鉴权、位置帧,验证 registration、session、location facts。
|
||||
- 模拟 raw archive 失败,验证不发送成功 ACK。
|
||||
- 模拟 Kafka publish 失败,验证 durable ACK 失败。
|
||||
|
||||
存储测试:
|
||||
|
||||
- TDengine schema 初始化。
|
||||
- 批量写入 raw frames 和 decoded facts。
|
||||
- 按 vehicle key 和时间范围查询。
|
||||
- Cursor 分页正确性。
|
||||
- 按 `rawUri` 单帧回放。
|
||||
|
||||
压测:
|
||||
|
||||
- 持续 30 分钟 2,000 frames/s 写入。
|
||||
- 持续 5 分钟 6,000 frames/s 突发写入。
|
||||
- 1,000 QPS location/realtime 历史查询,p95 延迟受控。
|
||||
- 校验 raw archive receipt、Kafka raw fact、TDengine raw frame row 三者数量无缺口。
|
||||
|
||||
## 运维要求
|
||||
|
||||
指标:
|
||||
|
||||
- TCP 连接数。
|
||||
- Frame 接收速率。
|
||||
- Raw archive 写入延迟和失败数。
|
||||
- Kafka 发布延迟和失败数。
|
||||
- 按协议和 message id 统计 decoder 成功/失败数。
|
||||
- TDengine batch size、延迟和失败数。
|
||||
- 各查询 endpoint 的 QPS 和延迟。
|
||||
- Kafka consumer lag。
|
||||
|
||||
日志:
|
||||
|
||||
- 每个失败帧输出一条结构化日志,包含 `frameId`、protocol、vehicle key、message id、raw URI 和 error。
|
||||
- 正常日志不输出完整 raw bytes。
|
||||
- Raw hex 只允许在显式 debug 工具里输出,并限制大小。
|
||||
|
||||
告警:
|
||||
|
||||
- Raw archive 写入失败。
|
||||
- Durable ACK 超时。
|
||||
- Kafka producer 失败。
|
||||
- History writer lag。
|
||||
- TDengine 写入失败。
|
||||
- Raw archive 数量和 TDengine raw frame row 数量不一致。
|
||||
|
||||
## 验收标准
|
||||
|
||||
重设计只有满足以下条件才算完成:
|
||||
|
||||
1. 每一条被接收的 GB32960 和 JT808 帧都会创建 raw archive object 和 TDengine `raw_frames` 行。
|
||||
2. 有 bytes 的异常帧可以从 raw history 查询到,包含 parse status 和错误原因。
|
||||
3. GB32960 和 JT808 位置历史可以按车辆和时间范围 cursor 分页查询。
|
||||
4. GB32960 实时历史可以按车辆和时间范围查询。
|
||||
5. JT808 注册和会话数据可查询,并且可以更新 identity binding。
|
||||
6. 单帧详情可以通过 `rawUri` 重新读取 raw bytes,并使用当前解析器解码。
|
||||
7. 需要 ACK 的帧,在 raw archive 和 raw fact publish 成功前,不会发送成功 ACK。
|
||||
8. 新协议字段可以通过新增 decoder/projector 扩展;只有成为高频查询字段时才需要 TDengine 表迁移。
|
||||
9. 压测证明可以持续 2,000 frames/s 写入,并支持 1,000 QPS 有边界历史查询。
|
||||
10. GB32960/JT808 生产查询不再依赖旧 DuckDB/Parquet 热历史路径。
|
||||
|
||||
## 已固定决策
|
||||
|
||||
第一版实现固定以下选择:
|
||||
|
||||
- 先使用本地 raw archive,实现接口时预留对象存储。
|
||||
- TDengine 是 GB32960/JT808 唯一生产热历史查询库。
|
||||
- 使用协议明确的查询 API,不做一个泛化万能查询 API。
|
||||
- 高频历史查询统一使用 cursor pagination。
|
||||
- Kafka payload 保持轻量,只传 raw 引用,不传 raw bytes。
|
||||
@@ -1,498 +0,0 @@
|
||||
# Go 车辆接入重构设计规格
|
||||
|
||||
## 目标
|
||||
|
||||
使用 Go 重新设计车辆数据接入运行面,让系统回到第一性原则:
|
||||
|
||||
- 接入层只负责可靠收包、协议解析、应答、身份解析和投递。
|
||||
- Kafka 是唯一在线消息通道。
|
||||
- TDengine 保存高吞吐时序明细和完整 RAW 解析 JSON。
|
||||
- MySQL 保存低频配置、身份映射和每日统计窄表。
|
||||
- Redis 保存可重建的准实时状态。
|
||||
- 32960、JT808、宇通 MQTT 使用同一套数据层接口,避免每个协议各自落库。
|
||||
|
||||
第一阶段只做生产链路的骨架和核心数据闭环:接收、解析、Kafka、TDengine、MySQL 统计、Redis 实时查询、ECS 验证。历史查询 API 和业务侧复杂分析放在后续阶段。
|
||||
|
||||
## 参考原则
|
||||
|
||||
本设计只吸收开源项目的边界思想,不复制外部实现代码。
|
||||
|
||||
- GB32960 参考 `DarkInno/gb32960-go-sdk`:连接管理、Handler、Forwarder、鉴权接口分离。
|
||||
- JT808 参考 `cuteLittleDevil/go-jt808`:高并发 Go 原生 TCP、协议核心小而可扩展、事件/适配器机制。
|
||||
- JT808 参考 `fakeyanss/jt808-server-go`:`FramePayload -> PacketData -> JT808Msg -> Reply` 的分层处理、Gateway 模式、保活和版本兼容。
|
||||
- TDengine 参考官方数据模型:按数据采集点建立 supertable,静态维度做 tags,明细数据按子表写入;Go 连接优先使用 WebSocket 驱动。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 第一阶段不继续扩展 Java 服务。
|
||||
- 第一阶段不做统一大查询 API。
|
||||
- 第一阶段不恢复信达 Push。
|
||||
- 第一阶段不把所有协议字段展开成一张超宽 TDengine 表。
|
||||
- Redis 不作为历史事实源。
|
||||
- MySQL 不保存高频原始明细。
|
||||
|
||||
## 目标目录
|
||||
|
||||
新的 Go 运行面放在 `go/vehicle-gateway`,不继续沿用实验性的多 module 分散结构。现有 `go/ingest-edge` 和 `go/vehicle-state` 可以作为迁移素材,最终归并或删除。
|
||||
|
||||
```text
|
||||
go/vehicle-gateway/
|
||||
cmd/
|
||||
gateway/ # 协议接入进程:32960 TCP、JT808 TCP、宇通 MQTT
|
||||
history-writer/ # Kafka -> TDengine RAW/location/mileage points
|
||||
stat-writer/ # Kafka -> MySQL 每日统计窄表
|
||||
realtime-api/ # Redis 准实时查询 API
|
||||
internal/
|
||||
config/ # 环境变量、配置文件、校验
|
||||
gateway/ # TCP/MQTT 生命周期、连接限制、优雅停机
|
||||
protocol/
|
||||
gb32960/ # 32960 编解码
|
||||
jt808/ # 808 编解码
|
||||
yutongmqtt/ # 宇通 MQTT payload 解析
|
||||
identity/ # VIN、phone、device_id、plate 绑定
|
||||
envelope/ # 统一事件模型
|
||||
eventbus/ # Kafka producer/consumer
|
||||
history/ # TDengine adapter
|
||||
stats/ # 每日里程聚合
|
||||
realtime/ # Redis adapter 与快照合并
|
||||
observability/ # 日志、metrics、健康检查
|
||||
```
|
||||
|
||||
## 协议接入设计
|
||||
|
||||
### GB32960
|
||||
|
||||
职责:
|
||||
|
||||
- 监听 TCP `32960`。
|
||||
- 支持车辆登入、实时信息上报、补发信息上报、车辆登出、平台登入/登出、心跳、校时。
|
||||
- 正确返回协议应答。
|
||||
- 完整解析 2016 数据单元:
|
||||
- 整车数据
|
||||
- 驱动电机数据
|
||||
- 燃料电池数据
|
||||
- 发动机数据
|
||||
- 车辆位置数据
|
||||
- 极值数据
|
||||
- 报警数据
|
||||
- 可充电储能装置电压数据
|
||||
- 可充电储能装置温度数据
|
||||
- RAW 中保存完整 parsed JSON。
|
||||
- 关键字段标准化为统一 field:
|
||||
- `speed_kmh`
|
||||
- `total_mileage_km`
|
||||
- `soc_percent`
|
||||
- `longitude`
|
||||
- `latitude`
|
||||
- `vehicle_status`
|
||||
- `charge_status`
|
||||
|
||||
### JT808
|
||||
|
||||
职责:
|
||||
|
||||
- 监听 TCP `808`。
|
||||
- 支持 2011/2013 基础兼容,预留 2019 版本标记。
|
||||
- 分层处理:
|
||||
- Frame:`0x7e` 包边界、转义、校验。
|
||||
- Packet:消息头、手机号 BCD、流水号、分包。
|
||||
- Message:注册、鉴权、心跳、注销、位置、批量位置、未知上行。
|
||||
- Reply:注册应答、平台通用应答。
|
||||
- 终端手机号按协议头 BCD 解析,内部同时保留原始 BCD、规范化 phone、去前导零 phone。
|
||||
- 注册帧和鉴权帧写入 808 registration 表。
|
||||
- 如果设备直接上报位置帧,也要走 identity 解析,尝试用 phone、device_id、plate 找到 VIN。
|
||||
- 0200 附加信息必须解析表 27:
|
||||
- `0x01` GPS 总里程,DWORD,单位 0.1 km。
|
||||
- 其他已知附加项结构化放入 `parsed_json.additional`。
|
||||
- 标准化字段:
|
||||
- `speed_kmh`
|
||||
- `total_mileage_km`
|
||||
- `longitude`
|
||||
- `latitude`
|
||||
- `altitude_m`
|
||||
- `direction_deg`
|
||||
- `alarm_flag`
|
||||
- `status_flag`
|
||||
|
||||
### 宇通 MQTT
|
||||
|
||||
职责:
|
||||
|
||||
- 使用正式 MQTT 配置订阅生产 topic。
|
||||
- 接收 payload 后解析为统一 envelope。
|
||||
- 按 payload 中的车辆标识解析 VIN、车牌、设备号。
|
||||
- 保存完整 payload 和 parsed JSON。
|
||||
- 标准化字段向 32960/808 对齐:
|
||||
- `speed_kmh`
|
||||
- `total_mileage_km`
|
||||
- `longitude`
|
||||
- `latitude`
|
||||
- `soc_percent`
|
||||
- `vehicle_status`
|
||||
|
||||
## 统一 Envelope
|
||||
|
||||
所有协议接入后输出同一结构。
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "string",
|
||||
"trace_id": "string",
|
||||
"protocol": "GB32960|JT808|YUTONG_MQTT",
|
||||
"message_id": "string",
|
||||
"vin": "string",
|
||||
"vehicle_key": "string",
|
||||
"phone": "string",
|
||||
"device_id": "string",
|
||||
"plate": "string",
|
||||
"source_endpoint": "ip:port or mqtt://broker/topic",
|
||||
"event_time_ms": 0,
|
||||
"received_at_ms": 0,
|
||||
"raw_hex": "string",
|
||||
"raw_text": "string",
|
||||
"parsed": {},
|
||||
"fields": {
|
||||
"speed_kmh": 0,
|
||||
"total_mileage_km": 0,
|
||||
"longitude": 0,
|
||||
"latitude": 0
|
||||
},
|
||||
"parse_status": "OK|PARTIAL|BAD_FRAME",
|
||||
"parse_error": ""
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `event_id` 使用协议、设备标识、消息流水、事件时间、RAW hash 生成,保证幂等。
|
||||
- Kafka partition key 优先使用 VIN;没有 VIN 时使用 `protocol:phone/device_id`。
|
||||
- `parsed` 保存协议完整结构化字段。
|
||||
- `fields` 只保存跨协议核心字段,用于统计、位置和实时快照。
|
||||
|
||||
## Kafka Topic
|
||||
|
||||
生产只支持 Kafka。
|
||||
|
||||
| Topic | 内容 | Key |
|
||||
|---|---|---|
|
||||
| `vehicle.raw.gb32960.v1` | 32960 完整 RAW envelope | VIN 或 vehicle_key |
|
||||
| `vehicle.raw.jt808.v1` | 808 完整 RAW envelope | VIN 或 phone |
|
||||
| `vehicle.raw.yutong-mqtt.v1` | 宇通 MQTT 完整 RAW envelope | VIN 或 vehicle_key |
|
||||
| `vehicle.event.unified.v1` | 统一轻量事件,用于业务消费 | VIN 或 vehicle_key |
|
||||
|
||||
接入层必须先写 RAW topic,再写 unified event。后续消费者只依赖 Kafka,不直接依赖接入进程内存。
|
||||
|
||||
发布可靠性:
|
||||
|
||||
- Kafka 生产端必须开启同步写入,RAW 写成功后才允许写 unified event。
|
||||
- Kafka 写入必须支持可配置重试、单次写超时和短退避,默认值为 `KAFKA_PUBLISH_ATTEMPTS=3`、`KAFKA_PUBLISH_TIMEOUT_MS=3000`、`KAFKA_PUBLISH_BACKOFF_MS=100`。
|
||||
- gateway 支持本地磁盘 spool/WAL,配置 `KAFKA_SPOOL_DIR` 后启用;Kafka 长时间不可用时,发布失败的 envelope 先原子写入本地 JSON 文件。
|
||||
- spool 补发按文件名顺序执行,补发成功后删除文件;默认补发间隔由 `KAFKA_SPOOL_REPLAY_INTERVAL_MS=1000` 控制。
|
||||
- 如果 RAW 写 Kafka 失败并已落盘,同一个 event 的 unified event 不允许抢先写 Kafka,也必须进入 spool,确保恢复后按 RAW -> unified 顺序补发。
|
||||
- Kafka topic 不允许自动创建,topic 和分区数由部署脚本或运维初始化,避免生产拼写错误造成隐性分流。
|
||||
|
||||
## TDengine 数据库设计
|
||||
|
||||
如果现有 TDengine 库不符合目标,可以新建库并迁移。建议库名:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE IF NOT EXISTS lingniu_vehicle_ts KEEP 7300 DURATION 10 BUFFER 256;
|
||||
```
|
||||
|
||||
### raw_frames
|
||||
|
||||
保存完整 RAW 和完整 parsed JSON。
|
||||
|
||||
```sql
|
||||
CREATE STABLE IF NOT EXISTS raw_frames (
|
||||
ts TIMESTAMP,
|
||||
frame_id NCHAR(64),
|
||||
event_id NCHAR(64),
|
||||
message_id INT,
|
||||
event_time TIMESTAMP,
|
||||
received_at TIMESTAMP,
|
||||
raw_size_bytes INT,
|
||||
raw_hex BINARY(16374),
|
||||
raw_text BINARY(16374),
|
||||
parsed_json BINARY(16374),
|
||||
fields_json BINARY(4096),
|
||||
parse_status NCHAR(16),
|
||||
parse_error BINARY(1024),
|
||||
source_endpoint NCHAR(128)
|
||||
) TAGS (
|
||||
protocol NCHAR(32),
|
||||
vehicle_key NCHAR(64),
|
||||
vin NCHAR(32),
|
||||
phone NCHAR(32),
|
||||
device_id NCHAR(64)
|
||||
);
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 子表按 `protocol + vehicle_key hash` 创建。
|
||||
- RAW 查询优先按 tags 和时间范围过滤。
|
||||
- `parsed_json` 是完整协议字段;查询 API 可选择是否返回,避免默认大字段拖慢。
|
||||
|
||||
### vehicle_locations
|
||||
|
||||
只保存位置查询核心字段。
|
||||
|
||||
```sql
|
||||
CREATE STABLE IF NOT EXISTS vehicle_locations (
|
||||
ts TIMESTAMP,
|
||||
event_id NCHAR(64),
|
||||
frame_id NCHAR(64),
|
||||
received_at TIMESTAMP,
|
||||
longitude DOUBLE,
|
||||
latitude DOUBLE,
|
||||
altitude_m DOUBLE,
|
||||
speed_kmh DOUBLE,
|
||||
direction_deg INT,
|
||||
alarm_flag BIGINT,
|
||||
status_flag BIGINT,
|
||||
total_mileage_km DOUBLE
|
||||
) TAGS (
|
||||
protocol NCHAR(32),
|
||||
vehicle_key NCHAR(64),
|
||||
vin NCHAR(32),
|
||||
phone NCHAR(32),
|
||||
device_id NCHAR(64)
|
||||
);
|
||||
```
|
||||
|
||||
### vehicle_mileage_points
|
||||
|
||||
保存总里程采样点,用于回放重算和查询。
|
||||
|
||||
```sql
|
||||
CREATE STABLE IF NOT EXISTS vehicle_mileage_points (
|
||||
ts TIMESTAMP,
|
||||
event_id NCHAR(64),
|
||||
frame_id NCHAR(64),
|
||||
received_at TIMESTAMP,
|
||||
total_mileage_km DOUBLE,
|
||||
speed_kmh DOUBLE,
|
||||
longitude DOUBLE,
|
||||
latitude DOUBLE
|
||||
) TAGS (
|
||||
protocol NCHAR(32),
|
||||
vehicle_key NCHAR(64),
|
||||
vin NCHAR(32),
|
||||
phone NCHAR(32),
|
||||
device_id NCHAR(64)
|
||||
);
|
||||
```
|
||||
|
||||
## MySQL 数据库设计
|
||||
|
||||
MySQL 只保存配置、身份映射、808 注册鉴权和每日统计窄表。
|
||||
|
||||
### vehicle_identity_binding
|
||||
|
||||
用于把外部标识定位到 VIN。
|
||||
|
||||
```sql
|
||||
CREATE TABLE vehicle_identity_binding (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
vin VARCHAR(32) NOT NULL,
|
||||
plate VARCHAR(32) NULL,
|
||||
phone VARCHAR(32) NULL,
|
||||
device_id VARCHAR(64) NULL,
|
||||
remark VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_vin (vin),
|
||||
KEY idx_plate (plate),
|
||||
KEY idx_phone (phone),
|
||||
KEY idx_device_id (device_id)
|
||||
);
|
||||
```
|
||||
|
||||
### jt808_registration
|
||||
|
||||
只记录 808 独有的注册、鉴权和在线身份信息。
|
||||
|
||||
```sql
|
||||
CREATE TABLE jt808_registration (
|
||||
phone VARCHAR(32) PRIMARY KEY,
|
||||
vin VARCHAR(32) NULL,
|
||||
plate VARCHAR(32) NULL,
|
||||
device_id VARCHAR(64) NULL,
|
||||
manufacturer_id VARCHAR(32) NULL,
|
||||
terminal_model VARCHAR(64) NULL,
|
||||
terminal_id VARCHAR(64) NULL,
|
||||
auth_code VARCHAR(128) NULL,
|
||||
source_endpoint VARCHAR(128) NULL,
|
||||
first_registered_at DATETIME NULL,
|
||||
latest_registered_at DATETIME NULL,
|
||||
latest_authed_at DATETIME NULL,
|
||||
latest_seen_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_vin (vin),
|
||||
KEY idx_plate (plate),
|
||||
KEY idx_device_id (device_id)
|
||||
);
|
||||
```
|
||||
|
||||
### vehicle_daily_metric
|
||||
|
||||
32960 和 808 的每日里程、每日总里程都进入同一张窄表。
|
||||
|
||||
```sql
|
||||
CREATE TABLE vehicle_daily_metric (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
vin VARCHAR(32) NOT NULL,
|
||||
stat_date DATE NOT NULL,
|
||||
protocol VARCHAR(32) NOT NULL,
|
||||
metric_key VARCHAR(64) NOT NULL,
|
||||
metric_value DECIMAL(18,3) NOT NULL,
|
||||
metric_unit VARCHAR(16) NOT NULL,
|
||||
first_total_mileage_km DECIMAL(18,3) NULL,
|
||||
latest_total_mileage_km DECIMAL(18,3) NULL,
|
||||
sample_count BIGINT NOT NULL DEFAULT 0,
|
||||
calculation_method VARCHAR(64) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_daily_metric (vin, stat_date, protocol, metric_key),
|
||||
KEY idx_stat_date (stat_date),
|
||||
KEY idx_protocol_metric (protocol, metric_key)
|
||||
);
|
||||
```
|
||||
|
||||
指标:
|
||||
|
||||
- `daily_mileage_km = max(total_mileage_km) - min(total_mileage_km)`
|
||||
- `daily_total_mileage_km = max(total_mileage_km)`
|
||||
|
||||
统计消费者可幂等重放。每个样本 upsert 时只维护当天 `min/max total_mileage_km`,不依赖进程内状态。
|
||||
|
||||
## Redis 实时数据设计
|
||||
|
||||
Redis 保存可从 Kafka 重建的数据。
|
||||
|
||||
| Key | 内容 |
|
||||
|---|---|
|
||||
| `vehicle:online:{vin}` | 在线状态、最后活跃时间、协议列表 |
|
||||
| `vehicle:latest:{vin}` | 合并后的 VIN 最新快照 |
|
||||
| `vehicle:latest:{vin}:{protocol}` | 单协议最新快照 |
|
||||
| `vehicle:last_seen` | VIN 到最后接收时间的 sorted set |
|
||||
|
||||
合并规则:
|
||||
|
||||
- 位置以最新 event_time 为准。
|
||||
- 总里程取最新上报值,不做递增假设。
|
||||
- 32960 多帧字段按 field timestamp 合并。
|
||||
- 808 和 MQTT 不覆盖其他协议独有字段。
|
||||
- Redis TTL 用于在线判断,不作为数据删除依据。
|
||||
|
||||
## 数据流
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
source["车辆 / 平台"] --> gateway["Go gateway"]
|
||||
gateway --> parser["protocol parser"]
|
||||
parser --> identity["identity resolver"]
|
||||
identity --> kafkaRaw["Kafka RAW topics"]
|
||||
identity --> kafkaEvent["Kafka unified event"]
|
||||
kafkaRaw --> history["history-writer"]
|
||||
kafkaRaw --> stats["stat-writer"]
|
||||
kafkaEvent --> realtime["realtime consumer"]
|
||||
history --> td["TDengine raw_frames / locations / mileage_points"]
|
||||
stats --> mysql["MySQL vehicle_daily_metric"]
|
||||
realtime --> redis["Redis latest state"]
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 协议坏帧仍写 RAW topic,`parse_status=BAD_FRAME`。
|
||||
- 可部分解析的帧写 `parse_status=PARTIAL`,保留 parse error。
|
||||
- Kafka 写失败时接入层先按配置重试;重试耗尽后不写 unified event,必须打错误日志和 metrics。
|
||||
- 启用 `KAFKA_SPOOL_DIR` 后,Kafka 重试耗尽会落本地 spool;如果 spool 写失败,才视为接入层最终失败。
|
||||
- TDengine 写失败不提交 Kafka offset。
|
||||
- MySQL 统计写失败不提交 Kafka offset。
|
||||
- Redis 写失败不影响历史和统计,但要通过 metrics 暴露。
|
||||
- 808 相同 phone 新连接时,旧连接应被替换并记录 latest_seen。
|
||||
|
||||
## 部署设计
|
||||
|
||||
生产 ECS 运行 Go 二进制或容器:
|
||||
|
||||
- `vehicle-gateway`
|
||||
- `vehicle-history-writer`
|
||||
- `vehicle-stat-writer`
|
||||
- `vehicle-realtime-api`
|
||||
|
||||
配置来源:
|
||||
|
||||
- 环境变量优先。
|
||||
- Nacos 可作为后续配置中心,但 Go 第一阶段必须能只靠环境变量启动,便于 Portainer 部署和故障恢复。
|
||||
|
||||
TDengine 连接:
|
||||
|
||||
- 优先 WebSocket `taosWS`。
|
||||
- 若当前 ECS 只开放原生端口,可短期兼容 `taosSql`,但代码配置必须显式标记为兼容模式。
|
||||
|
||||
## 验证计划
|
||||
|
||||
### 本地验证
|
||||
|
||||
- 用样例 808 报文验证:
|
||||
- phone BCD 解析。
|
||||
- 0200 经纬度、速度、方向、时间。
|
||||
- 表 27 `0x01` GPS 总里程。
|
||||
- 用真实 32960 报文验证:
|
||||
- 登录应答正确。
|
||||
- 实时帧和补发帧都能写 RAW。
|
||||
- 位置和总里程字段进入统一 fields。
|
||||
- 用宇通 MQTT payload 验证:
|
||||
- payload 完整保存。
|
||||
- 核心字段归一化。
|
||||
- 不连 Kafka 时可输出 JSON log。
|
||||
- 连接 Kafka 时 RAW topic 可消费到 envelope。
|
||||
|
||||
### ECS 验证
|
||||
|
||||
- 部署前先旁路接收或短窗口切流。
|
||||
- 验证端口:
|
||||
- `32960`
|
||||
- `808`
|
||||
- MQTT 订阅连接。
|
||||
- 验证 Kafka topic 有持续写入。
|
||||
- 验证 TDengine:
|
||||
- `raw_frames` 有三种协议数据。
|
||||
- `vehicle_locations` 能按 VIN、协议、时间分页查询。
|
||||
- `vehicle_mileage_points` 有 32960 和 808 总里程采样。
|
||||
- 验证 MySQL:
|
||||
- `vehicle_daily_metric` 有 32960 和 808 的 `daily_mileage_km`、`daily_total_mileage_km`。
|
||||
- 验证 Redis:
|
||||
- 查询 VIN 在线。
|
||||
- 查询 VIN 合并实时快照。
|
||||
- 查询 VIN 分协议实时快照。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- Go gateway 能在 ECS 上接收真实 32960、808、宇通 MQTT 数据。
|
||||
- 三种协议 RAW 都进入 Kafka。
|
||||
- Kafka 短暂写失败时,gateway 按配置重试;单元测试覆盖首次失败后成功和重试耗尽返回错误。
|
||||
- Kafka 长时间不可用时,gateway 可把 RAW/unified 写入本地 spool;单元测试覆盖 RAW 已落盘时 unified 也落盘、恢复后按顺序补发并删除文件。
|
||||
- 三种协议 RAW 和 parsed JSON 都进入 TDengine。
|
||||
- 32960 和 808 的位置进入 `vehicle_locations`。
|
||||
- 32960 和 808 的总里程采样进入 `vehicle_mileage_points`。
|
||||
- 32960 和 808 的每日里程、每日总里程进入 MySQL 窄表。
|
||||
- Redis 可以查询 VIN 在线和实时数据。
|
||||
- 任一消费者宕机后可通过 Kafka offset 继续恢复。
|
||||
- 查询和统计验证使用 ECS 上的真实数据完成。
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. 创建 `go/vehicle-gateway` 单 module,迁移现有 Go 原型中可用的解析、Kafka、TDengine、MySQL、Redis 代码。
|
||||
2. 先写协议解析单元测试,覆盖 808 样例报文和 32960 核心字段。
|
||||
3. 完成统一 envelope 和 Kafka sink。
|
||||
4. 完成 TDengine schema bootstrap 与 history writer。
|
||||
5. 完成 MySQL schema bootstrap 与 stat writer。
|
||||
6. 完成 Redis realtime writer/API。
|
||||
7. 本地启动 gateway,用 ECS 转发流量验证。
|
||||
8. 构建 Linux 镜像,部署 ECS。
|
||||
9. 按验收标准逐项验证并记录证据。
|
||||
@@ -0,0 +1,552 @@
|
||||
# 车辆数据管理中台设计 Spec
|
||||
|
||||
## 目标
|
||||
|
||||
建设一套独立的车辆数据管理中台,放在新项目目录 `vehicle-data-platform`。中台包含前端和后端,前端使用 Semi UI,整体风格高级、简洁、接近大厂数据控制台;后端使用 Go BFF 聚合现有车辆接入链路的数据能力。系统一期目标是让业务和运营人员可以稳定完成查车、看实时状态、查历史、追溯 RAW、看里程和定位数据质量问题,同时给平台运维提供只读链路健康态势。
|
||||
|
||||
## 产品定位更新:一个车辆服务
|
||||
|
||||
中台的第一性原则是“一个车辆服务”。GB32960、JT808、宇通 MQTT 是同一辆车的数据来源证据,不是三个并列的业务产品。所有主页面都应先回答车辆层面的问题,再在需要排障、对账、追溯时下钻到协议来源。
|
||||
|
||||
用户输入 VIN、车牌、手机号或来源标识后,系统需要尽量解析到同一个车辆上下文,并在实时监控、轨迹回放、历史查询、告警事件、统计分析之间保持这个上下文。协议只作为来源标签、可信度解释和 RAW 证据入口出现。
|
||||
|
||||
## 一期定位
|
||||
|
||||
一期优先服务运营、业务和数据质量人员,核心问题是:
|
||||
|
||||
- 车辆是否在线。
|
||||
- 车辆在哪里。
|
||||
- 最新状态是什么。
|
||||
- 最近是否断链。
|
||||
- 每日和区间里程是否可信。
|
||||
- 原始报文和解析字段是否可追溯。
|
||||
- 告警事件是否能追到影响车辆和证据。
|
||||
- VIN、车牌、手机号、OEM 绑定是否正确。
|
||||
|
||||
链路运维能力作为辅助模块内嵌,展示 Gateway、NATS、Kafka、Redis、TDengine、MySQL 和 capacity-check 的关键健康状态,但一期不做复杂监控平台和告警工单闭环。
|
||||
|
||||
## 非目标
|
||||
|
||||
一期不做以下内容:
|
||||
|
||||
- 复杂多租户权限体系。
|
||||
- 自定义大屏编排。
|
||||
- 复杂工单闭环。
|
||||
- 任意字段计算平台。
|
||||
- 完整 BI 报表系统。
|
||||
- 复杂三维轨迹或大屏动画。
|
||||
- 对现有 Go gateway、NATS bridge、history writer、stat writer、realtime API 做破坏性改造。
|
||||
|
||||
## 项目结构
|
||||
|
||||
新建项目目录:
|
||||
|
||||
```text
|
||||
vehicle-data-platform/
|
||||
apps/
|
||||
web/
|
||||
api/
|
||||
docs/
|
||||
product-spec.md
|
||||
api-contract.md
|
||||
deployment.md
|
||||
deploy/
|
||||
systemd/
|
||||
nginx/
|
||||
scripts/
|
||||
```
|
||||
|
||||
### 前端技术栈
|
||||
|
||||
- React
|
||||
- TypeScript
|
||||
- Vite
|
||||
- Semi UI
|
||||
- ECharts
|
||||
- 地图组件,一期可先用轻量地图封装,后续再替换为正式地图供应商
|
||||
|
||||
### 后端技术栈
|
||||
|
||||
- Go
|
||||
- 标准库 HTTP 或轻量路由
|
||||
- MySQL driver
|
||||
- Redis client
|
||||
- TDengine driver
|
||||
- HTTP client 聚合现有 health、metrics、capacity-check 输出
|
||||
|
||||
后端作为 BFF,不让前端直接连接 MySQL、Redis、TDengine 或现有 realtime API。
|
||||
|
||||
## 信息架构
|
||||
|
||||
左侧导航围绕车辆服务组织,一期可以先保留现有 7 个一级模块,但命名和内容要向以下目标靠拢。
|
||||
|
||||
### 1. 运营总览
|
||||
|
||||
路径:`/dashboard`
|
||||
|
||||
能力:
|
||||
|
||||
- 在线车辆数。
|
||||
- 今日活跃车辆。
|
||||
- 今日上报量。
|
||||
- 异常车辆数。
|
||||
- Kafka lag。
|
||||
- 链路健康状态。
|
||||
- 车辆服务状态分布:健康、降级、离线、身份缺失、暂无来源。
|
||||
- 来源覆盖分布:单源车辆、多源车辆、暂无来源车辆。
|
||||
- 最新车辆分布地图小窗。
|
||||
- 最近异常列表:断链、未绑定、里程异常、解析失败、跨来源不一致。
|
||||
|
||||
### 2. 车辆服务
|
||||
|
||||
路径:`/vehicles`
|
||||
|
||||
能力:
|
||||
|
||||
- 表格展示车牌、VIN、手机号、OEM、来源证据、在线来源数、车辆服务状态、最后上报、最近位置、绑定完整度。
|
||||
- 支持 VIN、车牌、手机号、OEM 搜索。
|
||||
- 支持服务状态、来源覆盖、在线状态、绑定完整度筛选。
|
||||
- 支持查看详情、导出。
|
||||
- 支持导入绑定入口的一期展示:提供导入说明和模板下载按钮,不提交数据;正式写入流程不纳入一期验收。
|
||||
|
||||
### 3. 实时监控
|
||||
|
||||
路径:`/realtime`
|
||||
|
||||
能力:
|
||||
|
||||
- 表格视图和地图视图切换。
|
||||
- 展示 VIN、车牌、来源证据、速度、SOC、总里程、最后上报时间、位置。
|
||||
- 地图按车辆服务状态和在线状态分色,协议只用于点位详情里的来源证据。
|
||||
- 支持只看在线车辆、异常车辆、断链车辆和多源不一致车辆。
|
||||
- 点击车辆后打开右侧抽屉展示最新实时字段摘要、来源新鲜度和跳转入口。
|
||||
|
||||
### 4. 车辆详情
|
||||
|
||||
路径:`/vehicles/:vin`
|
||||
|
||||
能力:
|
||||
|
||||
- 顶部身份卡:车牌、VIN、OEM、协议、在线状态。
|
||||
- Tabs:
|
||||
- 最新状态。
|
||||
- 历史位置。
|
||||
- RAW 报文。
|
||||
- 里程统计。
|
||||
- 数据质量。
|
||||
- 多协议对比:同一车辆在 GB32960、JT808、YUTONG_MQTT 下的最新时间、字段完整度、里程差异。
|
||||
|
||||
### 5. 轨迹回放
|
||||
|
||||
路径:`/history`
|
||||
|
||||
能力:
|
||||
|
||||
- 查询条件:VIN/车牌/手机号、时间范围、来源筛选。
|
||||
- 地图轨迹播放,支持起点、终点、当前播放点和速度。
|
||||
- 表格与地图联动:点击点位能看到该点时间、位置、速度、里程和来源。
|
||||
- 可跳转 RAW 证据、统计分析和告警事件。
|
||||
|
||||
### 6. 历史查询
|
||||
|
||||
路径:`/history`
|
||||
|
||||
能力:
|
||||
|
||||
- Tab:历史位置、RAW 报文、解析字段。
|
||||
- 查询条件:VIN、协议、时间范围、消息类型、字段筛选。
|
||||
- RAW 支持分页、字段裁剪、是否包含 payload、JSON 详情查看。
|
||||
- 解析字段使用扁平化字段名,支持选择字段缩小响应体。
|
||||
|
||||
### 7. 告警事件
|
||||
|
||||
路径:`/quality`,后续可独立为 `/alerts`
|
||||
|
||||
能力:
|
||||
|
||||
- 展示来源断链、未绑定、无 VIN、无车牌、无手机号、RAW 解析失败、里程突变、跨来源不一致。
|
||||
- 每条告警必须能跳回车辆详情、轨迹回放、历史 RAW 或统计证据。
|
||||
- 一期支持人工确认和状态展示,通知发送作为后续阶段。
|
||||
|
||||
### 8. 统计分析
|
||||
|
||||
路径:`/mileage`
|
||||
|
||||
能力:
|
||||
|
||||
- 日里程查询。
|
||||
- 区间里程查询。
|
||||
- 协议来源对比。
|
||||
- 异常标记:总里程倒退、突增、缺失、跨协议不一致。
|
||||
- 在线率、数据完整率和来源一致性作为后续统计卡片。
|
||||
|
||||
### 9. 运维质量
|
||||
|
||||
路径:`/quality`
|
||||
|
||||
能力:
|
||||
|
||||
- 断链车辆。
|
||||
- 未绑定车辆。
|
||||
- 无 VIN、无车牌、无手机号车辆。
|
||||
- RAW 解析失败。
|
||||
- capacity-check 结果。
|
||||
- Kafka/NATS/TDengine/Redis/MySQL 关键状态。
|
||||
|
||||
## 高德地图设计
|
||||
|
||||
地图供应商使用高德地图。用户提供了 Web JS 和 API 服务两类 key,系统实现时需要按用途分开:
|
||||
|
||||
- Web JS key 只通过前端运行时配置注入,例如 `window.__LINGNIU_PLATFORM_CONFIG__.amap.webJsKey`,不写死在 TypeScript 源码。
|
||||
- 安全密钥或代理服务地址同样通过运行时配置注入。
|
||||
- API 服务 key 放在后端或 ECS 环境变量中,只用于后端地理编码、行政区划或后续服务端地图能力。
|
||||
- 前端没有拿到可用 key 时,`VehicleMap` 使用坐标预览降级,不阻塞查车、查轨迹和查告警。
|
||||
|
||||
地图一期覆盖三类业务场景:
|
||||
|
||||
- 实时监控:多车点位、在线状态、异常状态、点位详情。
|
||||
- 轨迹回放:折线、起终点、当前播放点、表格联动。
|
||||
- 告警事件:告警车辆定位和影响范围提示。
|
||||
|
||||
## 跨页面车辆上下文
|
||||
|
||||
全局搜索解析到车辆后,需要把 VIN、车牌、手机号、来源和服务状态作为页面上下文:
|
||||
|
||||
- 车辆服务行可以跳转车辆档案。
|
||||
- 实时监控车辆可以跳转轨迹回放、历史查询、统计分析和告警事件。
|
||||
- 轨迹点可以跳转同日里程统计和 RAW 证据。
|
||||
- 里程异常可以跳转对应日期轨迹和 RAW 证据。
|
||||
- 告警事件可以跳转车辆档案、历史证据和统计证据。
|
||||
|
||||
这一点是产品一致性的硬约束。页面之间不应只靠用户手动复制 VIN。
|
||||
|
||||
## 视觉与交互设计
|
||||
|
||||
整体视觉使用 Semi UI 原生组件为基础,避免大屏炫技风。目标是云控制台、数据控制台、飞书后台一类的专业感。
|
||||
|
||||
### 设计原则
|
||||
|
||||
- 灰白背景,深色正文,蓝青强调色。
|
||||
- 红橙只用于异常、告警、风险。
|
||||
- 表格优先,不用卡片堆满页面。
|
||||
- KPI 卡片少而准。
|
||||
- 右侧抽屉承载详情,不频繁跳整页。
|
||||
- RAW 和 parsed JSON 使用代码查看器或结构化 JSON Tree。
|
||||
- 地图是业务辅助,不作为首页背景。
|
||||
- 所有列表页都有稳定筛选区、刷新时间和导出入口。
|
||||
|
||||
### 页面骨架
|
||||
|
||||
- 顶部栏:系统名、全局车辆搜索、环境状态、刷新时间、用户入口。
|
||||
- 左侧栏:7 个一级导航。
|
||||
- 主体区:页面标题、筛选/操作区、数据区。
|
||||
- 右侧抽屉:车辆摘要、RAW 详情、异常解释。
|
||||
|
||||
## 后端 BFF 设计
|
||||
|
||||
后端统一暴露 `/api/*`,并托管前端静态文件。
|
||||
|
||||
### API 响应格式
|
||||
|
||||
成功:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {},
|
||||
"traceId": "string",
|
||||
"timestamp": 1783080000000
|
||||
}
|
||||
```
|
||||
|
||||
分页:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"items": [],
|
||||
"total": 100,
|
||||
"limit": 50,
|
||||
"offset": 0
|
||||
},
|
||||
"traceId": "string",
|
||||
"timestamp": 1783080000000
|
||||
}
|
||||
```
|
||||
|
||||
错误:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "QUERY_FAILED",
|
||||
"message": "查询失败",
|
||||
"detail": "具体错误"
|
||||
},
|
||||
"traceId": "string",
|
||||
"timestamp": 1783080000000
|
||||
}
|
||||
```
|
||||
|
||||
### 后端模块
|
||||
|
||||
#### vehicle
|
||||
|
||||
职责:
|
||||
|
||||
- 车辆台账。
|
||||
- 身份绑定。
|
||||
- VIN、车牌、手机号、OEM 查询。
|
||||
|
||||
数据源:
|
||||
|
||||
- MySQL `vehicle_identity_binding`。
|
||||
- MySQL `vehicle_realtime_snapshot` 和 `vehicle_realtime_location` 用于补充在线状态和最新上报。
|
||||
|
||||
#### realtime
|
||||
|
||||
职责:
|
||||
|
||||
- 实时位置。
|
||||
- 实时快照。
|
||||
- 单车当前状态。
|
||||
- 单车协议 realtime raw 摘要。
|
||||
|
||||
数据源:
|
||||
|
||||
- MySQL `vehicle_realtime_snapshot`。
|
||||
- MySQL `vehicle_realtime_location`。
|
||||
- Redis `vehicle:realtime-raw:{protocol}:{vin}`。
|
||||
- Redis online key 族。
|
||||
|
||||
#### history
|
||||
|
||||
职责:
|
||||
|
||||
- 历史位置。
|
||||
- RAW frame 查询。
|
||||
- RAW 字段过滤。
|
||||
|
||||
数据源:
|
||||
|
||||
- TDengine `vehicle_locations`。
|
||||
- TDengine `raw_frames`。
|
||||
- TDengine `raw_frame_payload_chunks`。
|
||||
|
||||
#### mileage
|
||||
|
||||
职责:
|
||||
|
||||
- 日里程。
|
||||
- 区间里程。
|
||||
- 里程异常解释。
|
||||
|
||||
数据源:
|
||||
|
||||
- MySQL `vehicle_daily_mileage`。
|
||||
|
||||
#### quality
|
||||
|
||||
职责:
|
||||
|
||||
- 无 VIN。
|
||||
- 无车牌。
|
||||
- 无手机号。
|
||||
- 长时间未上报。
|
||||
- 里程突变。
|
||||
- 解析失败。
|
||||
|
||||
数据源:
|
||||
|
||||
- MySQL identity 和 realtime 表。
|
||||
- TDengine raw_frames。
|
||||
- Redis online。
|
||||
|
||||
#### ops
|
||||
|
||||
职责:
|
||||
|
||||
- capacity-check。
|
||||
- 服务 readyz。
|
||||
- Kafka/NATS/Redis/TDengine/MySQL 状态摘要。
|
||||
|
||||
数据源:
|
||||
|
||||
- 现有本机 health endpoint。
|
||||
- 现有 metrics endpoint。
|
||||
- `/opt/lingniu-go-native/current/capacity-check` 输出。
|
||||
|
||||
## 一期 API
|
||||
|
||||
### Dashboard
|
||||
|
||||
- `GET /api/dashboard/summary`
|
||||
|
||||
返回:
|
||||
|
||||
- 车辆在线数。
|
||||
- 今日活跃数。
|
||||
- 今日上报量。
|
||||
- 协议分布。
|
||||
- 异常计数。
|
||||
- 链路健康摘要。
|
||||
|
||||
### Vehicles
|
||||
|
||||
- `GET /api/vehicles`
|
||||
- `GET /api/vehicles/:vin`
|
||||
- `GET /api/vehicles/:vin/realtime`
|
||||
- `GET /api/vehicles/:vin/protocols`
|
||||
|
||||
### Realtime
|
||||
|
||||
- `GET /api/realtime/locations`
|
||||
- `GET /api/realtime/snapshots`
|
||||
- `GET /api/realtime/raw/:protocol/:vin`
|
||||
|
||||
### History
|
||||
|
||||
- `GET /api/history/locations`
|
||||
- `POST /api/history/raw-frames/query`
|
||||
|
||||
RAW query 使用 POST,避免字段过滤数组导致 URL 过长。
|
||||
|
||||
### Mileage
|
||||
|
||||
- `GET /api/mileage/daily`
|
||||
- `GET /api/mileage/range`
|
||||
|
||||
### Quality
|
||||
|
||||
- `GET /api/quality/issues`
|
||||
- `GET /api/quality/unbound-vehicles`
|
||||
- `GET /api/quality/offline-vehicles`
|
||||
|
||||
### Ops
|
||||
|
||||
- `GET /api/ops/health`
|
||||
- `GET /api/ops/capacity`
|
||||
|
||||
## 数据源映射
|
||||
|
||||
### MySQL
|
||||
|
||||
- `vehicle_identity_binding`:车辆身份事实表。
|
||||
- `vehicle_realtime_snapshot`:每协议每 VIN 最新轻量快照。
|
||||
- `vehicle_realtime_location`:每协议每 VIN 最新位置。
|
||||
- `vehicle_daily_mileage`:每日里程统计。
|
||||
- `jt808_registration`:JT808 注册与鉴权辅助信息。
|
||||
|
||||
### Redis
|
||||
|
||||
- `vehicle:realtime-raw:{protocol}:{vin}`:最新完整协议 parsed 状态。
|
||||
- `vehicle:online:*`:在线状态。
|
||||
- `vehicle:last_seen`:最近活跃排序。
|
||||
- `vehicle:rt-kv:*`:扁平化实时字段。
|
||||
|
||||
### TDengine
|
||||
|
||||
- `raw_frames`:RAW 证据层。
|
||||
- `raw_frame_payload_chunks`:超长 payload 分片。
|
||||
- `vehicle_locations`:历史位置。
|
||||
|
||||
## 权限设计
|
||||
|
||||
一期做轻量鉴权:
|
||||
|
||||
- 登录页。
|
||||
- 单一管理员账号或配置文件 token。
|
||||
- 前端请求带 bearer token。
|
||||
- 后端校验 token。
|
||||
|
||||
后续再扩展 RBAC:
|
||||
|
||||
- 业务只读。
|
||||
- 运维只读。
|
||||
- 管理员。
|
||||
|
||||
## 部署设计
|
||||
|
||||
ECS 部署:
|
||||
|
||||
- 后端端口:`20300`。
|
||||
- 后端托管前端静态文件。
|
||||
- `/api/*` 是后端接口。
|
||||
- `/` 和前端路由返回静态入口。
|
||||
- systemd unit:`lingniu-vehicle-platform.service`。
|
||||
|
||||
访问地址:
|
||||
|
||||
```text
|
||||
http://115.29.187.205:20300
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
后端需要:
|
||||
|
||||
- `HTTP_ADDR=:20300`
|
||||
- `MYSQL_DSN`
|
||||
- `REDIS_ADDR`
|
||||
- `REDIS_USERNAME`
|
||||
- `REDIS_PASSWORD`
|
||||
- `REDIS_DB`
|
||||
- `TDENGINE_DSN`
|
||||
- `TDENGINE_DATABASE=lingniu_vehicle_ts`
|
||||
- `CAPACITY_CHECK_BIN=/opt/lingniu-go-native/current/capacity-check`
|
||||
- `AUTH_TOKEN`
|
||||
|
||||
## 验收标准
|
||||
|
||||
一期完成时必须满足:
|
||||
|
||||
1. 新项目目录 `vehicle-data-platform` 存在。
|
||||
2. 前端使用 Semi UI。
|
||||
3. UI 有完整导航、统一布局、统一视觉规范。
|
||||
4. 可以查看车辆台账。
|
||||
5. 可以查询实时位置和实时状态。
|
||||
6. 可以进入单车详情。
|
||||
7. 可以查询历史位置。
|
||||
8. 可以查询 RAW frame,并支持字段裁剪。
|
||||
9. 可以查看日里程和区间里程。
|
||||
10. 可以查看数据质量问题。
|
||||
11. 可以查看链路健康状态。
|
||||
12. 后端 API 返回统一响应结构。
|
||||
13. 前端核心列表、详情和查询来自真实后端 API。
|
||||
14. ECS 部署后可通过 `http://115.29.187.205:20300` 访问。
|
||||
15. 后端 systemd 服务健康。
|
||||
16. 浏览器验证主要页面可访问、筛选可用、详情抽屉可用。
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 后端
|
||||
|
||||
- API handler 单元测试。
|
||||
- query 参数解析测试。
|
||||
- response envelope 测试。
|
||||
- 数据源 repository 测试。
|
||||
- 健康检查测试。
|
||||
|
||||
### 前端
|
||||
|
||||
- TypeScript build。
|
||||
- 页面 smoke test。
|
||||
- API client 测试。
|
||||
- 主要页面空态、加载态、错误态检查。
|
||||
|
||||
### 部署
|
||||
|
||||
- 本地前端 build。
|
||||
- 后端 go test。
|
||||
- ECS systemd 启动。
|
||||
- `/api/ops/health` 返回正常。
|
||||
- 浏览器打开首页、车辆台账、实时监控、历史查询。
|
||||
|
||||
## 后续演进
|
||||
|
||||
- RBAC 权限。
|
||||
- 车辆绑定导入写入流程。
|
||||
- 告警通知。
|
||||
- 轨迹回放。
|
||||
- BI 报表。
|
||||
- 自定义字段分析。
|
||||
- 运维监控大盘。
|
||||
@@ -0,0 +1,161 @@
|
||||
# Multi-Source Mileage Statistics Design
|
||||
|
||||
## Goal
|
||||
|
||||
Vehicle mileage statistics must keep every protocol source that reports data, while still exposing one reliable default result for BI and API consumers. The design separates source facts from elected business results so multi-source JT808, GB32960, and Yutong MQTT data can be audited, corrected, and re-elected without losing raw source evidence.
|
||||
|
||||
## Current Problem
|
||||
|
||||
The current `vehicle_daily_mileage` table stores one row per `vin + stat_date + protocol`. That prevents duplicate counting, but it also forces source election too early. When multiple source platforms report the same VIN, mixing them can create false mileage jumps; selecting only one source can undercount when a reliable alternative exists.
|
||||
|
||||
The observed example was `LA9GG64L7PBAF4001` on `2026-07-08` for JT808. Two source IPs reported different total mileage ranges. The correct model is to retain each source as its own candidate and then elect the recommended candidate for default queries.
|
||||
|
||||
## Tables
|
||||
|
||||
### `vehicle_data_source`
|
||||
|
||||
Stores source platform metadata discovered from incoming data and later maintained by operators.
|
||||
|
||||
Identity:
|
||||
|
||||
- `protocol`
|
||||
- `source_ip`
|
||||
|
||||
Fields:
|
||||
|
||||
- `id`
|
||||
- `protocol`
|
||||
- `source_ip`
|
||||
- `latest_source_endpoint`
|
||||
- `platform_name`
|
||||
- `trust_priority`
|
||||
- `enabled`
|
||||
- `first_seen_at`
|
||||
- `latest_seen_at`
|
||||
- `remark`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
Rules:
|
||||
|
||||
- `protocol + source_ip` is unique.
|
||||
- Ports are not part of source identity because sender ports change often.
|
||||
- Runtime may update `latest_source_endpoint`, `first_seen_at`, and `latest_seen_at`.
|
||||
- Runtime must not overwrite manually maintained `platform_name`, `trust_priority`, `enabled`, or `remark`.
|
||||
|
||||
### `vehicle_daily_mileage_source`
|
||||
|
||||
Stores one daily candidate per source.
|
||||
|
||||
Identity:
|
||||
|
||||
- `vin`
|
||||
- `stat_date`
|
||||
- `protocol`
|
||||
- `source_key`
|
||||
|
||||
Fields:
|
||||
|
||||
- `vin`
|
||||
- `stat_date`
|
||||
- `protocol`
|
||||
- `source_key`
|
||||
- `source_ip`
|
||||
- `source_endpoint`
|
||||
- `phone`
|
||||
- `device_id`
|
||||
- `platform_name`
|
||||
- `first_total_mileage_km`
|
||||
- `latest_total_mileage_km`
|
||||
- `daily_mileage_km`
|
||||
- `sample_count`
|
||||
- `first_event_time`
|
||||
- `latest_event_time`
|
||||
- `quality_status`
|
||||
- `quality_reason`
|
||||
- `is_selected`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
Rules:
|
||||
|
||||
- Candidate rows are facts by source. They should not be deleted just because they are not selected.
|
||||
- `source_key` is normalized as protocol-specific device identity plus source IP. For JT808 that is usually `phone@source_ip`.
|
||||
- A candidate can be marked invalid without being removed.
|
||||
- Candidate statistics never mix different `source_key` values.
|
||||
|
||||
### `vehicle_daily_mileage`
|
||||
|
||||
Keeps the existing default query surface for BI and APIs.
|
||||
|
||||
Rules:
|
||||
|
||||
- One row per `vin + stat_date + protocol`.
|
||||
- The row is a projection from one selected `vehicle_daily_mileage_source` row.
|
||||
- It stores selected source metadata for fast default queries.
|
||||
|
||||
## Election
|
||||
|
||||
The default selected source is chosen from candidate rows. Selection priority:
|
||||
|
||||
1. Source must be enabled in `vehicle_data_source`.
|
||||
2. Prefer lower `trust_priority` when configured.
|
||||
3. Prefer candidates with a valid same-source previous-day baseline.
|
||||
4. Reject impossible deltas, such as negative mileage or mileage above the configured daily maximum.
|
||||
5. Prefer more samples when priority and quality are tied.
|
||||
6. Prefer later `latest_event_time` when still tied.
|
||||
|
||||
If trusted source A is missing on a day, source B can be selected only if B has its own previous-day baseline. The system must not calculate `B today - A yesterday`.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. Gateway/history/realtime parsing continues to emit protocol fields without non-protocol synthetic fields.
|
||||
2. Stats writer extracts protocol-specific total mileage from the existing fields event.
|
||||
3. Stats writer upserts `vehicle_data_source` by `protocol + source_ip`.
|
||||
4. Stats writer upserts `vehicle_daily_mileage_source` by `vin + stat_date + protocol + source_key`.
|
||||
5. Election updates `vehicle_daily_mileage` from candidates.
|
||||
6. History backfill can rebuild candidates from TDengine raw frames and then rebuild final projections.
|
||||
|
||||
## API Behavior
|
||||
|
||||
Default daily mileage API continues to read `vehicle_daily_mileage`.
|
||||
|
||||
Future query extension:
|
||||
|
||||
- `includeCandidates=true` returns all candidates for each selected row.
|
||||
- Candidate rows include `platform_name`, `source_ip`, `source_endpoint`, `quality_status`, and `quality_reason`.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Multiple ports from the same platform source are collapsed by source IP.
|
||||
- A new source with no previous-day baseline is stored as a candidate with `NO_PREVIOUS_BASELINE` and is not selected by default.
|
||||
- A source that reports a total-mileage reset is stored as a candidate with an invalid quality status until a clear policy is applied.
|
||||
- If all candidates are invalid, no final row is projected for that VIN/day/protocol.
|
||||
- Manual platform names and priority changes should allow re-election without reparsing raw data.
|
||||
|
||||
## Performance
|
||||
|
||||
- Real-time writes are small upserts keyed by indexed dimensions.
|
||||
- Default BI/API reads stay fast because they read the final projection table.
|
||||
- Candidate/audit queries are opt-in.
|
||||
- Backfill aggregates by source and date instead of writing every raw frame to MySQL.
|
||||
|
||||
## Testing
|
||||
|
||||
Automated tests should cover:
|
||||
|
||||
- Source IP extraction and endpoint normalization.
|
||||
- Source table upsert preserving manually maintained fields.
|
||||
- Candidate stats never mixing different source keys.
|
||||
- Election choosing the continuous source for `LA9GG64L7PBAF4001`-style data.
|
||||
- Election skipping `B today - A yesterday`.
|
||||
- Backfill rebuilding candidates and final projection.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add schemas and repository helpers.
|
||||
2. Write candidate rows in stats writer and backfill.
|
||||
3. Add election projection into `vehicle_daily_mileage`.
|
||||
4. Rebuild `2026-07-08` JT808 statistics and verify `LA9GG64L7PBAF4001`.
|
||||
5. Deploy to ECS and monitor stat-writer lag and write errors.
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
# Target Architecture
|
||||
|
||||
This document records the production target for `lingniu-vehicle-ingest`.
|
||||
The project should stay small at runtime: protocol apps ingest and publish,
|
||||
history consumes and indexes, analytics derives metrics, and business systems
|
||||
read through explicit APIs or Kafka.
|
||||
|
||||
Only supported message backbone: Kafka.
|
||||
|
||||
## Active Scope
|
||||
|
||||
Active production protocols:
|
||||
|
||||
| Protocol | Runtime app | Event topic | Raw topic |
|
||||
|---|---|---|---|
|
||||
| GB/T 32960 | `gb32960-ingest-app` | `vehicle.event.gb32960.v1` | `vehicle.raw.gb32960.v1` |
|
||||
| JT/T 808 | `jt808-ingest-app` | `vehicle.event.jt808.v1` | `vehicle.raw.jt808.v1` |
|
||||
| Yutong MQTT | `yutong-mqtt-app` | `vehicle.event.mqtt-yutong.v1` | `vehicle.raw.mqtt-yutong.v1` |
|
||||
|
||||
Xinda Push source, Maven profile, deployment bindings, and history consumers
|
||||
are removed. New optimization work should target the active protocols above.
|
||||
|
||||
JSATL12 attachment upload is also outside the default production reactor. Keep
|
||||
it available through the `optional-attachments` profile until an attachment app
|
||||
is explicitly deployed.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep protocol IO, raw archive, history, latest-state, and statistics as
|
||||
separate responsibilities.
|
||||
- Publish normalized Kafka envelopes with VIN as the partition key whenever a
|
||||
VIN is known.
|
||||
- Store complete raw payload metadata and parsed JSON in TDengine `raw_frames`.
|
||||
- Store query-friendly location rows separately from raw payload JSON.
|
||||
- Keep derived statistics in metric tables, not protocol-specific daily tables.
|
||||
- Use Redis only for optional latest-state APIs that can be rebuilt from Kafka.
|
||||
- Keep optional compatibility modules outside the production hot path.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Ingest apps do not write business tables.
|
||||
- Ingest apps do not serve history query APIs.
|
||||
- Redis is not the long-term historical store.
|
||||
- File-based event indexes are not part of the current build surface.
|
||||
- `telemetry_fields` parsing and field-trend APIs are owned by a separate
|
||||
field parsing service. This project stores RAW parsed JSON plus compact
|
||||
location rows and does not expose a telemetry-fields history API.
|
||||
|
||||
## Runtime Responsibilities
|
||||
|
||||
### Protocol Apps
|
||||
|
||||
Apps:
|
||||
|
||||
- `gb32960-ingest-app`
|
||||
- `jt808-ingest-app`
|
||||
- `yutong-mqtt-app`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Accept protocol traffic.
|
||||
- Decode, authenticate, acknowledge, and maintain sessions where the protocol
|
||||
requires it.
|
||||
- Archive raw bytes through `sink-archive` when bytes are available.
|
||||
- Publish parsed event envelopes to Kafka.
|
||||
- Publish raw envelopes to Kafka.
|
||||
- Resolve vehicle identity through the shared MySQL identity binding table.
|
||||
|
||||
Protocol apps must stay independent from TDengine history readers, Redis state
|
||||
repositories, and statistic calculators.
|
||||
|
||||
### Event Contract
|
||||
|
||||
The Kafka sink owns the protobuf envelope and consumer plumbing.
|
||||
Consumers should use `EnvelopeConsumerProcessor` so invalid protobuf, skipped
|
||||
envelopes, and downstream failures are converted to structured results and DLQ
|
||||
records instead of blocking a partition.
|
||||
|
||||
Core identity fields:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `event_id` | Parsed event idempotency key |
|
||||
| `trace_id` | Cross-service trace id |
|
||||
| `vin` | Vehicle id and Kafka partition key |
|
||||
| `source` | Source protocol, for example `GB32960`, `JT808`, `YUTONG_MQTT` |
|
||||
| `event_time_ms` | Device event time |
|
||||
| `ingest_time_ms` | Platform receive time |
|
||||
| `raw_uri` | `archive://...` reference to raw bytes when available |
|
||||
|
||||
Full-field telemetry uses `TelemetrySnapshot` fields with stable keys such as
|
||||
`total_mileage_km`, `speed_kmh`, `longitude`, and `latitude`.
|
||||
|
||||
### History App
|
||||
|
||||
App: `vehicle-history-app`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Consume active protocol event topics.
|
||||
- Consume active protocol raw topics.
|
||||
- Write raw frame rows into TDengine `raw_frames`.
|
||||
- Write compact location rows into TDengine location tables.
|
||||
- Keep `payloadJson.parsed` on raw rows for full raw inspection.
|
||||
- Expose paged history APIs for raw frames and locations.
|
||||
- Keep specialized APIs off by default unless explicitly enabled.
|
||||
|
||||
Production default:
|
||||
|
||||
- `TDENGINE_HISTORY_ENABLED=true` in deployment.
|
||||
- `EVENT_FILE_STORE_ENABLED=false`.
|
||||
|
||||
File-based event indexes have been removed; this app should keep history
|
||||
queries on TDengine and raw replay on `archive://...` references.
|
||||
|
||||
### Analytics App
|
||||
|
||||
App: `vehicle-analytics-app`
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Consume JT808 event envelopes.
|
||||
- Calculate 808 daily mileage from the reported GPS total mileage only:
|
||||
|
||||
```text
|
||||
daily_mileage_km = max_total_mileage_km - min_total_mileage_km
|
||||
calculation_method = JT808_TOTAL_MILEAGE_DIFF
|
||||
```
|
||||
|
||||
- Store the metric in `vehicle_stat_metric` with
|
||||
`metric_key = daily_mileage_km`; the local-day minimum and maximum GPS total
|
||||
mileage used for the subtraction stay on the same metric row as calculation
|
||||
source columns.
|
||||
|
||||
Runtime state: none outside `vehicle_stat_metric`. There is no JT808-specific
|
||||
daily mileage table. Restart recovery reads the same metric row.
|
||||
|
||||
### Latest State
|
||||
|
||||
Module: `vehicle-state-service`
|
||||
|
||||
This module is outside the default production reactor. Build it with
|
||||
`-Poptional-latest-state` when Redis latest-state APIs are explicitly needed.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Consume normalized Kafka envelopes.
|
||||
- Maintain latest state, location, and safety snapshots in Redis.
|
||||
- Serve latest-state APIs for operational screens.
|
||||
|
||||
Redis state must be rebuildable from Kafka replay and should not be used as the
|
||||
source of truth for historical queries.
|
||||
|
||||
## Data Flow
|
||||
|
||||
Default production flow:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
vehicle["Vehicles / Platforms"] --> ingest["Protocol apps"]
|
||||
ingest --> archive["sink-archive<br/>raw bytes"]
|
||||
ingest --> kafka["Kafka<br/>event + raw envelopes"]
|
||||
kafka --> history["vehicle-history-app"]
|
||||
kafka --> analytics["vehicle-analytics-app"]
|
||||
history --> tdengine["TDengine<br/>raw_frames + locations"]
|
||||
analytics --> mysql["MySQL<br/>vehicle_stat_metric"]
|
||||
```
|
||||
|
||||
Optional latest-state flow, enabled only with `-Poptional-latest-state`, consumes
|
||||
the same Kafka envelopes and rebuilds Redis snapshots outside the default
|
||||
production deployment.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- Protocol apps continue accepting traffic when history, analytics, or Redis
|
||||
consumers are unavailable.
|
||||
- Kafka producer failures are handled by sink retry/circuit-breaker behavior and
|
||||
DLQ topics where configured.
|
||||
- History consumers should commit offsets only after TDengine writes succeed.
|
||||
- Analytics daily mileage state can recover from `vehicle_stat_metric` or Kafka
|
||||
replay.
|
||||
- Raw archive failures must be observable, but event publication and history
|
||||
indexing remain separate concerns.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Active production deployment contains GB32960, JT808, Yutong MQTT,
|
||||
vehicle-history, and vehicle-analytics apps.
|
||||
- Xinda Push source and deployment bindings are removed.
|
||||
- JSATL12 is absent from the default Maven reactor and available through
|
||||
`optional-attachments`.
|
||||
- vehicle-state-service is absent from the default Maven reactor and available
|
||||
through `optional-latest-state`.
|
||||
- raw-archive-store prototype has been removed; raw bytes are written through
|
||||
`sink-archive`.
|
||||
- History APIs read from TDengine; file-based event indexes are not maintained.
|
||||
- Raw frame queries can return complete parsed JSON through `payloadJson.parsed`.
|
||||
- Location queries page over compact rows and reference raw records instead of
|
||||
duplicating full raw JSON.
|
||||
- JT808 daily mileage is stored only in `vehicle_stat_metric`.
|
||||
- Runtime state needed for 808 mileage is also in `vehicle_stat_metric`.
|
||||
- Build validation covers the active modules and their composition tests.
|
||||
@@ -1,146 +0,0 @@
|
||||
# Vehicle Telemetry Internal Fields
|
||||
|
||||
This document defines the internal telemetry field model for Lingniu hydrogen
|
||||
vehicle operations. Protocol fields such as GB/T 32960, JT808, MQTT, and vendor
|
||||
extensions must be mapped into these fields before storage, statistics, or
|
||||
business reporting.
|
||||
|
||||
## Design Rules
|
||||
|
||||
- Business statistics use internal fields, not protocol field names.
|
||||
- Every internal field has one stable unit and one business meaning.
|
||||
- Protocol-specific raw values remain traceable through `source_protocol`,
|
||||
`protocol_version`, `metadata`, and raw archive references.
|
||||
- Core operational fields are stored as typed columns or typed payload fields.
|
||||
- Unstable vendor-specific fields can stay in extension metadata until promoted.
|
||||
- Hydrogen leak safety is a first-class critical safety event, not a generic
|
||||
alarm count.
|
||||
|
||||
## Identity And Time
|
||||
|
||||
| Internal field | Type | Unit | Meaning | GB/T 32960 source |
|
||||
|---|---:|---|---|---|
|
||||
| `vin` | string | - | Vehicle VIN and partition key | header VIN |
|
||||
| `event_time` | instant | - | Device collection time | command body timestamp |
|
||||
| `ingest_time` | instant | - | Platform receive time | ingest server clock |
|
||||
| `source_protocol` | enum | - | Source adapter | `GB32960` |
|
||||
| `protocol_version` | string | - | Source protocol version | `V2016` / `V2025` |
|
||||
|
||||
## Real-Time Vehicle State
|
||||
|
||||
| Internal field | Type | Unit | Meaning | GB/T 32960 source |
|
||||
|---|---:|---|---|---|
|
||||
| `vehicle_state` | enum | - | Started, shutdown, other, invalid | vehicle block `vehicleState` |
|
||||
| `charging_state` | enum | - | Charging state | vehicle block `chargingState` |
|
||||
| `running_mode` | enum | - | Electric, hybrid, fuel, other | vehicle block `runningMode` |
|
||||
| `speed_kmh` | double | km/h | Current speed | vehicle block `speedKmh` |
|
||||
| `total_mileage_km` | double | km | Odometer mileage | vehicle block `totalMileageKm` |
|
||||
| `gear_level` | integer | - | Gear value | vehicle block `gearRaw` low 4 bits |
|
||||
| `accelerator_pedal` | double | % | Accelerator pedal opening | V2016 vehicle block |
|
||||
| `brake_pedal` | double | % | Brake pedal opening | V2016 vehicle block |
|
||||
|
||||
## Location
|
||||
|
||||
| Internal field | Type | Unit | Meaning | GB/T 32960 source |
|
||||
|---|---:|---|---|---|
|
||||
| `longitude` | double | deg | Longitude | position block |
|
||||
| `latitude` | double | deg | Latitude | position block |
|
||||
| `altitude_m` | double | m | Altitude when available | extension or other protocol |
|
||||
| `direction_deg` | double | deg | Direction when available | extension or other protocol |
|
||||
| `location_status_raw` | long | - | Raw location status bits | position block `statusFlag` |
|
||||
|
||||
## Battery And Electricity
|
||||
|
||||
| Internal field | Type | Unit | Meaning | GB/T 32960 source |
|
||||
|---|---:|---|---|---|
|
||||
| `battery_soc` | double | % | Battery SOC | vehicle block `socPercent` |
|
||||
| `battery_voltage_v` | double | V | Battery total voltage | vehicle block `totalVoltageV` |
|
||||
| `battery_current_a` | double | A | Battery total current | vehicle block `totalCurrentA` |
|
||||
| `battery_power_kw` | double | kW | Derived instantaneous battery power | `voltage * current / 1000` in statistics |
|
||||
| `battery_temperature_max_c` | integer | C | Max battery temperature | temperature or extreme blocks |
|
||||
| `battery_temperature_min_c` | integer | C | Min battery temperature | temperature or extreme blocks |
|
||||
| `daily_electricity_kwh` | double | kWh | Daily electricity usage | statistics time integral |
|
||||
|
||||
Daily electricity must be computed in the statistics layer from typed telemetry
|
||||
points. Do not accumulate protocol raw values directly.
|
||||
|
||||
## Hydrogen And Fuel Cell
|
||||
|
||||
| Internal field | Type | Unit | Meaning | GB/T 32960 source |
|
||||
|---|---:|---|---|---|
|
||||
| `fc_voltage_v` | double | V | Fuel cell voltage | fuel cell block |
|
||||
| `fc_current_a` | double | A | Fuel cell current | fuel cell block |
|
||||
| `fc_temp_c` | double | C | Fuel cell or hydrogen system temperature | fuel cell block |
|
||||
| `hydrogen_remaining_kg` | double | kg | Remaining hydrogen mass | vendor extension or capacity conversion |
|
||||
| `hydrogen_remaining_percent` | double | % | Remaining hydrogen percent | V2025 fuel cell block |
|
||||
| `hydrogen_high_pressure_mpa` | double | MPa | Hydrogen high pressure | fuel cell block |
|
||||
| `hydrogen_low_pressure_mpa` | double | MPa | Hydrogen low pressure when available | extension or other protocol |
|
||||
| `daily_hydrogen_kg` | double | kg | Daily hydrogen consumed | statistics layer |
|
||||
| `hydrogen_mileage_efficiency` | double | km/kg | Mileage per kg hydrogen | statistics layer |
|
||||
|
||||
Daily hydrogen usage should be calculated from adjacent hydrogen remaining
|
||||
values. A rising value indicates refueling and must not be counted as negative
|
||||
consumption.
|
||||
|
||||
## Hydrogen Tank Safety
|
||||
|
||||
| Internal field | Type | Unit | Meaning | GB/T 32960 source |
|
||||
|---|---:|---|---|---|
|
||||
| `safety_category` | enum | - | `GENERAL`, `TANK_PRESSURE`, `TANK_TEMPERATURE`, `HYDROGEN_LEAK` | alarm bit classification |
|
||||
| `hydrogen_leak_detected` | boolean | - | Whether hydrogen leak is detected | general alarm bit `HYDROGEN_LEAK` |
|
||||
| `hydrogen_leak_level` | enum | - | `NONE`, `WARNING`, `CRITICAL`, `UNKNOWN` | internal rule |
|
||||
| `hydrogen_leak_action_required` | boolean | - | Whether immediate handling is required | internal rule |
|
||||
| `tank_pressure_status` | enum | - | Normal, warning, critical | hydrogen pressure bit or threshold rule |
|
||||
| `tank_temperature_status` | enum | - | Normal, warning, critical | hydrogen temp bit or threshold rule |
|
||||
|
||||
Safety rule:
|
||||
|
||||
```text
|
||||
If HYDROGEN_LEAK is present:
|
||||
alarm.level = CRITICAL
|
||||
safety_category = HYDROGEN_LEAK
|
||||
hydrogen_leak_detected = true
|
||||
hydrogen_leak_level = CRITICAL
|
||||
hydrogen_leak_action_required = true
|
||||
```
|
||||
|
||||
Hydrogen leak overrides otherwise normal vehicle, speed, battery, or hydrogen
|
||||
state. It must be counted independently from generic alarms.
|
||||
|
||||
## Daily Operation Statistics
|
||||
|
||||
Daily statistics are generated by `stat_date + vin`.
|
||||
|
||||
| Internal field | Type | Unit | Meaning |
|
||||
|---|---:|---|---|
|
||||
| `stat_date` | date | - | Statistics date |
|
||||
| `vin` | string | - | Vehicle VIN |
|
||||
| `first_event_time` | instant | - | First valid telemetry time |
|
||||
| `last_event_time` | instant | - | Last valid telemetry time |
|
||||
| `online_minutes` | double | min | Online duration |
|
||||
| `running_minutes` | double | min | Running duration |
|
||||
| `daily_mileage_km` | double | km | Daily mileage |
|
||||
| `daily_electricity_kwh` | double | kWh | Daily electricity usage |
|
||||
| `daily_hydrogen_kg` | double | kg | Daily hydrogen usage |
|
||||
| `hydrogen_added_kg` | double | kg | Refueled hydrogen |
|
||||
| `km_per_kg_hydrogen` | double | km/kg | Hydrogen efficiency |
|
||||
| `kwh_per_100km` | double | kWh/100km | Electricity efficiency |
|
||||
| `alarm_count` | integer | - | Generic alarm count |
|
||||
| `tank_safety_alarm_count` | integer | - | Tank safety alarm count |
|
||||
| `hydrogen_leak_alarm_count` | integer | - | Hydrogen leak alarm count |
|
||||
| `hydrogen_leak_duration_seconds` | long | s | Hydrogen leak duration |
|
||||
| `data_quality_level` | enum | - | Good, partial, bad |
|
||||
|
||||
## Implementation Status
|
||||
|
||||
- `RealtimePayload` already uses internal operational field names for state,
|
||||
location, battery, fuel cell, and hydrogen fields.
|
||||
- `AlarmPayload` carries internal hydrogen safety fields.
|
||||
- `Gb32960EventMapper` maps GB/T 32960 alarm bits into internal safety fields.
|
||||
- `TelemetrySnapshot` publishes full-field internal telemetry values through the
|
||||
Kafka protobuf envelope.
|
||||
- `event-history-service`, `vehicle-state-service`, and `vehicle-stat-service`
|
||||
consume the same full-field telemetry snapshot contract.
|
||||
- `event-history-service` CSV export can flatten selected internal fields with
|
||||
`fields=field_a,field_b`; media archive references are exposed through
|
||||
`rawArchiveUri` by falling back to `MediaMeta.archiveRef`.
|
||||
109
go/vehicle-gateway/cmd/capacity-check/main.go
Normal file
109
go/vehicle-gateway/cmd/capacity-check/main.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/capacity"
|
||||
)
|
||||
|
||||
const defaultEndpoints = "gateway=http://127.0.0.1:20211,history=http://127.0.0.1:20212,stat=http://127.0.0.1:20213,bridge=http://127.0.0.1:20214,realtime=http://127.0.0.1:20200,fast-writer=http://127.0.0.1:20215"
|
||||
|
||||
type endpoint struct {
|
||||
Name string
|
||||
URL string
|
||||
}
|
||||
|
||||
func main() {
|
||||
var rawEndpoints string
|
||||
var timeout time.Duration
|
||||
flag.StringVar(&rawEndpoints, "endpoints", defaultEndpoints, "comma separated name=url endpoints; /metrics is appended when no path is provided")
|
||||
flag.DurationVar(&timeout, "timeout", 2*time.Second, "per endpoint timeout")
|
||||
flag.Parse()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
metricsByService, findings := collectMetrics(ctx, parseEndpoints(rawEndpoints), timeout)
|
||||
report := capacity.Evaluate(metricsByService)
|
||||
report.Findings = append(report.Findings, findings...)
|
||||
if len(report.Findings) > 0 {
|
||||
report.Status = capacity.StatusDegraded
|
||||
}
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
_ = encoder.Encode(report)
|
||||
if report.Status != capacity.StatusOK {
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func parseEndpoints(value string) []endpoint {
|
||||
var endpoints []endpoint
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
name, url, ok := strings.Cut(item, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
url = withMetricsPath(strings.TrimSpace(url))
|
||||
if name == "" || url == "" {
|
||||
continue
|
||||
}
|
||||
endpoints = append(endpoints, endpoint{Name: name, URL: url})
|
||||
}
|
||||
return endpoints
|
||||
}
|
||||
|
||||
func withMetricsPath(value string) string {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return value
|
||||
}
|
||||
if parsed.Path == "" || parsed.Path == "/" {
|
||||
parsed.Path = "/metrics"
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func collectMetrics(ctx context.Context, endpoints []endpoint, timeout time.Duration) (map[string]string, []string) {
|
||||
client := &http.Client{Timeout: timeout}
|
||||
metricsByService := make(map[string]string, len(endpoints))
|
||||
var findings []string
|
||||
for _, ep := range endpoints {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, ep.URL, nil)
|
||||
if err != nil {
|
||||
findings = append(findings, fmt.Sprintf("%s metrics request invalid: %v", ep.Name, err))
|
||||
continue
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
findings = append(findings, fmt.Sprintf("%s metrics fetch failed: %v", ep.Name, err))
|
||||
continue
|
||||
}
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
_ = response.Body.Close()
|
||||
if readErr != nil {
|
||||
findings = append(findings, fmt.Sprintf("%s metrics read failed: %v", ep.Name, readErr))
|
||||
continue
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
findings = append(findings, fmt.Sprintf("%s metrics status %d", ep.Name, response.StatusCode))
|
||||
continue
|
||||
}
|
||||
metricsByService[ep.Name] = string(body)
|
||||
}
|
||||
return metricsByService, findings
|
||||
}
|
||||
31
go/vehicle-gateway/cmd/capacity-check/main_test.go
Normal file
31
go/vehicle-gateway/cmd/capacity-check/main_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseEndpoints(t *testing.T) {
|
||||
endpoints := parseEndpoints("gateway=http://127.0.0.1:20211/metrics, fast=http://127.0.0.1:20215/metrics")
|
||||
|
||||
if len(endpoints) != 2 {
|
||||
t.Fatalf("len = %d, want 2: %#v", len(endpoints), endpoints)
|
||||
}
|
||||
if endpoints[0].Name != "gateway" || endpoints[0].URL != "http://127.0.0.1:20211/metrics" {
|
||||
t.Fatalf("endpoint[0] = %#v", endpoints[0])
|
||||
}
|
||||
if endpoints[1].Name != "fast" || endpoints[1].URL != "http://127.0.0.1:20215/metrics" {
|
||||
t.Fatalf("endpoint[1] = %#v", endpoints[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEndpointsUsesDefaultMetricsURLWhenOnlyBaseURLIsProvided(t *testing.T) {
|
||||
endpoints := parseEndpoints("gateway=http://127.0.0.1:20211")
|
||||
|
||||
if len(endpoints) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(endpoints))
|
||||
}
|
||||
if !strings.HasSuffix(endpoints[0].URL, "/metrics") {
|
||||
t.Fatalf("url = %q, want /metrics suffix", endpoints[0].URL)
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,13 @@ import (
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/eventbus"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/gateway"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/gb32960"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/jt808"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -27,7 +30,8 @@ func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
sink, err := buildSink(ctx, logger)
|
||||
registry := metrics.NewRegistry()
|
||||
sink, err := buildSink(ctx, logger, registry)
|
||||
if err != nil {
|
||||
logger.Error("build sink failed", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -39,6 +43,8 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer closeResolver()
|
||||
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-gateway", nil, registry))
|
||||
publishUnified := envBool("PUBLISH_UNIFIED_ENABLED", false)
|
||||
|
||||
protocols := []gateway.TCPProtocol{
|
||||
{
|
||||
@@ -65,9 +71,11 @@ func main() {
|
||||
Sink: sink,
|
||||
Resolver: resolver,
|
||||
Logger: logger,
|
||||
Metrics: registry,
|
||||
ReadBufferSize: envInt("TCP_READ_BUFFER_BYTES", 64*1024),
|
||||
IdleTimeout: time.Duration(envInt("TCP_IDLE_TIMEOUT_SECONDS", 180)) * time.Second,
|
||||
MaxConnections: envInt("TCP_MAX_CONNECTIONS", 20_000),
|
||||
MaxConnections: envInt("TCP_MAX_CONNECTIONS", 120_000),
|
||||
PublishUnified: publishUnified,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("build tcp server failed", "protocol", protocol.Protocol, "error", err)
|
||||
@@ -100,6 +108,8 @@ func main() {
|
||||
Sink: sink,
|
||||
Resolver: resolver,
|
||||
Logger: logger,
|
||||
Metrics: registry,
|
||||
PublishUnified: publishUnified,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("build yutong mqtt client failed", "error", err)
|
||||
@@ -137,11 +147,50 @@ func buildIdentityResolver(ctx context.Context, logger *slog.Logger) (identity.R
|
||||
return nil, nil, err
|
||||
}
|
||||
table := env("VEHICLE_IDENTITY_TABLE", "vehicle_identity_binding")
|
||||
logger.Info("identity mysql resolver enabled", "table", table)
|
||||
return identity.NewMySQLResolver(db, table), func() { _ = db.Close() }, nil
|
||||
resolver := identity.NewMySQLResolverWithOptions(db, table, identity.MySQLResolverOptions{
|
||||
LocationTouchInterval: time.Duration(envInt("JT808_REGISTRATION_LOCATION_TOUCH_INTERVAL_SECONDS", 600)) * time.Second,
|
||||
LookupCacheTTL: time.Duration(envInt("IDENTITY_LOOKUP_CACHE_TTL_SECONDS", 600)) * time.Second,
|
||||
})
|
||||
if envBool("IDENTITY_MYSQL_ENSURE_SCHEMA", true) {
|
||||
if err := resolver.EnsureSchema(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
logger.Info("identity mysql resolver enabled", "table", table, "lookup_cache_ttl_seconds", envInt("IDENTITY_LOOKUP_CACHE_TTL_SECONDS", 600))
|
||||
return resolver, func() { _ = db.Close() }, nil
|
||||
}
|
||||
|
||||
func buildSink(ctx context.Context, logger *slog.Logger) (eventbus.Sink, error) {
|
||||
func buildSink(ctx context.Context, logger *slog.Logger, registry *metrics.Registry) (eventbus.Sink, error) {
|
||||
if strings.TrimSpace(os.Getenv("NATS_URL")) != "" {
|
||||
sink, err := eventbus.NewNATSSink(natsSinkConfigFromEnv())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out eventbus.Sink = eventbus.NewRetryingSink(sink, eventbus.RetryConfig{
|
||||
Attempts: envInt("NATS_PUBLISH_ATTEMPTS", 3),
|
||||
Backoff: time.Duration(envInt("NATS_PUBLISH_BACKOFF_MS", 100)) * time.Millisecond,
|
||||
AttemptTimeout: time.Duration(envInt("NATS_PUBLISH_TIMEOUT_MS", 3000)) * time.Millisecond,
|
||||
})
|
||||
if envBool("NATS_ASYNC_ENABLED", true) {
|
||||
queueSize := envInt("NATS_ASYNC_QUEUE_SIZE", envInt("KAFKA_ASYNC_QUEUE_SIZE", 100000))
|
||||
workers := envInt("NATS_ASYNC_WORKERS", envInt("KAFKA_ASYNC_WORKERS", 8))
|
||||
timeout := time.Duration(envInt("NATS_ASYNC_PUBLISH_TIMEOUT_MS", envInt("KAFKA_ASYNC_PUBLISH_TIMEOUT_MS", 30000))) * time.Millisecond
|
||||
out = eventbus.NewAsyncSink(out, eventbus.AsyncConfig{
|
||||
QueueSize: queueSize,
|
||||
Workers: workers,
|
||||
OperationTimeout: timeout,
|
||||
Metrics: registry,
|
||||
Name: "nats",
|
||||
OnError: func(err error) {
|
||||
logger.Warn("nats async publish failed", "error", err)
|
||||
},
|
||||
})
|
||||
logger.Info("nats async publish enabled", "queue_size", queueSize, "workers", workers, "publish_timeout_ms", timeout.Milliseconds())
|
||||
}
|
||||
logger.Info("nats jetstream sink enabled", "url", env("NATS_URL", ""), "unified_subject", natsSinkConfigFromEnv().UnifiedSubject, "publish_unified_enabled", envBool("PUBLISH_UNIFIED_ENABLED", false), "publish_fields_enabled", true)
|
||||
return out, nil
|
||||
}
|
||||
brokers := splitCSV(os.Getenv("KAFKA_BROKERS"))
|
||||
if len(brokers) == 0 {
|
||||
logger.Warn("KAFKA_BROKERS is empty; using log sink")
|
||||
@@ -150,11 +199,16 @@ func buildSink(ctx context.Context, logger *slog.Logger) (eventbus.Sink, error)
|
||||
sink, err := eventbus.NewKafkaSink(eventbus.KafkaConfig{
|
||||
Brokers: brokers,
|
||||
RawTopics: map[envelope.Protocol]string{
|
||||
envelope.ProtocolGB32960: env("KAFKA_TOPIC_GB32960_RAW", "vehicle.raw.gb32960.v1"),
|
||||
envelope.ProtocolJT808: env("KAFKA_TOPIC_JT808_RAW", "vehicle.raw.jt808.v1"),
|
||||
envelope.ProtocolYutongMQTT: env("KAFKA_TOPIC_YUTONG_MQTT_RAW", "vehicle.raw.yutong-mqtt.v1"),
|
||||
envelope.ProtocolGB32960: env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960),
|
||||
envelope.ProtocolJT808: env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808),
|
||||
envelope.ProtocolYutongMQTT: env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT),
|
||||
},
|
||||
UnifiedTopic: env("KAFKA_TOPIC_UNIFIED", "vehicle.event.unified.v1"),
|
||||
FieldsTopics: map[envelope.Protocol]string{
|
||||
envelope.ProtocolGB32960: env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960),
|
||||
envelope.ProtocolJT808: env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808),
|
||||
envelope.ProtocolYutongMQTT: env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT),
|
||||
},
|
||||
UnifiedTopic: env("KAFKA_TOPIC_UNIFIED", topics.Unified),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -166,17 +220,52 @@ func buildSink(ctx context.Context, logger *slog.Logger) (eventbus.Sink, error)
|
||||
})
|
||||
spoolDir := strings.TrimSpace(os.Getenv("KAFKA_SPOOL_DIR"))
|
||||
if spoolDir != "" {
|
||||
durable := eventbus.NewDurableSink(out, eventbus.DurableConfig{Directory: spoolDir})
|
||||
replayBatchSize := envInt("KAFKA_SPOOL_REPLAY_BATCH_SIZE", 200)
|
||||
durable := eventbus.NewDurableSink(out, eventbus.DurableConfig{Directory: spoolDir, ReplayBatchSize: replayBatchSize})
|
||||
interval := time.Duration(envInt("KAFKA_SPOOL_REPLAY_INTERVAL_MS", 1000)) * time.Millisecond
|
||||
go durable.ReplayLoop(ctx, interval, func(err error) {
|
||||
logger.Warn("kafka spool replay failed", "error", err)
|
||||
})
|
||||
logger.Info("kafka durable spool enabled", "dir", spoolDir, "replay_interval_ms", interval.Milliseconds())
|
||||
logger.Info("kafka durable spool enabled", "dir", spoolDir, "replay_interval_ms", interval.Milliseconds(), "replay_batch_size", replayBatchSize)
|
||||
out = durable
|
||||
}
|
||||
if envBool("KAFKA_ASYNC_ENABLED", true) {
|
||||
queueSize := envInt("KAFKA_ASYNC_QUEUE_SIZE", 100000)
|
||||
workers := envInt("KAFKA_ASYNC_WORKERS", 8)
|
||||
timeout := time.Duration(envInt("KAFKA_ASYNC_PUBLISH_TIMEOUT_MS", 30000)) * time.Millisecond
|
||||
out = eventbus.NewAsyncSink(out, eventbus.AsyncConfig{
|
||||
QueueSize: queueSize,
|
||||
Workers: workers,
|
||||
OperationTimeout: timeout,
|
||||
Metrics: registry,
|
||||
Name: "kafka",
|
||||
OnError: func(err error) {
|
||||
logger.Warn("kafka async publish failed", "error", err)
|
||||
},
|
||||
})
|
||||
logger.Info("kafka async publish enabled", "queue_size", queueSize, "workers", workers, "publish_timeout_ms", timeout.Milliseconds())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func natsSinkConfigFromEnv() eventbus.NATSConfig {
|
||||
return eventbus.NATSConfig{
|
||||
URL: env("NATS_URL", ""),
|
||||
Name: env("NATS_CLIENT_NAME", "lingniu-vehicle-gateway"),
|
||||
RawSubjects: map[envelope.Protocol]string{
|
||||
envelope.ProtocolGB32960: env("NATS_SUBJECT_GB32960_RAW", env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960)),
|
||||
envelope.ProtocolJT808: env("NATS_SUBJECT_JT808_RAW", env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808)),
|
||||
envelope.ProtocolYutongMQTT: env("NATS_SUBJECT_YUTONG_MQTT_RAW", env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT)),
|
||||
},
|
||||
FieldsSubjects: map[envelope.Protocol]string{
|
||||
envelope.ProtocolGB32960: env("NATS_SUBJECT_GB32960_FIELDS", env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960)),
|
||||
envelope.ProtocolJT808: env("NATS_SUBJECT_JT808_FIELDS", env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808)),
|
||||
envelope.ProtocolYutongMQTT: env("NATS_SUBJECT_YUTONG_MQTT_FIELDS", env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT)),
|
||||
},
|
||||
UnifiedSubject: env("NATS_SUBJECT_UNIFIED", env("KAFKA_TOPIC_UNIFIED", topics.Unified)),
|
||||
}
|
||||
}
|
||||
|
||||
func env(key string, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
|
||||
93
go/vehicle-gateway/cmd/gateway/main_test.go
Normal file
93
go/vehicle-gateway/cmd/gateway/main_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
func TestNATSSinkConfigFromEnvUsesExplicitSubjects(t *testing.T) {
|
||||
t.Setenv("NATS_URL", "nats://172.17.111.56:4222")
|
||||
t.Setenv("NATS_SUBJECT_GB32960_RAW", "custom.raw.gb32960")
|
||||
t.Setenv("NATS_SUBJECT_JT808_RAW", "custom.raw.jt808")
|
||||
t.Setenv("NATS_SUBJECT_YUTONG_MQTT_RAW", "custom.raw.yutong")
|
||||
t.Setenv("NATS_SUBJECT_UNIFIED", "custom.unified")
|
||||
|
||||
cfg := natsSinkConfigFromEnv()
|
||||
|
||||
if got, want := cfg.URL, "nats://172.17.111.56:4222"; got != want {
|
||||
t.Fatalf("URL = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.RawSubjects[envelope.ProtocolGB32960], "custom.raw.gb32960"; got != want {
|
||||
t.Fatalf("gb32960 subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.RawSubjects[envelope.ProtocolJT808], "custom.raw.jt808"; got != want {
|
||||
t.Fatalf("jt808 subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.RawSubjects[envelope.ProtocolYutongMQTT], "custom.raw.yutong"; got != want {
|
||||
t.Fatalf("yutong subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.UnifiedSubject, "custom.unified"; got != want {
|
||||
t.Fatalf("unified subject = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayConfiguresIdentityLookupCacheTTL(t *testing.T) {
|
||||
source, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read main.go: %v", err)
|
||||
}
|
||||
for _, want := range []string{"LookupCacheTTL", "IDENTITY_LOOKUP_CACHE_TTL_SECONDS"} {
|
||||
if !strings.Contains(string(source), want) {
|
||||
t.Fatalf("gateway should expose identity lookup cache ttl, missing %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayDefaultsTo100KConnectionCeiling(t *testing.T) {
|
||||
source, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read main.go: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(source), `envInt("TCP_MAX_CONNECTIONS", 120_000)`) {
|
||||
t.Fatal("gateway should default TCP_MAX_CONNECTIONS to a 100K-ready ceiling")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayPassesMetricsRegistryToAsyncSink(t *testing.T) {
|
||||
source, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read main.go: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"buildSink(ctx, logger, registry)",
|
||||
"Metrics: registry",
|
||||
`Name: "nats"`,
|
||||
`Name: "kafka"`,
|
||||
} {
|
||||
if !strings.Contains(string(source), want) {
|
||||
t.Fatalf("gateway async sink metrics wiring missing %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNATSSinkConfigFromEnvDefaultsToGoSubjects(t *testing.T) {
|
||||
t.Setenv("NATS_URL", "nats://172.17.111.56:4222")
|
||||
|
||||
cfg := natsSinkConfigFromEnv()
|
||||
|
||||
if got, want := cfg.RawSubjects[envelope.ProtocolGB32960], "vehicle.raw.go.gb32960.v1"; got != want {
|
||||
t.Fatalf("gb32960 subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.RawSubjects[envelope.ProtocolJT808], "vehicle.raw.go.jt808.v1"; got != want {
|
||||
t.Fatalf("jt808 subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.RawSubjects[envelope.ProtocolYutongMQTT], "vehicle.raw.go.yutong-mqtt.v1"; got != want {
|
||||
t.Fatalf("yutong subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.UnifiedSubject, "vehicle.event.go.unified.v1"; got != want {
|
||||
t.Fatalf("unified subject = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,20 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/segmentio/kafka-go"
|
||||
_ "github.com/taosdata/driver-go/v3/taosWS"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/history"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -33,6 +38,10 @@ func main() {
|
||||
logger.Error("tdengine ping failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-history-writer", []health.Check{
|
||||
{Name: "tdengine", Check: db.PingContext},
|
||||
}, registry))
|
||||
|
||||
writer := history.NewWriter(db)
|
||||
if cfg.EnsureSchema {
|
||||
@@ -54,7 +63,9 @@ func main() {
|
||||
logger.Info("history writer started",
|
||||
"driver", cfg.TDengineDriver,
|
||||
"group", cfg.KafkaGroup,
|
||||
"topics", strings.Join(cfg.KafkaTopics, ","))
|
||||
"topics", strings.Join(cfg.KafkaTopics, ","),
|
||||
"batch_size", cfg.BatchSize,
|
||||
"batch_wait_ms", cfg.BatchWait)
|
||||
|
||||
for {
|
||||
message, err := reader.FetchMessage(ctx)
|
||||
@@ -65,20 +76,175 @@ func main() {
|
||||
logger.Error("kafka fetch failed", "error", err)
|
||||
continue
|
||||
}
|
||||
batch := collectHistoryBatch(ctx, reader, message, cfg.BatchSize, time.Duration(cfg.BatchWait)*time.Millisecond)
|
||||
processHistoryBatch(ctx, logger, registry, writer, reader, batch)
|
||||
}
|
||||
}
|
||||
|
||||
const kafkaMessageOperationTimeout = 30 * time.Second
|
||||
|
||||
type historyAppender interface {
|
||||
AppendAll(context.Context, envelope.FrameEnvelope) error
|
||||
AppendAllBatch(context.Context, []envelope.FrameEnvelope) error
|
||||
}
|
||||
|
||||
type kafkaMessageCommitter interface {
|
||||
CommitMessages(context.Context, ...kafka.Message) error
|
||||
}
|
||||
|
||||
type kafkaMessageFetcher interface {
|
||||
FetchMessage(context.Context) (kafka.Message, error)
|
||||
}
|
||||
|
||||
func collectHistoryBatch(ctx context.Context, fetcher kafkaMessageFetcher, first kafka.Message, maxSize int, maxWait time.Duration) []kafka.Message {
|
||||
if maxSize <= 1 {
|
||||
return []kafka.Message{first}
|
||||
}
|
||||
if maxWait <= 0 {
|
||||
maxWait = 100 * time.Millisecond
|
||||
}
|
||||
batch := []kafka.Message{first}
|
||||
deadline := time.Now().Add(maxWait)
|
||||
for len(batch) < maxSize {
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, remaining)
|
||||
message, err := fetcher.FetchMessage(fetchCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
batch = append(batch, message)
|
||||
}
|
||||
return batch
|
||||
}
|
||||
|
||||
func processHistoryMessage(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender historyAppender, committer kafkaMessageCommitter, message kafka.Message) {
|
||||
messageCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), kafkaMessageOperationTimeout)
|
||||
defer cancel()
|
||||
|
||||
addWriterMetric(registry, "vehicle_history_kafka_messages_total", message, "received")
|
||||
addWriterLagMetric(registry, message)
|
||||
var env envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(message.Value, &env); err != nil {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_messages_total", message, "invalid_json")
|
||||
logger.Warn("skip invalid envelope json", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
_ = reader.CommitMessages(ctx, message)
|
||||
continue
|
||||
_ = committer.CommitMessages(messageCtx, message)
|
||||
return
|
||||
}
|
||||
if err := writer.AppendAll(ctx, env); err != nil {
|
||||
if err := appender.AppendAll(messageCtx, env); err != nil {
|
||||
addWriterMetric(registry, "vehicle_history_writes_total", message, "error")
|
||||
logger.Error("tdengine append failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err)
|
||||
return
|
||||
}
|
||||
addWriterMetric(registry, "vehicle_history_writes_total", message, "ok")
|
||||
if err := committer.CommitMessages(messageCtx, message); err != nil {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "error")
|
||||
logger.Error("kafka commit failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
return
|
||||
}
|
||||
addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "ok")
|
||||
}
|
||||
|
||||
func processHistoryBatch(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender historyAppender, committer kafkaMessageCommitter, messages []kafka.Message) {
|
||||
if len(messages) == 0 {
|
||||
return
|
||||
}
|
||||
messageCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), kafkaMessageOperationTimeout)
|
||||
defer cancel()
|
||||
|
||||
envelopes := make([]envelope.FrameEnvelope, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_messages_total", message, "received")
|
||||
addWriterLagMetric(registry, message)
|
||||
var env envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(message.Value, &env); err != nil {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_messages_total", message, "invalid_json")
|
||||
logger.Warn("skip invalid envelope json", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
continue
|
||||
}
|
||||
if err := reader.CommitMessages(ctx, message); err != nil {
|
||||
logger.Error("kafka commit failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
envelopes = append(envelopes, env)
|
||||
}
|
||||
if len(envelopes) > 0 {
|
||||
setBatchPending(registry, len(messages), len(envelopes))
|
||||
defer setBatchPending(registry, 0, 0)
|
||||
started := time.Now()
|
||||
err := appender.AppendAllBatch(messageCtx, envelopes)
|
||||
elapsed := time.Since(started)
|
||||
status := "ok"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
}
|
||||
addBatchMetric(registry, "vehicle_history_batch_flush_total", status, 1)
|
||||
addBatchMetric(registry, "vehicle_history_batch_rows_total", status, float64(len(envelopes)))
|
||||
setBatchDuration(registry, status, elapsed)
|
||||
if err != nil {
|
||||
for _, message := range messages {
|
||||
addWriterMetric(registry, "vehicle_history_writes_total", message, "error")
|
||||
}
|
||||
first := messages[0]
|
||||
logger.Error("tdengine batch append failed", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "rows", len(envelopes), "error", err)
|
||||
return
|
||||
}
|
||||
for _, message := range messages {
|
||||
addWriterMetric(registry, "vehicle_history_writes_total", message, "ok")
|
||||
}
|
||||
}
|
||||
if err := committer.CommitMessages(messageCtx, messages...); err != nil {
|
||||
for _, message := range messages {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "error")
|
||||
}
|
||||
first := messages[0]
|
||||
logger.Error("kafka batch commit failed", "topic", first.Topic, "partition", first.Partition, "offset", first.Offset, "messages", len(messages), "error", err)
|
||||
return
|
||||
}
|
||||
for _, message := range messages {
|
||||
addWriterMetric(registry, "vehicle_history_kafka_commits_total", message, "ok")
|
||||
}
|
||||
}
|
||||
|
||||
func addWriterMetric(registry *metrics.Registry, name string, message kafka.Message, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.IncCounter(name, metrics.Labels{"topic": message.Topic, "status": status})
|
||||
}
|
||||
|
||||
func addBatchMetric(registry *metrics.Registry, name string, status string, value float64) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.AddCounter(name, metrics.Labels{"status": status}, value)
|
||||
}
|
||||
|
||||
func setBatchDuration(registry *metrics.Registry, status string, elapsed time.Duration) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetGauge("vehicle_history_batch_flush_duration_ms", metrics.Labels{"status": status}, float64(elapsed.Milliseconds()))
|
||||
}
|
||||
|
||||
func setBatchPending(registry *metrics.Registry, messages int, rows int) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetGauge("vehicle_history_batch_pending_messages", nil, float64(messages))
|
||||
registry.SetGauge("vehicle_history_batch_pending_rows", nil, float64(rows))
|
||||
}
|
||||
|
||||
func addWriterLagMetric(registry *metrics.Registry, message kafka.Message) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetKafkaLag("vehicle_history_kafka_lag", message.Topic, message.Partition, message.Offset, message.HighWaterMark)
|
||||
}
|
||||
|
||||
type config struct {
|
||||
@@ -89,17 +255,21 @@ type config struct {
|
||||
TDengineDSN string
|
||||
TDengineDatabase string
|
||||
EnsureSchema bool
|
||||
BatchSize int
|
||||
BatchWait int
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
return config{
|
||||
KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")),
|
||||
KafkaTopics: splitCSV(env("KAFKA_TOPICS", "vehicle.raw.gb32960.v1,vehicle.raw.jt808.v1,vehicle.raw.yutong-mqtt.v1")),
|
||||
KafkaTopics: splitCSV(env("KAFKA_TOPICS", strings.Join([]string{topics.RawGB32960, topics.RawJT808, topics.RawYutongMQTT}, ","))),
|
||||
KafkaGroup: env("KAFKA_GROUP", "go-history-writer"),
|
||||
TDengineDriver: env("TDENGINE_DRIVER", "taosWS"),
|
||||
TDengineDSN: env("TDENGINE_DSN", ""),
|
||||
TDengineDatabase: env("TDENGINE_DATABASE", history.DefaultDatabase),
|
||||
EnsureSchema: env("TDENGINE_ENSURE_SCHEMA", "true") != "false",
|
||||
BatchSize: envInt("HISTORY_BATCH_SIZE", 200),
|
||||
BatchWait: envInt("HISTORY_BATCH_WAIT_MS", 100),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +281,18 @@ func env(key string, fallback string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
var out []string
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
|
||||
289
go/vehicle-gateway/cmd/history-writer/main_test.go
Normal file
289
go/vehicle-gateway/cmd/history-writer/main_test.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/segmentio/kafka-go"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
)
|
||||
|
||||
func TestProcessHistoryMessageUsesUncancelledContextForAppendAndCommit(t *testing.T) {
|
||||
parent, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
env := envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
MessageID: "0x0200",
|
||||
Phone: "13307795425",
|
||||
EventTimeMS: 1782918600000,
|
||||
ReceivedAtMS: 1782918600000,
|
||||
ParseStatus: envelope.ParseOK,
|
||||
}
|
||||
payload, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appender := &contextCheckingHistoryAppender{}
|
||||
committer := &contextCheckingHistoryCommitter{}
|
||||
|
||||
processHistoryMessage(parent, discardHistoryLogger{}, nil, appender, committer, kafka.Message{Value: payload})
|
||||
|
||||
if appender.ctxErr != nil {
|
||||
t.Fatalf("appender saw cancelled context: %v", appender.ctxErr)
|
||||
}
|
||||
if committer.ctxErr != nil {
|
||||
t.Fatalf("committer saw cancelled context: %v", committer.ctxErr)
|
||||
}
|
||||
if appender.count != 1 || committer.count != 1 {
|
||||
t.Fatalf("appends=%d commits=%d", appender.count, committer.count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessHistoryMessageRecordsMetrics(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", MessageID: "0x01"}
|
||||
payload, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
|
||||
processHistoryMessage(
|
||||
context.Background(),
|
||||
discardHistoryLogger{},
|
||||
registry,
|
||||
&contextCheckingHistoryAppender{},
|
||||
&contextCheckingHistoryCommitter{},
|
||||
kafka.Message{Topic: "vehicle.raw.gb32960.v1", Partition: 1, Offset: 20, HighWaterMark: 23, Value: payload},
|
||||
)
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_history_kafka_messages_total{status="received",topic="vehicle.raw.gb32960.v1"} 1`,
|
||||
`vehicle_history_writes_total{status="ok",topic="vehicle.raw.gb32960.v1"} 1`,
|
||||
`vehicle_history_kafka_commits_total{status="ok",topic="vehicle.raw.gb32960.v1"} 1`,
|
||||
`vehicle_history_kafka_lag{partition="1",topic="vehicle.raw.gb32960.v1"} 2`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("metrics missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessHistoryBatchAppendsAllBeforeCommit(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", MessageID: "0x02"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
|
||||
firstPayload, err := json.Marshal(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := json.Marshal(second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appender := &contextCheckingHistoryAppender{}
|
||||
committer := &contextCheckingHistoryCommitter{}
|
||||
registry := metrics.NewRegistry()
|
||||
|
||||
processHistoryBatch(
|
||||
context.Background(),
|
||||
discardHistoryLogger{},
|
||||
registry,
|
||||
appender,
|
||||
committer,
|
||||
[]kafka.Message{
|
||||
{Topic: "vehicle.raw.go.gb32960.v1", Partition: 1, Offset: 10, HighWaterMark: 13, Value: firstPayload},
|
||||
{Topic: "vehicle.raw.go.jt808.v1", Partition: 2, Offset: 20, HighWaterMark: 21, Value: secondPayload},
|
||||
},
|
||||
)
|
||||
|
||||
if appender.batchCount != 1 {
|
||||
t.Fatalf("batch appends = %d, want 1", appender.batchCount)
|
||||
}
|
||||
if got := len(appender.batch); got != 2 {
|
||||
t.Fatalf("batch size = %d, want 2", got)
|
||||
}
|
||||
if committer.count != 1 {
|
||||
t.Fatalf("commit calls = %d, want 1", committer.count)
|
||||
}
|
||||
if committer.messageCount != 2 {
|
||||
t.Fatalf("committed messages = %d, want 2", committer.messageCount)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_history_batch_rows_total{status="ok"} 2`,
|
||||
`vehicle_history_batch_flush_total{status="ok"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("batch metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessHistoryBatchExposesPendingBatchDepthDuringAppend(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", MessageID: "0x02"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
|
||||
firstPayload, err := json.Marshal(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := json.Marshal(second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
appender := &contextCheckingHistoryAppender{
|
||||
onAppendBatch: func() {
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_history_batch_pending_messages 2`,
|
||||
`vehicle_history_batch_pending_rows 2`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("pending batch metric missing %s while appending:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
processHistoryBatch(
|
||||
context.Background(),
|
||||
discardHistoryLogger{},
|
||||
registry,
|
||||
appender,
|
||||
&contextCheckingHistoryCommitter{},
|
||||
[]kafka.Message{
|
||||
{Topic: "vehicle.raw.go.gb32960.v1", Partition: 1, Offset: 10, HighWaterMark: 13, Value: firstPayload},
|
||||
{Topic: "vehicle.raw.go.jt808.v1", Partition: 2, Offset: 20, HighWaterMark: 21, Value: secondPayload},
|
||||
},
|
||||
)
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_history_batch_pending_messages 0`,
|
||||
`vehicle_history_batch_pending_rows 0`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("pending batch metric should reset after append, missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessHistoryBatchDoesNotCommitWhenAppendFails(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", MessageID: "0x02"}
|
||||
payload, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appender := &contextCheckingHistoryAppender{err: errTestHistoryAppend}
|
||||
committer := &contextCheckingHistoryCommitter{}
|
||||
registry := metrics.NewRegistry()
|
||||
|
||||
processHistoryBatch(
|
||||
context.Background(),
|
||||
discardHistoryLogger{},
|
||||
registry,
|
||||
appender,
|
||||
committer,
|
||||
[]kafka.Message{{Topic: "vehicle.raw.go.gb32960.v1", Partition: 1, Offset: 10, HighWaterMark: 11, Value: payload}},
|
||||
)
|
||||
|
||||
if committer.count != 0 {
|
||||
t.Fatalf("commit calls = %d, want 0", committer.count)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_history_batch_rows_total{status="error"} 1`,
|
||||
`vehicle_history_batch_flush_total{status="error"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("batch error metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsToGoRawTopics(t *testing.T) {
|
||||
cfg := loadConfig()
|
||||
|
||||
got := strings.Join(cfg.KafkaTopics, ",")
|
||||
want := "vehicle.raw.go.gb32960.v1,vehicle.raw.go.jt808.v1,vehicle.raw.go.yutong-mqtt.v1"
|
||||
if got != want {
|
||||
t.Fatalf("KafkaTopics = %q, want %q", got, want)
|
||||
}
|
||||
if cfg.BatchSize != 200 {
|
||||
t.Fatalf("BatchSize = %d, want 200", cfg.BatchSize)
|
||||
}
|
||||
if cfg.BatchWait != 100 {
|
||||
t.Fatalf("BatchWait = %d, want 100", cfg.BatchWait)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigReadsBatchSettings(t *testing.T) {
|
||||
t.Setenv("HISTORY_BATCH_SIZE", "500")
|
||||
t.Setenv("HISTORY_BATCH_WAIT_MS", "250")
|
||||
|
||||
cfg := loadConfig()
|
||||
|
||||
if cfg.BatchSize != 500 {
|
||||
t.Fatalf("BatchSize = %d, want 500", cfg.BatchSize)
|
||||
}
|
||||
if cfg.BatchWait != 250 {
|
||||
t.Fatalf("BatchWait = %d, want 250", cfg.BatchWait)
|
||||
}
|
||||
}
|
||||
|
||||
type contextCheckingHistoryAppender struct {
|
||||
ctxErr error
|
||||
count int
|
||||
batchCount int
|
||||
batch []envelope.FrameEnvelope
|
||||
err error
|
||||
onAppendBatch func()
|
||||
}
|
||||
|
||||
func (a *contextCheckingHistoryAppender) AppendAll(ctx context.Context, _ envelope.FrameEnvelope) error {
|
||||
a.ctxErr = ctx.Err()
|
||||
a.count++
|
||||
if a.ctxErr != nil {
|
||||
return a.ctxErr
|
||||
}
|
||||
return a.err
|
||||
}
|
||||
|
||||
func (a *contextCheckingHistoryAppender) AppendAllBatch(ctx context.Context, envs []envelope.FrameEnvelope) error {
|
||||
a.ctxErr = ctx.Err()
|
||||
a.batchCount++
|
||||
a.batch = append([]envelope.FrameEnvelope(nil), envs...)
|
||||
if a.onAppendBatch != nil {
|
||||
a.onAppendBatch()
|
||||
}
|
||||
if a.ctxErr != nil {
|
||||
return a.ctxErr
|
||||
}
|
||||
return a.err
|
||||
}
|
||||
|
||||
type contextCheckingHistoryCommitter struct {
|
||||
ctxErr error
|
||||
count int
|
||||
messageCount int
|
||||
}
|
||||
|
||||
func (c *contextCheckingHistoryCommitter) CommitMessages(ctx context.Context, messages ...kafka.Message) error {
|
||||
c.ctxErr = ctx.Err()
|
||||
c.count++
|
||||
c.messageCount += len(messages)
|
||||
return c.ctxErr
|
||||
}
|
||||
|
||||
type discardHistoryLogger struct{}
|
||||
|
||||
func (discardHistoryLogger) Error(string, ...any) {}
|
||||
func (discardHistoryLogger) Warn(string, ...any) {}
|
||||
|
||||
var errTestHistoryAppend = errors.New("test history append failed")
|
||||
45
go/vehicle-gateway/cmd/load-sim/main.go
Normal file
45
go/vehicle-gateway/cmd/load-sim/main.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/loadsim"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
||||
flagCfg := loadsim.RegisterFlags(flag.CommandLine)
|
||||
if err := flag.CommandLine.Parse(os.Args[1:]); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cfg, err := flagCfg.Build()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
log.Printf("load simulation started protocol=%s addr=%s connections=%d connect_rate=%d send_interval=%s duration=%s template=%s",
|
||||
cfg.Protocol, cfg.Addr, cfg.Connections, cfg.ConnectRatePerSecond, cfg.SendInterval, cfg.Duration, cfg.Template)
|
||||
stats, err := (loadsim.Runner{}).Run(ctx, cfg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Print(formatStats(stats))
|
||||
}
|
||||
|
||||
func formatStats(stats loadsim.Stats) string {
|
||||
return fmt.Sprintf("connections_opened=%d connections_failed=%d frames_written=%d write_errors=%d",
|
||||
stats.ConnectionsOpened,
|
||||
stats.ConnectionsFailed,
|
||||
stats.FramesWritten,
|
||||
stats.WriteErrors,
|
||||
)
|
||||
}
|
||||
28
go/vehicle-gateway/cmd/load-sim/main_test.go
Normal file
28
go/vehicle-gateway/cmd/load-sim/main_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/loadsim"
|
||||
)
|
||||
|
||||
func TestFormatStatsIncludesCapacityCounters(t *testing.T) {
|
||||
out := formatStats(loadsim.Stats{
|
||||
ConnectionsOpened: 10,
|
||||
ConnectionsFailed: 2,
|
||||
FramesWritten: 300,
|
||||
WriteErrors: 1,
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
"connections_opened=10",
|
||||
"connections_failed=2",
|
||||
"frames_written=300",
|
||||
"write_errors=1",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("formatStats() = %q, missing %q", out, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
499
go/vehicle-gateway/cmd/nats-fast-writer/main.go
Normal file
499
go/vehicle-gateway/cmd/nats-fast-writer/main.go
Normal file
@@ -0,0 +1,499 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/redis/go-redis/v9"
|
||||
_ "github.com/taosdata/driver-go/v3/taosWS"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/history"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := observability.NewLogger("nats-fast-writer")
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
cfg := loadConfig()
|
||||
conn, err := nats.Connect(cfg.NATSURL, nats.Name(cfg.NATSClientName), nats.Timeout(5*time.Second))
|
||||
if err != nil {
|
||||
logger.Error("nats connect failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer conn.Close()
|
||||
js, err := conn.JetStream()
|
||||
if err != nil {
|
||||
logger.Error("nats jetstream init failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := ensureStream(js, cfg); err != nil {
|
||||
logger.Error("nats stream ensure failed", "stream", cfg.NATSStream, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
sub, err := js.PullSubscribe(
|
||||
cfg.NATSFilter,
|
||||
cfg.NATSDurable,
|
||||
nats.BindStream(cfg.NATSStream),
|
||||
nats.ManualAck(),
|
||||
nats.AckWait(cfg.AckWait),
|
||||
nats.MaxDeliver(-1),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error("nats pull consumer init failed", "stream", cfg.NATSStream, "durable", cfg.NATSDurable, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
tdDB, err := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN)
|
||||
if err != nil {
|
||||
logger.Error("tdengine open failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer tdDB.Close()
|
||||
tdDB.SetMaxOpenConns(cfg.TDengineMaxOpenConns)
|
||||
tdDB.SetMaxIdleConns(cfg.TDengineMaxIdleConns)
|
||||
if err := tdDB.PingContext(ctx); err != nil {
|
||||
logger.Error("tdengine ping failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
historyWriter := history.NewWriterWithDatabase(tdDB, cfg.TDengineDatabase)
|
||||
if cfg.TDengineEnsureSchema {
|
||||
if err := historyWriter.EnsureSchema(ctx, cfg.TDengineDatabase); err != nil {
|
||||
logger.Error("tdengine schema bootstrap failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
redisClient := redis.NewClient(&redis.Options{
|
||||
Addr: cfg.RedisAddr,
|
||||
Username: cfg.RedisUsername,
|
||||
Password: cfg.RedisPassword,
|
||||
DB: cfg.RedisDB,
|
||||
})
|
||||
defer redisClient.Close()
|
||||
if err := redisClient.Ping(ctx).Err(); err != nil {
|
||||
logger.Error("redis ping failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
realtimeRepo := realtime.NewRepository(redisClient, realtime.Config{OnlineTTL: cfg.OnlineTTL})
|
||||
|
||||
registry := metrics.NewRegistry()
|
||||
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "nats-fast-writer", []health.Check{
|
||||
{Name: "nats", Check: func(context.Context) error {
|
||||
if conn.Status() != nats.CONNECTED {
|
||||
return fmt.Errorf("nats status is %s", conn.Status().String())
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Name: "tdengine", Check: tdDB.PingContext},
|
||||
{Name: "redis", Check: func(ctx context.Context) error { return redisClient.Ping(ctx).Err() }},
|
||||
}, registry))
|
||||
|
||||
logger.Info("nats fast writer started",
|
||||
"stream", cfg.NATSStream,
|
||||
"durable", cfg.NATSDurable,
|
||||
"filter", cfg.NATSFilter,
|
||||
"batch_size", cfg.BatchSize,
|
||||
"workers", cfg.Workers,
|
||||
"tdengine_max_open_conns", cfg.TDengineMaxOpenConns,
|
||||
"tdengine_max_idle_conns", cfg.TDengineMaxIdleConns,
|
||||
"operation_timeout_ms", cfg.OperationWait.Milliseconds())
|
||||
for i := 0; i < cfg.Workers; i++ {
|
||||
go runFastWorker(ctx, logger, registry, js, sub, historyWriter, realtimeRepo, cfg)
|
||||
}
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
type config struct {
|
||||
NATSURL string
|
||||
NATSClientName string
|
||||
NATSStream string
|
||||
NATSDurable string
|
||||
NATSFilter string
|
||||
NATSSubjects []string
|
||||
BatchSize int
|
||||
FetchWait time.Duration
|
||||
OperationWait time.Duration
|
||||
AckWait time.Duration
|
||||
StreamMaxAge time.Duration
|
||||
StreamMaxBytes int64
|
||||
StreamEnsureWait time.Duration
|
||||
Workers int
|
||||
TDengineDriver string
|
||||
TDengineDSN string
|
||||
TDengineDatabase string
|
||||
TDengineEnsureSchema bool
|
||||
TDengineMaxOpenConns int
|
||||
TDengineMaxIdleConns int
|
||||
RedisAddr string
|
||||
RedisUsername string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
OnlineTTL time.Duration
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
subjects := splitCSV(env("NATS_STREAM_SUBJECTS", strings.Join([]string{
|
||||
env("NATS_SUBJECT_GB32960_RAW", env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960)),
|
||||
env("NATS_SUBJECT_JT808_RAW", env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808)),
|
||||
env("NATS_SUBJECT_YUTONG_MQTT_RAW", env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT)),
|
||||
env("NATS_SUBJECT_GB32960_FIELDS", env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960)),
|
||||
env("NATS_SUBJECT_JT808_FIELDS", env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808)),
|
||||
env("NATS_SUBJECT_YUTONG_MQTT_FIELDS", env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT)),
|
||||
}, ",")))
|
||||
workers := envInt("FAST_WRITER_WORKERS", 8)
|
||||
maxOpenConns := envInt("FAST_WRITER_TDENGINE_MAX_OPEN_CONNS", 1)
|
||||
maxIdleConns := envInt("FAST_WRITER_TDENGINE_MAX_IDLE_CONNS", maxOpenConns)
|
||||
return config{
|
||||
NATSURL: env("NATS_URL", "nats://127.0.0.1:4222"),
|
||||
NATSClientName: env("NATS_CLIENT_NAME", "lingniu-nats-fast-writer"),
|
||||
NATSStream: env("NATS_STREAM", "VEHICLE_INGEST"),
|
||||
NATSDurable: env("NATS_DURABLE", "vehicle-fast-writer"),
|
||||
NATSFilter: env("NATS_FILTER", "vehicle.raw.go.>"),
|
||||
NATSSubjects: subjects,
|
||||
BatchSize: envInt("FAST_WRITER_BATCH_SIZE", 100),
|
||||
FetchWait: time.Duration(envInt("FAST_WRITER_FETCH_WAIT_MS", 100)) * time.Millisecond,
|
||||
OperationWait: time.Duration(envInt("FAST_WRITER_OPERATION_TIMEOUT_MS", 1000)) * time.Millisecond,
|
||||
AckWait: time.Duration(envInt("NATS_ACK_WAIT_SECONDS", 30)) * time.Second,
|
||||
StreamMaxAge: time.Duration(envInt("NATS_STREAM_MAX_AGE_HOURS", 24)) * time.Hour,
|
||||
StreamMaxBytes: envInt64("NATS_STREAM_MAX_BYTES", 20*1024*1024*1024),
|
||||
StreamEnsureWait: time.Duration(envInt("NATS_STREAM_ENSURE_TIMEOUT_SECONDS", 60)) * time.Second,
|
||||
Workers: workers,
|
||||
TDengineDriver: env("TDENGINE_DRIVER", "taosWS"),
|
||||
TDengineDSN: env("TDENGINE_DSN", ""),
|
||||
TDengineDatabase: env("TDENGINE_DATABASE", history.DefaultDatabase),
|
||||
TDengineEnsureSchema: env("TDENGINE_ENSURE_SCHEMA", "true") != "false",
|
||||
TDengineMaxOpenConns: maxOpenConns,
|
||||
TDengineMaxIdleConns: maxIdleConns,
|
||||
RedisAddr: env("REDIS_ADDR", "127.0.0.1:6379"),
|
||||
RedisUsername: env("REDIS_USERNAME", ""),
|
||||
RedisPassword: env("REDIS_PASSWORD", ""),
|
||||
RedisDB: envInt("REDIS_DB", 0),
|
||||
OnlineTTL: time.Duration(envInt("REALTIME_ONLINE_TTL_SECONDS", 60)) * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
type fastAppender interface {
|
||||
AppendAll(context.Context, envelope.FrameEnvelope) error
|
||||
AppendAllBatch(context.Context, []envelope.FrameEnvelope) error
|
||||
}
|
||||
|
||||
type fastUpdater interface {
|
||||
FastUpdate(context.Context, envelope.FrameEnvelope) error
|
||||
}
|
||||
|
||||
type fastBatchUpdater interface {
|
||||
FastUpdateBatch(context.Context, []envelope.FrameEnvelope) error
|
||||
}
|
||||
|
||||
type fastMessage struct {
|
||||
subject string
|
||||
data []byte
|
||||
ack func() error
|
||||
}
|
||||
|
||||
func runFastWorker(ctx context.Context, logger *slog.Logger, registry *metrics.Registry, infoReader natsConsumerInfoReader, sub natsPullSubscription, appender fastAppender, updater fastUpdater, cfg config) {
|
||||
var lastConsumerInfoAt time.Time
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if time.Since(lastConsumerInfoAt) >= time.Duration(envInt("NATS_CONSUMER_METRICS_INTERVAL_SECONDS", 10))*time.Second {
|
||||
lastConsumerInfoAt = time.Now()
|
||||
if infoReader != nil {
|
||||
info, err := infoReader.ConsumerInfo(cfg.NATSStream, cfg.NATSDurable, nats.Context(ctx))
|
||||
if err != nil {
|
||||
logger.Warn("nats consumer info failed", "stream", cfg.NATSStream, "durable", cfg.NATSDurable, "error", err)
|
||||
} else {
|
||||
recordFastNATSConsumerInfoMetrics(registry, cfg, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
msgs, err := sub.Fetch(cfg.BatchSize, nats.MaxWait(cfg.FetchWait))
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrTimeout) {
|
||||
continue
|
||||
}
|
||||
logger.Error("nats fetch failed", "error", err)
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
fastMessages := make([]*fastMessage, 0, len(msgs))
|
||||
for _, msg := range msgs {
|
||||
natsMsg := msg
|
||||
fastMessages = append(fastMessages, &fastMessage{
|
||||
subject: natsMsg.Subject,
|
||||
data: natsMsg.Data,
|
||||
ack: func() error {
|
||||
return natsMsg.Ack()
|
||||
},
|
||||
})
|
||||
}
|
||||
operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cfg.OperationWait)
|
||||
err = processFastBatch(operationCtx, registry, appender, updater, fastMessages)
|
||||
cancel()
|
||||
if err != nil {
|
||||
addFastMetric(registry, fastBatchSubject(fastMessages), "error")
|
||||
logger.Error("fast write batch failed", "messages", len(fastMessages), "error", err)
|
||||
continue
|
||||
}
|
||||
for _, msg := range fastMessages {
|
||||
addFastMetric(registry, msg.subject, "ok")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type natsPullSubscription interface {
|
||||
Fetch(int, ...nats.PullOpt) ([]*nats.Msg, error)
|
||||
}
|
||||
|
||||
type natsConsumerInfoReader interface {
|
||||
ConsumerInfo(stream string, name string, opts ...nats.JSOpt) (*nats.ConsumerInfo, error)
|
||||
}
|
||||
|
||||
var fastWriterStageDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
||||
|
||||
func processFastBatch(ctx context.Context, registry *metrics.Registry, appender fastAppender, updater fastUpdater, messages []*fastMessage) error {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
envelopes := make([]envelope.FrameEnvelope, 0, len(messages))
|
||||
validMessages := make([]*fastMessage, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
var env envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(msg.data, &env); err != nil {
|
||||
if msg.ack != nil {
|
||||
_ = msg.ack()
|
||||
}
|
||||
continue
|
||||
}
|
||||
envelopes = append(envelopes, env)
|
||||
validMessages = append(validMessages, msg)
|
||||
}
|
||||
if len(envelopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
setFastBatchPending(registry, len(messages), len(envelopes))
|
||||
defer setFastBatchPending(registry, 0, 0)
|
||||
subject := fastBatchSubject(validMessages)
|
||||
started := time.Now()
|
||||
err := appender.AppendAllBatch(ctx, envelopes)
|
||||
recordFastWriterStageDuration(registry, subject, "tdengine", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("tdengine batch append: %w", err)
|
||||
}
|
||||
if batchUpdater, ok := updater.(fastBatchUpdater); ok {
|
||||
started = time.Now()
|
||||
err = batchUpdater.FastUpdateBatch(ctx, envelopes)
|
||||
recordFastWriterStageDuration(registry, subject, "redis", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("redis fast batch update: %w", err)
|
||||
}
|
||||
for _, msg := range validMessages {
|
||||
if msg.ack != nil {
|
||||
started = time.Now()
|
||||
err = msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("nats ack: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for i, env := range envelopes {
|
||||
msg := validMessages[i]
|
||||
started = time.Now()
|
||||
err = updater.FastUpdate(ctx, env)
|
||||
recordFastWriterStageDuration(registry, msg.subject, "redis", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("redis fast update: %w", err)
|
||||
}
|
||||
if msg.ack != nil {
|
||||
started = time.Now()
|
||||
err = msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("nats ack: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processFastMessage(ctx context.Context, registry *metrics.Registry, appender fastAppender, updater fastUpdater, msg *fastMessage) error {
|
||||
var env envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(msg.data, &env); err != nil {
|
||||
if msg.ack != nil {
|
||||
_ = msg.ack()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
started := time.Now()
|
||||
err := appender.AppendAll(ctx, env)
|
||||
recordFastWriterStageDuration(registry, msg.subject, "tdengine", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("tdengine append: %w", err)
|
||||
}
|
||||
started = time.Now()
|
||||
err = updater.FastUpdate(ctx, env)
|
||||
recordFastWriterStageDuration(registry, msg.subject, "redis", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("redis fast update: %w", err)
|
||||
}
|
||||
if msg.ack != nil {
|
||||
started = time.Now()
|
||||
err = msg.ack()
|
||||
recordFastWriterStageDuration(registry, msg.subject, "ack", statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
return fmt.Errorf("nats ack: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fastBatchSubject(messages []*fastMessage) string {
|
||||
if len(messages) == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
subject := messages[0].subject
|
||||
for _, msg := range messages[1:] {
|
||||
if msg.subject != subject {
|
||||
return "mixed"
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(subject) == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return subject
|
||||
}
|
||||
|
||||
func ensureStream(js nats.JetStreamContext, cfg config) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.StreamEnsureWait)
|
||||
defer cancel()
|
||||
opts := []nats.JSOpt{nats.Context(ctx)}
|
||||
stream := &nats.StreamConfig{
|
||||
Name: cfg.NATSStream,
|
||||
Subjects: cfg.NATSSubjects,
|
||||
Storage: nats.FileStorage,
|
||||
Retention: nats.LimitsPolicy,
|
||||
MaxAge: cfg.StreamMaxAge,
|
||||
MaxBytes: cfg.StreamMaxBytes,
|
||||
Duplicates: 2 * time.Minute,
|
||||
}
|
||||
if _, err := js.StreamInfo(cfg.NATSStream, opts...); err == nil {
|
||||
_, err = js.UpdateStream(stream, opts...)
|
||||
return err
|
||||
}
|
||||
_, err := js.AddStream(stream, opts...)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
_, updateErr := js.UpdateStream(stream, opts...)
|
||||
if updateErr == nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func addFastMetric(registry *metrics.Registry, subject string, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.IncCounter("vehicle_fast_writer_messages_total", metrics.Labels{"subject": subject, "status": status})
|
||||
}
|
||||
|
||||
func setFastBatchPending(registry *metrics.Registry, messages int, envelopes int) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetGauge("vehicle_fast_writer_batch_pending_messages", nil, float64(messages))
|
||||
registry.SetGauge("vehicle_fast_writer_batch_pending_envelopes", nil, float64(envelopes))
|
||||
}
|
||||
|
||||
func recordFastNATSConsumerInfoMetrics(registry *metrics.Registry, cfg config, info *nats.ConsumerInfo) {
|
||||
if registry == nil || info == nil {
|
||||
return
|
||||
}
|
||||
labels := metrics.Labels{"stream": cfg.NATSStream, "consumer": cfg.NATSDurable}
|
||||
registry.SetGauge("vehicle_fast_writer_nats_consumer_pending", labels, float64(info.NumPending))
|
||||
registry.SetGauge("vehicle_fast_writer_nats_consumer_ack_pending", labels, float64(info.NumAckPending))
|
||||
registry.SetGauge("vehicle_fast_writer_nats_consumer_waiting", labels, float64(info.NumWaiting))
|
||||
}
|
||||
|
||||
func recordFastWriterStageDuration(registry *metrics.Registry, subject string, stage string, status string, elapsed time.Duration) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.ObserveHistogram("vehicle_fast_writer_stage_duration_ms_histogram", metrics.Labels{
|
||||
"subject": subject,
|
||||
"stage": stage,
|
||||
"status": status,
|
||||
}, fastWriterStageDurationBucketsMS, float64(elapsed.Milliseconds()))
|
||||
}
|
||||
|
||||
func statusFromError(err error) string {
|
||||
if err != nil {
|
||||
return "error"
|
||||
}
|
||||
return "ok"
|
||||
}
|
||||
|
||||
func env(key string, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
var parsed int
|
||||
if _, err := fmt.Sscanf(value, "%d", &parsed); err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envInt64(key string, fallback int64) int64 {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
var parsed int64
|
||||
if _, err := fmt.Sscanf(value, "%d", &parsed); err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
var out []string
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
342
go/vehicle-gateway/cmd/nats-fast-writer/main_test.go
Normal file
342
go/vehicle-gateway/cmd/nats-fast-writer/main_test.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
)
|
||||
|
||||
func TestProcessFastMessageWritesTDengineAndRedisBeforeAck(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", EventID: "evt-1"}
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appender := &recordingFastAppender{}
|
||||
updater := &recordingFastSingleUpdater{}
|
||||
ackCount := 0
|
||||
msg := &fastMessage{data: payload, ack: func() error {
|
||||
ackCount++
|
||||
return nil
|
||||
}}
|
||||
|
||||
if err := processFastMessage(context.Background(), nil, appender, updater, msg); err != nil {
|
||||
t.Fatalf("processFastMessage() error = %v", err)
|
||||
}
|
||||
if appender.count != 1 || updater.count != 1 || ackCount != 1 {
|
||||
t.Fatalf("appends=%d updates=%d acks=%d", appender.count, updater.count, ackCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastMessageDoesNotAckWhenRedisFails(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-2"}
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appender := &recordingFastAppender{}
|
||||
updater := &recordingFastUpdater{err: errors.New("redis down")}
|
||||
ackCount := 0
|
||||
msg := &fastMessage{data: payload, ack: func() error {
|
||||
ackCount++
|
||||
return nil
|
||||
}}
|
||||
|
||||
if err := processFastMessage(context.Background(), nil, appender, updater, msg); err == nil {
|
||||
t.Fatal("processFastMessage() error = nil, want redis failure")
|
||||
}
|
||||
if ackCount != 0 {
|
||||
t.Fatalf("ack count = %d, want 0", ackCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastMessageRecordsStageDurationMetrics(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-3"}
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
msg := &fastMessage{subject: "vehicle.raw.go.jt808.v1", data: payload, ack: func() error { return nil }}
|
||||
|
||||
if err := processFastMessage(context.Background(), registry, &recordingFastAppender{}, &recordingFastUpdater{}, msg); err != nil {
|
||||
t.Fatalf("processFastMessage() error = %v", err)
|
||||
}
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_bucket{le="+Inf",stage="tdengine",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_count{stage="tdengine",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_bucket{le="+Inf",stage="redis",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_fast_writer_stage_duration_ms_histogram_bucket{le="+Inf",stage="ack",status="ok",subject="vehicle.raw.go.jt808.v1"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("fast writer stage metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchAppendsTDengineBatchBeforeRedisAndAck(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-4"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN002", EventID: "evt-5"}
|
||||
firstPayload, err := first.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := second.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appender := &recordingFastAppender{}
|
||||
updater := &recordingFastSingleUpdater{}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: firstPayload, ack: func() error { ackCount++; return nil }},
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: secondPayload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), nil, appender, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
if appender.count != 0 {
|
||||
t.Fatalf("AppendAll count = %d, want 0", appender.count)
|
||||
}
|
||||
if appender.batchCount != 1 || appender.batchRows != 2 {
|
||||
t.Fatalf("AppendAllBatch count=%d rows=%d, want count=1 rows=2", appender.batchCount, appender.batchRows)
|
||||
}
|
||||
if updater.count != 2 || ackCount != 2 {
|
||||
t.Fatalf("updates=%d acks=%d, want 2/2", updater.count, ackCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchUsesRedisBatchUpdaterWhenAvailable(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-batch-1"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN002", EventID: "evt-batch-2"}
|
||||
firstPayload, err := first.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := second.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updater := &recordingFastUpdater{}
|
||||
ackCount := 0
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: firstPayload, ack: func() error { ackCount++; return nil }},
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: secondPayload, ack: func() error { ackCount++; return nil }},
|
||||
}
|
||||
|
||||
if err := processFastBatch(context.Background(), nil, &recordingFastAppender{}, updater, msgs); err != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", err)
|
||||
}
|
||||
if updater.batchCount != 1 || updater.batchRows != 2 {
|
||||
t.Fatalf("FastUpdateBatch count=%d rows=%d, want count=1 rows=2", updater.batchCount, updater.batchRows)
|
||||
}
|
||||
if updater.count != 0 {
|
||||
t.Fatalf("FastUpdate count=%d, want 0 when batch updater is available", updater.count)
|
||||
}
|
||||
if ackCount != 2 {
|
||||
t.Fatalf("ack count=%d, want 2", ackCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFastBatchExposesPendingMetricsDuringAppend(t *testing.T) {
|
||||
first := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001", EventID: "evt-6"}
|
||||
second := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN002", EventID: "evt-7"}
|
||||
firstPayload, err := first.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPayload, err := second.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
appenderStarted := make(chan struct{})
|
||||
appenderRelease := make(chan struct{})
|
||||
appender := &recordingFastAppender{
|
||||
onAppendBatch: func() {
|
||||
close(appenderStarted)
|
||||
<-appenderRelease
|
||||
},
|
||||
}
|
||||
msgs := []*fastMessage{
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: firstPayload, ack: func() error { return nil }},
|
||||
{subject: "vehicle.raw.go.jt808.v1", data: secondPayload, ack: func() error { return nil }},
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
var processErr error
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
processErr = processFastBatch(context.Background(), registry, appender, &recordingFastUpdater{}, msgs)
|
||||
}()
|
||||
<-appenderStarted
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_batch_pending_messages 2`,
|
||||
`vehicle_fast_writer_batch_pending_envelopes 2`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("pending metric missing %s while appending:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
|
||||
close(appenderRelease)
|
||||
wg.Wait()
|
||||
if processErr != nil {
|
||||
t.Fatalf("processFastBatch() error = %v", processErr)
|
||||
}
|
||||
text = registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_batch_pending_messages 0`,
|
||||
`vehicle_fast_writer_batch_pending_envelopes 0`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("pending metric should reset after append, missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordFastNATSConsumerInfoMetrics(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
cfg := config{NATSStream: "VEHICLE_INGEST", NATSDurable: "vehicle-fast-writer"}
|
||||
|
||||
recordFastNATSConsumerInfoMetrics(registry, cfg, &nats.ConsumerInfo{
|
||||
NumPending: 23,
|
||||
NumAckPending: 5,
|
||||
NumWaiting: 3,
|
||||
})
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_fast_writer_nats_consumer_pending{consumer="vehicle-fast-writer",stream="VEHICLE_INGEST"} 23`,
|
||||
`vehicle_fast_writer_nats_consumer_ack_pending{consumer="vehicle-fast-writer",stream="VEHICLE_INGEST"} 5`,
|
||||
`vehicle_fast_writer_nats_consumer_waiting{consumer="vehicle-fast-writer",stream="VEHICLE_INGEST"} 3`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("metrics missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsTDenginePoolToSingleConnection(t *testing.T) {
|
||||
t.Setenv("FAST_WRITER_WORKERS", "12")
|
||||
t.Setenv("FAST_WRITER_TDENGINE_MAX_OPEN_CONNS", "")
|
||||
|
||||
cfg := loadConfig()
|
||||
|
||||
if cfg.TDengineMaxOpenConns != 1 {
|
||||
t.Fatalf("TDengineMaxOpenConns = %d, want 1", cfg.TDengineMaxOpenConns)
|
||||
}
|
||||
if cfg.TDengineMaxIdleConns != 1 {
|
||||
t.Fatalf("TDengineMaxIdleConns = %d, want 1", cfg.TDengineMaxIdleConns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsStreamMaxBytes(t *testing.T) {
|
||||
cfg := loadConfig()
|
||||
|
||||
if got, want := cfg.StreamMaxBytes, int64(20*1024*1024*1024); got != want {
|
||||
t.Fatalf("StreamMaxBytes = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := cfg.StreamEnsureWait, 60*time.Second; got != want {
|
||||
t.Fatalf("StreamEnsureWait = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := cfg.OperationWait, time.Second; got != want {
|
||||
t.Fatalf("OperationWait = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigReadsStreamMaxBytesOverride(t *testing.T) {
|
||||
t.Setenv("NATS_STREAM_MAX_BYTES", "1073741824")
|
||||
t.Setenv("NATS_STREAM_ENSURE_TIMEOUT_SECONDS", "90")
|
||||
t.Setenv("FAST_WRITER_OPERATION_TIMEOUT_MS", "1500")
|
||||
|
||||
cfg := loadConfig()
|
||||
|
||||
if got, want := cfg.StreamMaxBytes, int64(1073741824); got != want {
|
||||
t.Fatalf("StreamMaxBytes = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := cfg.StreamEnsureWait, 90*time.Second; got != want {
|
||||
t.Fatalf("StreamEnsureWait = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := cfg.OperationWait, 1500*time.Millisecond; got != want {
|
||||
t.Fatalf("OperationWait = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigReadsTDenginePoolOverride(t *testing.T) {
|
||||
t.Setenv("FAST_WRITER_WORKERS", "12")
|
||||
t.Setenv("FAST_WRITER_TDENGINE_MAX_OPEN_CONNS", "4")
|
||||
t.Setenv("FAST_WRITER_TDENGINE_MAX_IDLE_CONNS", "2")
|
||||
|
||||
cfg := loadConfig()
|
||||
|
||||
if cfg.TDengineMaxOpenConns != 4 {
|
||||
t.Fatalf("TDengineMaxOpenConns = %d, want 4", cfg.TDengineMaxOpenConns)
|
||||
}
|
||||
if cfg.TDengineMaxIdleConns != 2 {
|
||||
t.Fatalf("TDengineMaxIdleConns = %d, want 2", cfg.TDengineMaxIdleConns)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingFastAppender struct {
|
||||
count int
|
||||
batchCount int
|
||||
batchRows int
|
||||
err error
|
||||
onAppendBatch func()
|
||||
}
|
||||
|
||||
func (a *recordingFastAppender) AppendAll(context.Context, envelope.FrameEnvelope) error {
|
||||
a.count++
|
||||
return a.err
|
||||
}
|
||||
|
||||
func (a *recordingFastAppender) AppendAllBatch(_ context.Context, envs []envelope.FrameEnvelope) error {
|
||||
a.batchCount++
|
||||
a.batchRows += len(envs)
|
||||
if a.onAppendBatch != nil {
|
||||
a.onAppendBatch()
|
||||
}
|
||||
return a.err
|
||||
}
|
||||
|
||||
type recordingFastUpdater struct {
|
||||
count int
|
||||
batchCount int
|
||||
batchRows int
|
||||
err error
|
||||
}
|
||||
|
||||
func (u *recordingFastUpdater) FastUpdate(context.Context, envelope.FrameEnvelope) error {
|
||||
u.count++
|
||||
return u.err
|
||||
}
|
||||
|
||||
func (u *recordingFastUpdater) FastUpdateBatch(_ context.Context, envs []envelope.FrameEnvelope) error {
|
||||
u.batchCount++
|
||||
u.batchRows += len(envs)
|
||||
return u.err
|
||||
}
|
||||
|
||||
type recordingFastSingleUpdater struct {
|
||||
count int
|
||||
err error
|
||||
}
|
||||
|
||||
func (u *recordingFastSingleUpdater) FastUpdate(context.Context, envelope.FrameEnvelope) error {
|
||||
u.count++
|
||||
return u.err
|
||||
}
|
||||
402
go/vehicle-gateway/cmd/nats-kafka-bridge/main.go
Normal file
402
go/vehicle-gateway/cmd/nats-kafka-bridge/main.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/segmentio/kafka-go"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := observability.NewLogger("nats-kafka-bridge")
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
cfg := loadConfig()
|
||||
conn, err := nats.Connect(cfg.NATSURL, nats.Name(cfg.NATSClientName), nats.Timeout(5*time.Second))
|
||||
if err != nil {
|
||||
logger.Error("nats connect failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer conn.Close()
|
||||
registry := metrics.NewRegistry()
|
||||
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "nats-kafka-bridge", []health.Check{
|
||||
{Name: "nats", Check: func(context.Context) error {
|
||||
if conn.Status() != nats.CONNECTED {
|
||||
return fmt.Errorf("nats status is %s", conn.Status().String())
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
}, registry))
|
||||
js, err := conn.JetStream()
|
||||
if err != nil {
|
||||
logger.Error("nats jetstream init failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := ensureStream(js, cfg); err != nil {
|
||||
logger.Error("nats stream ensure failed", "stream", cfg.NATSStream, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
sub, err := js.PullSubscribe(
|
||||
cfg.NATSFilter,
|
||||
cfg.NATSDurable,
|
||||
nats.BindStream(cfg.NATSStream),
|
||||
nats.ManualAck(),
|
||||
nats.AckWait(cfg.AckWait),
|
||||
nats.MaxDeliver(-1),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error("nats pull consumer init failed", "stream", cfg.NATSStream, "durable", cfg.NATSDurable, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
writer := &kafka.Writer{
|
||||
Addr: kafka.TCP(cfg.KafkaBrokers...),
|
||||
Balancer: &kafka.Hash{},
|
||||
AllowAutoTopicCreation: false,
|
||||
RequiredAcks: kafka.RequireAll,
|
||||
Async: false,
|
||||
}
|
||||
defer writer.Close()
|
||||
|
||||
logger.Info("nats kafka bridge started",
|
||||
"nats_url", cfg.NATSURL,
|
||||
"stream", cfg.NATSStream,
|
||||
"durable", cfg.NATSDurable,
|
||||
"filter", cfg.NATSFilter,
|
||||
"kafka_brokers", strings.Join(cfg.KafkaBrokers, ","))
|
||||
runBridge(ctx, logger, registry, js, sub, writer, cfg)
|
||||
}
|
||||
|
||||
type config struct {
|
||||
NATSURL string
|
||||
NATSClientName string
|
||||
NATSStream string
|
||||
NATSDurable string
|
||||
NATSFilter string
|
||||
NATSSubjects []string
|
||||
KafkaBrokers []string
|
||||
Route map[string]string
|
||||
BatchSize int
|
||||
FetchWait time.Duration
|
||||
OperationWait time.Duration
|
||||
AckWait time.Duration
|
||||
StreamMaxAge time.Duration
|
||||
StreamMaxBytes int64
|
||||
StreamEnsureWait time.Duration
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
route := map[string]string{
|
||||
env("NATS_SUBJECT_GB32960_RAW", env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960)): env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960),
|
||||
env("NATS_SUBJECT_JT808_RAW", env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808)): env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808),
|
||||
env("NATS_SUBJECT_YUTONG_MQTT_RAW", env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT)): env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT),
|
||||
env("NATS_SUBJECT_GB32960_FIELDS", env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960)): env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960),
|
||||
env("NATS_SUBJECT_JT808_FIELDS", env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808)): env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808),
|
||||
env("NATS_SUBJECT_YUTONG_MQTT_FIELDS", env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT)): env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT),
|
||||
}
|
||||
if unifiedSubject, unifiedTopic, ok := unifiedRouteFromEnv(); ok {
|
||||
route[unifiedSubject] = unifiedTopic
|
||||
}
|
||||
return config{
|
||||
NATSURL: env("NATS_URL", "nats://127.0.0.1:4222"),
|
||||
NATSClientName: env("NATS_CLIENT_NAME", "lingniu-nats-kafka-bridge"),
|
||||
NATSStream: env("NATS_STREAM", "VEHICLE_INGEST"),
|
||||
NATSDurable: env("NATS_DURABLE", "vehicle-kafka-bridge"),
|
||||
NATSFilter: env("NATS_FILTER", "vehicle.>"),
|
||||
NATSSubjects: splitCSV(env("NATS_STREAM_SUBJECTS", strings.Join(mapKeys(route), ","))),
|
||||
KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")),
|
||||
Route: route,
|
||||
BatchSize: envInt("BRIDGE_BATCH_SIZE", 500),
|
||||
FetchWait: time.Duration(envInt("BRIDGE_FETCH_WAIT_MS", 1000)) * time.Millisecond,
|
||||
OperationWait: time.Duration(envInt("BRIDGE_OPERATION_TIMEOUT_MS", 30000)) * time.Millisecond,
|
||||
AckWait: time.Duration(envInt("NATS_ACK_WAIT_SECONDS", 60)) * time.Second,
|
||||
StreamMaxAge: time.Duration(envInt("NATS_STREAM_MAX_AGE_HOURS", 24)) * time.Hour,
|
||||
StreamMaxBytes: envInt64("NATS_STREAM_MAX_BYTES", 20*1024*1024*1024),
|
||||
StreamEnsureWait: time.Duration(envInt("NATS_STREAM_ENSURE_TIMEOUT_SECONDS", 60)) * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func unifiedRouteFromEnv() (string, string, bool) {
|
||||
subject, hasSubject := envOptional("NATS_SUBJECT_UNIFIED")
|
||||
topic, hasTopic := envOptional("KAFKA_TOPIC_UNIFIED")
|
||||
if !hasSubject && !hasTopic {
|
||||
return "", "", false
|
||||
}
|
||||
if subject == "" {
|
||||
subject = topic
|
||||
}
|
||||
if topic == "" {
|
||||
topic = subject
|
||||
}
|
||||
if subject == "" || topic == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return subject, topic, true
|
||||
}
|
||||
|
||||
type natsPullSubscription interface {
|
||||
Fetch(int, ...nats.PullOpt) ([]*nats.Msg, error)
|
||||
}
|
||||
|
||||
type natsConsumerInfoReader interface {
|
||||
ConsumerInfo(stream string, name string, opts ...nats.JSOpt) (*nats.ConsumerInfo, error)
|
||||
}
|
||||
|
||||
type kafkaBatchWriter interface {
|
||||
WriteMessages(context.Context, ...kafka.Message) error
|
||||
}
|
||||
|
||||
type bridgeMessage struct {
|
||||
subject string
|
||||
data []byte
|
||||
ack func() error
|
||||
}
|
||||
|
||||
func runBridge(ctx context.Context, logger *slog.Logger, registry *metrics.Registry, infoReader natsConsumerInfoReader, sub natsPullSubscription, writer kafkaBatchWriter, cfg config) {
|
||||
var lastConsumerInfoAt time.Time
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if time.Since(lastConsumerInfoAt) >= time.Duration(envInt("NATS_CONSUMER_METRICS_INTERVAL_SECONDS", 10))*time.Second {
|
||||
lastConsumerInfoAt = time.Now()
|
||||
if infoReader != nil {
|
||||
info, err := infoReader.ConsumerInfo(cfg.NATSStream, cfg.NATSDurable, nats.Context(ctx))
|
||||
if err != nil {
|
||||
logger.Warn("nats consumer info failed", "stream", cfg.NATSStream, "durable", cfg.NATSDurable, "error", err)
|
||||
} else {
|
||||
recordNATSConsumerInfoMetrics(registry, cfg, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
msgs, err := sub.Fetch(cfg.BatchSize, nats.MaxWait(cfg.FetchWait))
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrTimeout) {
|
||||
continue
|
||||
}
|
||||
logger.Error("nats fetch failed", "error", err)
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
bridgeMessages := make([]bridgeMessage, 0, len(msgs))
|
||||
for _, msg := range msgs {
|
||||
msg := msg
|
||||
bridgeMessages = append(bridgeMessages, bridgeMessage{
|
||||
subject: msg.Subject,
|
||||
data: msg.Data,
|
||||
ack: func() error {
|
||||
return msg.Ack()
|
||||
},
|
||||
})
|
||||
}
|
||||
operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cfg.OperationWait)
|
||||
err = bridgeBatch(operationCtx, registry, writer, bridgeMessages, cfg.Route)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Error("bridge batch failed", "count", len(bridgeMessages), "error", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ensureStream(js nats.JetStreamContext, cfg config) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.StreamEnsureWait)
|
||||
defer cancel()
|
||||
opts := []nats.JSOpt{nats.Context(ctx)}
|
||||
stream := &nats.StreamConfig{
|
||||
Name: cfg.NATSStream,
|
||||
Subjects: cfg.NATSSubjects,
|
||||
Storage: nats.FileStorage,
|
||||
Retention: nats.LimitsPolicy,
|
||||
MaxAge: cfg.StreamMaxAge,
|
||||
MaxBytes: cfg.StreamMaxBytes,
|
||||
Duplicates: 2 * time.Minute,
|
||||
}
|
||||
if _, err := js.StreamInfo(cfg.NATSStream, opts...); err == nil {
|
||||
_, err = js.UpdateStream(stream, opts...)
|
||||
return err
|
||||
}
|
||||
_, err := js.AddStream(stream, opts...)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
_, updateErr := js.UpdateStream(stream, opts...)
|
||||
if updateErr == nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func bridgeBatch(ctx context.Context, registry *metrics.Registry, writer kafkaBatchWriter, messages []bridgeMessage, route map[string]string) error {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
started := time.Now()
|
||||
status := "ok"
|
||||
defer func() {
|
||||
recordBridgeBatchDuration(registry, status, time.Since(started))
|
||||
recordBridgeBatchPending(registry, 0, 0)
|
||||
}()
|
||||
kafkaMessages := make([]kafka.Message, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
addBridgeSubjectMetric(registry, "vehicle_bridge_messages_total", message.subject, "received")
|
||||
topic, ok := route[message.subject]
|
||||
if !ok || topic == "" {
|
||||
addBridgeSubjectMetric(registry, "vehicle_bridge_messages_total", message.subject, "route_error")
|
||||
status = "error"
|
||||
return fmt.Errorf("kafka topic not configured for nats subject %q", message.subject)
|
||||
}
|
||||
kafkaMessages = append(kafkaMessages, kafkaMessage(topic, message.data))
|
||||
}
|
||||
recordBridgeBatchPending(registry, len(messages), len(kafkaMessages))
|
||||
if err := writer.WriteMessages(ctx, kafkaMessages...); err != nil {
|
||||
for _, message := range kafkaMessages {
|
||||
addBridgeTopicMetric(registry, "vehicle_bridge_kafka_writes_total", message.Topic, "error")
|
||||
}
|
||||
status = "error"
|
||||
return err
|
||||
}
|
||||
for _, message := range kafkaMessages {
|
||||
addBridgeTopicMetric(registry, "vehicle_bridge_kafka_writes_total", message.Topic, "ok")
|
||||
}
|
||||
for _, message := range messages {
|
||||
if message.ack == nil {
|
||||
continue
|
||||
}
|
||||
if err := message.ack(); err != nil {
|
||||
addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", message.subject, "error")
|
||||
status = "error"
|
||||
return err
|
||||
}
|
||||
addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", message.subject, "ok")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var bridgeBatchDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
||||
|
||||
func addBridgeSubjectMetric(registry *metrics.Registry, name string, subject string, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.IncCounter(name, metrics.Labels{"subject": subject, "status": status})
|
||||
}
|
||||
|
||||
func addBridgeTopicMetric(registry *metrics.Registry, name string, topic string, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.IncCounter(name, metrics.Labels{"topic": topic, "status": status})
|
||||
}
|
||||
|
||||
func recordBridgeBatchPending(registry *metrics.Registry, messages int, kafkaMessages int) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetGauge("vehicle_bridge_batch_pending_messages", nil, float64(messages))
|
||||
registry.SetGauge("vehicle_bridge_batch_pending_kafka_messages", nil, float64(kafkaMessages))
|
||||
}
|
||||
|
||||
func recordBridgeBatchDuration(registry *metrics.Registry, status string, elapsed time.Duration) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.ObserveHistogram("vehicle_bridge_batch_duration_ms_histogram", metrics.Labels{
|
||||
"status": status,
|
||||
}, bridgeBatchDurationBucketsMS, float64(elapsed.Milliseconds()))
|
||||
}
|
||||
|
||||
func recordNATSConsumerInfoMetrics(registry *metrics.Registry, cfg config, info *nats.ConsumerInfo) {
|
||||
if registry == nil || info == nil {
|
||||
return
|
||||
}
|
||||
labels := metrics.Labels{"stream": cfg.NATSStream, "consumer": cfg.NATSDurable}
|
||||
registry.SetGauge("vehicle_bridge_nats_consumer_pending", labels, float64(info.NumPending))
|
||||
registry.SetGauge("vehicle_bridge_nats_consumer_ack_pending", labels, float64(info.NumAckPending))
|
||||
registry.SetGauge("vehicle_bridge_nats_consumer_waiting", labels, float64(info.NumWaiting))
|
||||
}
|
||||
|
||||
func kafkaMessage(topic string, data []byte) kafka.Message {
|
||||
var env envelope.FrameEnvelope
|
||||
message := kafka.Message{Topic: topic, Value: data}
|
||||
if err := json.Unmarshal(data, &env); err == nil {
|
||||
message.Key = env.KafkaKey()
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func env(key string, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func envOptional(key string) (string, bool) {
|
||||
value, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(value), true
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envInt64(key string, fallback int64) int64 {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
var out []string
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapKeys(values map[string]string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
out = append(out, key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
232
go/vehicle-gateway/cmd/nats-kafka-bridge/main_test.go
Normal file
232
go/vehicle-gateway/cmd/nats-kafka-bridge/main_test.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/segmentio/kafka-go"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
)
|
||||
|
||||
func TestBridgeBatchWritesKafkaThenAcks(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
|
||||
payload, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer := &recordingBridgeWriter{}
|
||||
acked := 0
|
||||
|
||||
err = bridgeBatch(context.Background(), nil, writer, []bridgeMessage{
|
||||
{subject: "vehicle.raw.jt808.v1", data: payload, ack: func() error {
|
||||
acked++
|
||||
return nil
|
||||
}},
|
||||
}, map[string]string{"vehicle.raw.jt808.v1": "vehicle.raw.jt808.v1"})
|
||||
if err != nil {
|
||||
t.Fatalf("bridgeBatch() error = %v", err)
|
||||
}
|
||||
if len(writer.messages) != 1 {
|
||||
t.Fatalf("kafka writes = %d, want 1", len(writer.messages))
|
||||
}
|
||||
if got, want := writer.messages[0].Topic, "vehicle.raw.jt808.v1"; got != want {
|
||||
t.Fatalf("topic = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := string(writer.messages[0].Key), "JT808:13307795425"; got != want {
|
||||
t.Fatalf("key = %q, want %q", got, want)
|
||||
}
|
||||
if acked != 1 {
|
||||
t.Fatalf("acks = %d, want 1", acked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeBatchDoesNotAckWhenKafkaFails(t *testing.T) {
|
||||
writer := &recordingBridgeWriter{err: errors.New("kafka unavailable")}
|
||||
acked := 0
|
||||
|
||||
err := bridgeBatch(context.Background(), nil, writer, []bridgeMessage{
|
||||
{subject: "vehicle.event.unified.v1", data: []byte(`{"protocol":"JT808"}`), ack: func() error {
|
||||
acked++
|
||||
return nil
|
||||
}},
|
||||
}, map[string]string{"vehicle.event.unified.v1": "vehicle.event.unified.v1"})
|
||||
if err == nil {
|
||||
t.Fatal("bridgeBatch() error is nil, want kafka error")
|
||||
}
|
||||
if acked != 0 {
|
||||
t.Fatalf("acks = %d, want 0", acked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeBatchRecordsMetrics(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
|
||||
payload, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
writer := &recordingBridgeWriter{}
|
||||
|
||||
err = bridgeBatch(context.Background(), registry, writer, []bridgeMessage{
|
||||
{subject: "vehicle.raw.jt808.v1", data: payload, ack: func() error { return nil }},
|
||||
}, map[string]string{"vehicle.raw.jt808.v1": "vehicle.raw.jt808.v1"})
|
||||
if err != nil {
|
||||
t.Fatalf("bridgeBatch() error = %v", err)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_bridge_messages_total{status="received",subject="vehicle.raw.jt808.v1"} 1`,
|
||||
`vehicle_bridge_kafka_writes_total{status="ok",topic="vehicle.raw.jt808.v1"} 1`,
|
||||
`vehicle_bridge_nats_acks_total{status="ok",subject="vehicle.raw.jt808.v1"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("metrics missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeBatchExposesPendingAndDurationMetrics(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", MessageID: "0x02"}
|
||||
payload, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
writer := &recordingBridgeWriter{
|
||||
onWrite: func() {
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_bridge_batch_pending_messages 2`,
|
||||
`vehicle_bridge_batch_pending_kafka_messages 2`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("pending bridge metric missing %s during write:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
err = bridgeBatch(context.Background(), registry, writer, []bridgeMessage{
|
||||
{subject: "vehicle.raw.go.gb32960.v1", data: payload, ack: func() error { return nil }},
|
||||
{subject: "vehicle.raw.go.gb32960.v1", data: payload, ack: func() error { return nil }},
|
||||
}, map[string]string{"vehicle.raw.go.gb32960.v1": "vehicle.raw.go.gb32960.v1"})
|
||||
if err != nil {
|
||||
t.Fatalf("bridgeBatch() error = %v", err)
|
||||
}
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_bridge_batch_pending_messages 0`,
|
||||
`vehicle_bridge_batch_pending_kafka_messages 0`,
|
||||
`vehicle_bridge_batch_duration_ms_histogram_bucket{le="+Inf",status="ok"} 1`,
|
||||
`vehicle_bridge_batch_duration_ms_histogram_count{status="ok"} 1`,
|
||||
`vehicle_bridge_batch_duration_ms_histogram_sum{status="ok"}`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("bridge batch metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordNATSConsumerInfoMetrics(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
cfg := config{NATSStream: "VEHICLE_INGEST", NATSDurable: "vehicle-kafka-bridge"}
|
||||
|
||||
recordNATSConsumerInfoMetrics(registry, cfg, &nats.ConsumerInfo{
|
||||
NumPending: 17,
|
||||
NumAckPending: 3,
|
||||
NumWaiting: 2,
|
||||
})
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_bridge_nats_consumer_pending{consumer="vehicle-kafka-bridge",stream="VEHICLE_INGEST"} 17`,
|
||||
`vehicle_bridge_nats_consumer_ack_pending{consumer="vehicle-kafka-bridge",stream="VEHICLE_INGEST"} 3`,
|
||||
`vehicle_bridge_nats_consumer_waiting{consumer="vehicle-kafka-bridge",stream="VEHICLE_INGEST"} 2`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("metrics missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsToGoSubjectRoutes(t *testing.T) {
|
||||
cfg := loadConfig()
|
||||
|
||||
want := map[string]string{
|
||||
"vehicle.raw.go.gb32960.v1": "vehicle.raw.go.gb32960.v1",
|
||||
"vehicle.raw.go.jt808.v1": "vehicle.raw.go.jt808.v1",
|
||||
"vehicle.raw.go.yutong-mqtt.v1": "vehicle.raw.go.yutong-mqtt.v1",
|
||||
"vehicle.fields.go.gb32960.v1": "vehicle.fields.go.gb32960.v1",
|
||||
"vehicle.fields.go.jt808.v1": "vehicle.fields.go.jt808.v1",
|
||||
"vehicle.fields.go.yutong-mqtt.v1": "vehicle.fields.go.yutong-mqtt.v1",
|
||||
}
|
||||
if len(cfg.Route) != len(want) {
|
||||
t.Fatalf("route len = %d, want %d: %#v", len(cfg.Route), len(want), cfg.Route)
|
||||
}
|
||||
for subject, topic := range want {
|
||||
if got := cfg.Route[subject]; got != topic {
|
||||
t.Fatalf("route[%q] = %q, want %q; route=%#v", subject, got, topic, cfg.Route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsStreamMaxBytes(t *testing.T) {
|
||||
cfg := loadConfig()
|
||||
|
||||
if got, want := cfg.StreamMaxBytes, int64(20*1024*1024*1024); got != want {
|
||||
t.Fatalf("StreamMaxBytes = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := cfg.StreamEnsureWait, 60*time.Second; got != want {
|
||||
t.Fatalf("StreamEnsureWait = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigReadsStreamMaxBytesOverride(t *testing.T) {
|
||||
t.Setenv("NATS_STREAM_MAX_BYTES", "1073741824")
|
||||
t.Setenv("NATS_STREAM_ENSURE_TIMEOUT_SECONDS", "90")
|
||||
|
||||
cfg := loadConfig()
|
||||
|
||||
if got, want := cfg.StreamMaxBytes, int64(1073741824); got != want {
|
||||
t.Fatalf("StreamMaxBytes = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := cfg.StreamEnsureWait, 90*time.Second; got != want {
|
||||
t.Fatalf("StreamEnsureWait = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigIncludesUnifiedOnlyWhenExplicitlyConfigured(t *testing.T) {
|
||||
t.Setenv("NATS_SUBJECT_UNIFIED", "vehicle.event.go.unified.v1")
|
||||
t.Setenv("KAFKA_TOPIC_UNIFIED", "vehicle.event.go.unified.v1")
|
||||
|
||||
cfg := loadConfig()
|
||||
|
||||
if got := cfg.Route["vehicle.event.go.unified.v1"]; got != "vehicle.event.go.unified.v1" {
|
||||
t.Fatalf("unified route = %q, route=%#v", got, cfg.Route)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingBridgeWriter struct {
|
||||
messages []kafka.Message
|
||||
err error
|
||||
onWrite func()
|
||||
}
|
||||
|
||||
func (w *recordingBridgeWriter) WriteMessages(_ context.Context, messages ...kafka.Message) error {
|
||||
if w.onWrite != nil {
|
||||
w.onWrite()
|
||||
}
|
||||
if w.err != nil {
|
||||
return w.err
|
||||
}
|
||||
w.messages = append(w.messages, messages...)
|
||||
return nil
|
||||
}
|
||||
@@ -18,10 +18,13 @@ import (
|
||||
_ "github.com/taosdata/driver-go/v3/taosWS"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/history"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -42,17 +45,26 @@ func main() {
|
||||
}
|
||||
|
||||
repository := realtime.NewRepository(client, realtime.Config{
|
||||
OnlineTTL: time.Duration(envInt("ONLINE_TTL_SECONDS", 600)) * time.Second,
|
||||
OnlineTTL: time.Duration(envInt("ONLINE_TTL_SECONDS", envInt("REALTIME_ONLINE_TTL_SECONDS", 60))) * time.Second,
|
||||
})
|
||||
if brokers := splitCSV(os.Getenv("KAFKA_BROKERS")); len(brokers) > 0 {
|
||||
go consumeKafka(ctx, logger, repository, brokers)
|
||||
} else {
|
||||
logger.Warn("KAFKA_BROKERS is empty; realtime api will serve existing redis data only")
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
registry := metrics.NewRegistry()
|
||||
healthChecks := []health.Check{
|
||||
{Name: "redis", Check: func(ctx context.Context) error { return client.Ping(ctx).Err() }},
|
||||
}
|
||||
mux.Handle("/metrics", metrics.NewHandler(registry))
|
||||
mux.Handle("/openapi.json", realtime.NewOpenAPIHandler())
|
||||
mux.Handle("/swagger-ui/", realtime.NewSwaggerUIHandler())
|
||||
mux.Handle("/api/realtime/vehicles/", realtime.NewHandler(repository))
|
||||
mux.Handle("/api/realtime/online", realtime.NewOnlineIndexHandler(repository))
|
||||
mux.Handle("/api/debug/pipeline", realtime.NewPipelineDebugHandler(repository))
|
||||
closeStats := func() {}
|
||||
var updater realtimeUpdater = storeMetricUpdater{
|
||||
store: "redis",
|
||||
delegate: fastRealtimeUpdater{repository: repository},
|
||||
registry: registry,
|
||||
}
|
||||
if dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN")); dsn != "" {
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
@@ -64,18 +76,63 @@ func main() {
|
||||
logger.Error("stats mysql ping failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
healthChecks = append(healthChecks, health.Check{Name: "mysql", Check: db.PingContext})
|
||||
closeStats = func() { _ = db.Close() }
|
||||
bindingTable := env("VEHICLE_IDENTITY_TABLE", "vehicle_identity_binding")
|
||||
plateResolver := realtime.NewCachedPlateResolver(
|
||||
realtime.NewBindingPlateResolver(db, bindingTable),
|
||||
time.Duration(envInt("PLATE_CACHE_TTL_SECONDS", 600))*time.Second,
|
||||
)
|
||||
snapshotWriter := realtime.NewSnapshotWriterWithPlateResolver(db, plateResolver)
|
||||
if env("MYSQL_REALTIME_SNAPSHOT_ENABLED", "true") != "false" {
|
||||
if err := snapshotWriter.EnsureSchema(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
logger.Error("realtime snapshot mysql schema bootstrap failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
mysqlDelegate := realtimeUpdater(retryRealtimeUpdater{
|
||||
delegate: snapshotWriter,
|
||||
attempts: envInt("MYSQL_REALTIME_RETRY_ATTEMPTS", 3),
|
||||
delay: time.Duration(envInt("MYSQL_REALTIME_RETRY_DELAY_MS", 20)) * time.Millisecond,
|
||||
})
|
||||
mysqlUpdater := realtimeUpdater(storeMetricUpdater{store: "mysql", delegate: mysqlDelegate, registry: registry})
|
||||
if env("MYSQL_REALTIME_ASYNC_ENABLED", "true") != "false" {
|
||||
mysqlUpdater = newAsyncSecondaryRealtimeUpdater(
|
||||
nil,
|
||||
storeMetricUpdater{store: "mysql", delegate: mysqlDelegate, registry: registry},
|
||||
envInt("MYSQL_REALTIME_ASYNC_QUEUE_SIZE", 20000),
|
||||
envInt("MYSQL_REALTIME_ASYNC_WORKERS", 4),
|
||||
registry,
|
||||
logger,
|
||||
)
|
||||
}
|
||||
updater = compositeRealtimeUpdater{
|
||||
primary: storeMetricUpdater{store: "redis", delegate: fastRealtimeUpdater{repository: repository}, registry: registry},
|
||||
secondary: mysqlUpdater,
|
||||
}
|
||||
logger.Info("realtime mysql snapshot enabled", "table", "vehicle_realtime_snapshot", "plate_binding_table", bindingTable, "plate_cache_ttl_seconds", envInt("PLATE_CACHE_TTL_SECONDS", 600))
|
||||
}
|
||||
mux.Handle("/api/stats/daily-metrics", stats.NewMetricHandler(stats.NewMetricRepository(db)))
|
||||
mux.Handle("/api/realtime/snapshots", realtime.NewSnapshotQueryHandler(realtime.NewSnapshotQueryRepository(db)))
|
||||
mux.Handle("/api/realtime/locations", realtime.NewLocationQueryHandler(realtime.NewLocationQueryRepository(db)))
|
||||
logger.Info("stats mysql query enabled")
|
||||
} else {
|
||||
mux.HandleFunc("/api/stats/daily-metrics", func(w http.ResponseWriter, _ *http.Request) {
|
||||
mysqlUnavailable := func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": "MYSQL_DSN is not configured"})
|
||||
})
|
||||
}
|
||||
mux.HandleFunc("/api/stats/daily-metrics", mysqlUnavailable)
|
||||
mux.HandleFunc("/api/realtime/snapshots", mysqlUnavailable)
|
||||
mux.HandleFunc("/api/realtime/locations", mysqlUnavailable)
|
||||
logger.Warn("MYSQL_DSN is empty; stats query api disabled")
|
||||
}
|
||||
defer closeStats()
|
||||
if brokers := splitCSV(os.Getenv("KAFKA_BROKERS")); len(brokers) > 0 {
|
||||
go consumeKafka(ctx, logger, registry, updater, brokers)
|
||||
} else {
|
||||
logger.Warn("KAFKA_BROKERS is empty; realtime api will serve existing redis data only")
|
||||
}
|
||||
closeHistory := func() {}
|
||||
if dsn := strings.TrimSpace(os.Getenv("TDENGINE_DSN")); dsn != "" {
|
||||
driver := env("TDENGINE_DRIVER", "taosWS")
|
||||
@@ -89,11 +146,13 @@ func main() {
|
||||
logger.Error("history tdengine ping failed", "driver", driver, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
healthChecks = append(healthChecks, health.Check{Name: "tdengine", Check: db.PingContext})
|
||||
closeHistory = func() { _ = db.Close() }
|
||||
database := env("TDENGINE_DATABASE", history.DefaultDatabase)
|
||||
mux.Handle("/api/history/raw-frames", history.NewRawFrameHandler(history.NewRawFrameRepository(db, database)))
|
||||
rawFrameHandler := history.NewRawFrameHandler(history.NewRawFrameRepository(db, database))
|
||||
mux.Handle("/api/history/raw-frames", rawFrameHandler)
|
||||
mux.Handle("/api/history/raw-frames/query", rawFrameHandler)
|
||||
mux.Handle("/api/history/locations", history.NewLocationHandler(history.NewLocationRepository(db, database)))
|
||||
mux.Handle("/api/history/mileage-points", history.NewMileagePointHandler(history.NewMileagePointRepository(db, database)))
|
||||
logger.Info("history query enabled", "driver", driver, "database", database)
|
||||
} else {
|
||||
historyUnavailable := func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -102,11 +161,14 @@ func main() {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": "TDENGINE_DSN is not configured"})
|
||||
}
|
||||
mux.HandleFunc("/api/history/raw-frames", historyUnavailable)
|
||||
mux.HandleFunc("/api/history/raw-frames/query", historyUnavailable)
|
||||
mux.HandleFunc("/api/history/locations", historyUnavailable)
|
||||
mux.HandleFunc("/api/history/mileage-points", historyUnavailable)
|
||||
logger.Warn("TDENGINE_DSN is empty; history query api disabled")
|
||||
}
|
||||
defer closeHistory()
|
||||
healthHandler := health.NewHandler("vehicle-realtime-api", healthChecks)
|
||||
mux.Handle("/healthz", healthHandler)
|
||||
mux.Handle("/readyz", healthHandler)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: env("HTTP_ADDR", ":20210"),
|
||||
@@ -127,20 +189,17 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func consumeKafka(ctx context.Context, logger interface {
|
||||
type realtimeLogger interface {
|
||||
Info(string, ...any)
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, repository *realtime.Repository, brokers []string) {
|
||||
reader := kafka.NewReader(kafka.ReaderConfig{
|
||||
Brokers: brokers,
|
||||
GroupID: env("KAFKA_GROUP", "go-realtime-api"),
|
||||
GroupTopics: splitCSV(env("KAFKA_TOPICS", "vehicle.event.unified.v1")),
|
||||
MinBytes: 1,
|
||||
MaxBytes: 10e6,
|
||||
})
|
||||
}
|
||||
|
||||
func consumeKafka(ctx context.Context, logger realtimeLogger, registry *metrics.Registry, updater realtimeUpdater, brokers []string) {
|
||||
kafkaTopics := kafkaTopicsFromEnv()
|
||||
reader := kafka.NewReader(realtimeReaderConfig(brokers, kafkaTopics))
|
||||
defer reader.Close()
|
||||
logger.Info("realtime kafka consumer started", "topics", env("KAFKA_TOPICS", "vehicle.event.unified.v1"))
|
||||
logger.Info("realtime kafka consumer started", "topics", strings.Join(kafkaTopics, ","))
|
||||
for {
|
||||
message, err := reader.FetchMessage(ctx)
|
||||
if err != nil {
|
||||
@@ -150,20 +209,276 @@ func consumeKafka(ctx context.Context, logger interface {
|
||||
logger.Error("kafka fetch failed", "error", err)
|
||||
continue
|
||||
}
|
||||
processRealtimeMessage(ctx, logger, registry, updater, reader, message)
|
||||
}
|
||||
}
|
||||
|
||||
func realtimeReaderConfig(brokers []string, kafkaTopics []string) kafka.ReaderConfig {
|
||||
return kafka.ReaderConfig{
|
||||
Brokers: brokers,
|
||||
GroupID: env("KAFKA_GROUP", "go-realtime-api"),
|
||||
GroupTopics: kafkaTopics,
|
||||
MinBytes: 1,
|
||||
MaxBytes: 10e6,
|
||||
StartOffset: kafka.LastOffset,
|
||||
}
|
||||
}
|
||||
|
||||
func kafkaTopicsFromEnv() []string {
|
||||
defaultTopics := strings.Join([]string{topics.RawGB32960, topics.RawJT808, topics.RawYutongMQTT}, ",")
|
||||
return splitCSV(env("KAFKA_TOPICS", defaultTopics))
|
||||
}
|
||||
|
||||
const kafkaMessageOperationTimeout = 30 * time.Second
|
||||
|
||||
type realtimeUpdater interface {
|
||||
Update(context.Context, envelope.FrameEnvelope) error
|
||||
}
|
||||
|
||||
type fastRealtimeUpdater struct {
|
||||
repository *realtime.Repository
|
||||
}
|
||||
|
||||
func (u fastRealtimeUpdater) Update(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
return u.repository.FastUpdate(ctx, env)
|
||||
}
|
||||
|
||||
type storeMetricUpdater struct {
|
||||
store string
|
||||
delegate realtimeUpdater
|
||||
registry *metrics.Registry
|
||||
}
|
||||
|
||||
var realtimeStoreUpdateDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
||||
|
||||
func (u storeMetricUpdater) Update(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
if u.delegate == nil {
|
||||
return nil
|
||||
}
|
||||
started := time.Now()
|
||||
err := u.delegate.Update(ctx, env)
|
||||
elapsed := time.Since(started)
|
||||
status := "ok"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
}
|
||||
if u.registry != nil {
|
||||
labels := metrics.Labels{
|
||||
"store": u.store,
|
||||
"protocol": string(env.Protocol),
|
||||
"status": status,
|
||||
}
|
||||
elapsedMS := float64(elapsed.Milliseconds())
|
||||
u.registry.IncCounter("vehicle_realtime_store_updates_total", labels)
|
||||
u.registry.SetGauge("vehicle_realtime_store_update_duration_ms", labels, elapsedMS)
|
||||
u.registry.ObserveHistogram("vehicle_realtime_store_update_duration_ms_histogram", labels, realtimeStoreUpdateDurationBucketsMS, elapsedMS)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type retryRealtimeUpdater struct {
|
||||
delegate realtimeUpdater
|
||||
attempts int
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (u retryRealtimeUpdater) Update(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
if u.delegate == nil {
|
||||
return nil
|
||||
}
|
||||
attempts := u.attempts
|
||||
if attempts <= 0 {
|
||||
attempts = 1
|
||||
}
|
||||
var err error
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
err = u.delegate.Update(ctx, env)
|
||||
if err == nil || !isTransientMySQLWriteError(err) || attempt == attempts {
|
||||
return err
|
||||
}
|
||||
if u.delay <= 0 {
|
||||
continue
|
||||
}
|
||||
timer := time.NewTimer(u.delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func isTransientMySQLWriteError(err error) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
return strings.Contains(text, "deadlock") ||
|
||||
strings.Contains(text, "error 1213") ||
|
||||
strings.Contains(text, "40001") ||
|
||||
strings.Contains(text, "lock wait timeout") ||
|
||||
strings.Contains(text, "error 1205")
|
||||
}
|
||||
|
||||
type compositeRealtimeUpdater struct {
|
||||
primary realtimeUpdater
|
||||
secondary realtimeUpdater
|
||||
}
|
||||
|
||||
func (u compositeRealtimeUpdater) Update(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
if err := u.primary.Update(ctx, env); err != nil {
|
||||
return err
|
||||
}
|
||||
if u.secondary == nil {
|
||||
return nil
|
||||
}
|
||||
return u.secondary.Update(ctx, env)
|
||||
}
|
||||
|
||||
type asyncSecondaryRealtimeUpdater struct {
|
||||
primary realtimeUpdater
|
||||
secondary realtimeUpdater
|
||||
queue chan envelope.FrameEnvelope
|
||||
cancel context.CancelFunc
|
||||
registry *metrics.Registry
|
||||
logger realtimeLogger
|
||||
}
|
||||
|
||||
func newAsyncSecondaryRealtimeUpdater(primary realtimeUpdater, secondary realtimeUpdater, queueSize int, workers int, registry *metrics.Registry, logger realtimeLogger) *asyncSecondaryRealtimeUpdater {
|
||||
if queueSize <= 0 {
|
||||
queueSize = 20000
|
||||
}
|
||||
if workers <= 0 {
|
||||
workers = 4
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
updater := &asyncSecondaryRealtimeUpdater{
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
queue: make(chan envelope.FrameEnvelope, queueSize),
|
||||
cancel: cancel,
|
||||
registry: registry,
|
||||
logger: logger,
|
||||
}
|
||||
for i := 0; i < workers; i++ {
|
||||
go updater.run(ctx)
|
||||
}
|
||||
return updater
|
||||
}
|
||||
|
||||
func (u *asyncSecondaryRealtimeUpdater) Update(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
if u.primary != nil {
|
||||
if err := u.primary.Update(ctx, env); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if u.secondary == nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case u.queue <- env:
|
||||
u.recordQueueMetric(env, "queued")
|
||||
u.recordQueueDepth(env)
|
||||
return nil
|
||||
default:
|
||||
u.recordQueueMetric(env, "dropped")
|
||||
u.recordQueueDepth(env)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (u *asyncSecondaryRealtimeUpdater) run(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case env := <-u.queue:
|
||||
u.recordQueueDepth(env)
|
||||
secondaryCtx, cancel := context.WithTimeout(context.Background(), kafkaMessageOperationTimeout)
|
||||
if err := u.secondary.Update(secondaryCtx, env); err != nil && u.logger != nil {
|
||||
u.logger.Error("async realtime secondary update failed", "protocol", env.Protocol, "vin", env.VIN, "event_id", env.StableEventID(), "error", err)
|
||||
}
|
||||
cancel()
|
||||
u.recordQueueDepth(env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (u *asyncSecondaryRealtimeUpdater) recordQueueMetric(env envelope.FrameEnvelope, status string) {
|
||||
if u.registry == nil {
|
||||
return
|
||||
}
|
||||
u.registry.IncCounter("vehicle_realtime_async_queue_total", metrics.Labels{
|
||||
"store": "mysql",
|
||||
"protocol": string(env.Protocol),
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
func (u *asyncSecondaryRealtimeUpdater) recordQueueDepth(env envelope.FrameEnvelope) {
|
||||
if u.registry == nil {
|
||||
return
|
||||
}
|
||||
u.registry.SetGauge("vehicle_realtime_async_queue_depth", metrics.Labels{
|
||||
"store": "mysql",
|
||||
"protocol": string(env.Protocol),
|
||||
}, float64(len(u.queue)))
|
||||
}
|
||||
|
||||
func (u *asyncSecondaryRealtimeUpdater) Close() {
|
||||
if u.cancel != nil {
|
||||
u.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
type kafkaMessageCommitter interface {
|
||||
CommitMessages(context.Context, ...kafka.Message) error
|
||||
}
|
||||
|
||||
func processRealtimeMessage(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, updater realtimeUpdater, committer kafkaMessageCommitter, message kafka.Message) {
|
||||
messageCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), kafkaMessageOperationTimeout)
|
||||
defer cancel()
|
||||
|
||||
addRealtimeMetric(registry, "vehicle_realtime_kafka_messages_total", message, "received")
|
||||
addRealtimeLagMetric(registry, message)
|
||||
var env envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(message.Value, &env); err != nil {
|
||||
addRealtimeMetric(registry, "vehicle_realtime_kafka_messages_total", message, "invalid_json")
|
||||
logger.Warn("skip invalid envelope json", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
_ = reader.CommitMessages(ctx, message)
|
||||
continue
|
||||
_ = committer.CommitMessages(messageCtx, message)
|
||||
return
|
||||
}
|
||||
if err := repository.Update(ctx, env); err != nil {
|
||||
if err := updater.Update(messageCtx, env); err != nil {
|
||||
addRealtimeMetric(registry, "vehicle_realtime_updates_total", message, "error")
|
||||
logger.Error("redis realtime update failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err)
|
||||
continue
|
||||
return
|
||||
}
|
||||
if err := reader.CommitMessages(ctx, message); err != nil {
|
||||
addRealtimeMetric(registry, "vehicle_realtime_updates_total", message, "ok")
|
||||
if err := committer.CommitMessages(messageCtx, message); err != nil {
|
||||
addRealtimeMetric(registry, "vehicle_realtime_kafka_commits_total", message, "error")
|
||||
logger.Error("kafka commit failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
return
|
||||
}
|
||||
addRealtimeMetric(registry, "vehicle_realtime_kafka_commits_total", message, "ok")
|
||||
}
|
||||
|
||||
func addRealtimeMetric(registry *metrics.Registry, name string, message kafka.Message, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.IncCounter(name, metrics.Labels{
|
||||
"topic": message.Topic,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
func addRealtimeLagMetric(registry *metrics.Registry, message kafka.Message) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetKafkaLag("vehicle_realtime_kafka_lag", message.Topic, message.Partition, message.Offset, message.HighWaterMark)
|
||||
}
|
||||
|
||||
func env(key string, fallback string) string {
|
||||
|
||||
380
go/vehicle-gateway/cmd/realtime-api/main_test.go
Normal file
380
go/vehicle-gateway/cmd/realtime-api/main_test.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/segmentio/kafka-go"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
)
|
||||
|
||||
func TestProcessRealtimeMessageUsesUncancelledContextForUpdateAndCommit(t *testing.T) {
|
||||
parent, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
env := envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
MessageID: "0x0200",
|
||||
Phone: "13307795425",
|
||||
EventTimeMS: 1782918600000,
|
||||
ReceivedAtMS: 1782918600000,
|
||||
ParseStatus: envelope.ParseOK,
|
||||
}
|
||||
payload, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updater := &contextCheckingRealtimeUpdater{}
|
||||
committer := &contextCheckingMessageCommitter{}
|
||||
|
||||
processRealtimeMessage(parent, discardRealtimeLogger{}, nil, updater, committer, kafka.Message{Value: payload})
|
||||
|
||||
if updater.ctxErr != nil {
|
||||
t.Fatalf("updater saw cancelled context: %v", updater.ctxErr)
|
||||
}
|
||||
if committer.ctxErr != nil {
|
||||
t.Fatalf("committer saw cancelled context: %v", committer.ctxErr)
|
||||
}
|
||||
if updater.count != 1 || committer.count != 1 {
|
||||
t.Fatalf("updates=%d commits=%d", updater.count, committer.count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessRealtimeMessageRecordsMetrics(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
MessageID: "0x0200",
|
||||
Phone: "13307795425",
|
||||
EventTimeMS: 1782918600000,
|
||||
ReceivedAtMS: 1782918600000,
|
||||
ParseStatus: envelope.ParseOK,
|
||||
}
|
||||
payload, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
|
||||
processRealtimeMessage(
|
||||
context.Background(),
|
||||
discardRealtimeLogger{},
|
||||
registry,
|
||||
&contextCheckingRealtimeUpdater{},
|
||||
&contextCheckingMessageCommitter{},
|
||||
kafka.Message{Topic: "vehicle.raw.go.jt808.v1", Partition: 2, Offset: 10, HighWaterMark: 16, Value: payload},
|
||||
)
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_realtime_kafka_messages_total{status="received",topic="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_realtime_updates_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_realtime_kafka_commits_total{status="ok",topic="vehicle.raw.go.jt808.v1"} 1`,
|
||||
`vehicle_realtime_kafka_lag{partition="2",topic="vehicle.raw.go.jt808.v1"} 5`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("metrics missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeRealtimeUpdaterUpdatesBothStores(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001"}
|
||||
redisUpdater := &contextCheckingRealtimeUpdater{}
|
||||
snapshotUpdater := &contextCheckingRealtimeUpdater{}
|
||||
updater := compositeRealtimeUpdater{primary: redisUpdater, secondary: snapshotUpdater}
|
||||
|
||||
if err := updater.Update(context.Background(), env); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
if redisUpdater.count != 1 || snapshotUpdater.count != 1 {
|
||||
t.Fatalf("updates primary=%d secondary=%d", redisUpdater.count, snapshotUpdater.count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncSecondaryRealtimeUpdaterDoesNotBlockPrimaryPath(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001"}
|
||||
primary := &contextCheckingRealtimeUpdater{}
|
||||
secondary := &blockingRealtimeUpdater{started: make(chan struct{}), release: make(chan struct{})}
|
||||
registry := metrics.NewRegistry()
|
||||
updater := newAsyncSecondaryRealtimeUpdater(primary, secondary, 8, 1, registry, nil)
|
||||
defer updater.Close()
|
||||
|
||||
start := time.Now()
|
||||
if err := updater.Update(context.Background(), env); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("async updater blocked primary path for %v", elapsed)
|
||||
}
|
||||
if primary.count != 1 {
|
||||
t.Fatalf("primary updates = %d, want 1", primary.count)
|
||||
}
|
||||
select {
|
||||
case <-secondary.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("secondary update was not started asynchronously")
|
||||
}
|
||||
close(secondary.release)
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_realtime_async_queue_total{protocol="GB32960",status="queued",store="mysql"} 1`,
|
||||
`vehicle_realtime_async_queue_depth{protocol="GB32960",store="mysql"}`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("async queue metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncSecondaryQueueDropDoesNotFailPrimaryUpdate(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001"}
|
||||
primary := &contextCheckingRealtimeUpdater{}
|
||||
registry := metrics.NewRegistry()
|
||||
updater := &asyncSecondaryRealtimeUpdater{
|
||||
primary: primary,
|
||||
secondary: &contextCheckingRealtimeUpdater{},
|
||||
queue: make(chan envelope.FrameEnvelope, 1),
|
||||
registry: registry,
|
||||
}
|
||||
updater.queue <- envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN000"}
|
||||
|
||||
if err := updater.Update(context.Background(), env); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
if primary.count != 1 {
|
||||
t.Fatalf("primary updates = %d, want 1", primary.count)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_realtime_async_queue_total{protocol="JT808",status="dropped",store="mysql"} 1`,
|
||||
`vehicle_realtime_async_queue_depth{protocol="JT808",store="mysql"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("async queue drop metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMetricUpdaterRecordsStoreUpdateResults(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, VIN: "VIN001"}
|
||||
updater := storeMetricUpdater{
|
||||
store: "redis",
|
||||
delegate: &contextCheckingRealtimeUpdater{},
|
||||
registry: registry,
|
||||
}
|
||||
|
||||
if err := updater.Update(context.Background(), env); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_realtime_store_updates_total{protocol="JT808",status="ok",store="redis"} 1`,
|
||||
`vehicle_realtime_store_update_duration_ms{protocol="JT808",status="ok",store="redis"}`,
|
||||
`vehicle_realtime_store_update_duration_ms_histogram_bucket{le="+Inf",protocol="JT808",status="ok",store="redis"} 1`,
|
||||
`vehicle_realtime_store_update_duration_ms_histogram_count{protocol="JT808",status="ok",store="redis"} 1`,
|
||||
`vehicle_realtime_store_update_duration_ms_histogram_sum{protocol="JT808",status="ok",store="redis"}`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("store update metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryRealtimeUpdaterRetriesTransientMySQLDeadlock(t *testing.T) {
|
||||
delegate := &retryOnceRealtimeUpdater{err: errors.New("Error 1213 (40001): Deadlock found when trying to get lock")}
|
||||
updater := retryRealtimeUpdater{delegate: delegate, attempts: 3}
|
||||
|
||||
if err := updater.Update(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001"}); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
if delegate.count != 2 {
|
||||
t.Fatalf("delegate updates = %d, want 2", delegate.count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncSecondaryRealtimeUpdaterLogsSecondaryErrors(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001", EventID: "event-1"}
|
||||
logger := &recordingRealtimeLogger{}
|
||||
updater := newAsyncSecondaryRealtimeUpdater(nil, failingRealtimeUpdater{}, 8, 1, nil, logger)
|
||||
defer updater.Close()
|
||||
|
||||
if err := updater.Update(context.Background(), env); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if logger.containsError("async realtime secondary update failed") {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("secondary error was not logged: %#v", logger.errors)
|
||||
}
|
||||
|
||||
func TestKafkaTopicsFromEnvDefaultsToGoRawTopics(t *testing.T) {
|
||||
got := strings.Join(kafkaTopicsFromEnv(), ",")
|
||||
want := "vehicle.raw.go.gb32960.v1,vehicle.raw.go.jt808.v1,vehicle.raw.go.yutong-mqtt.v1"
|
||||
if got != want {
|
||||
t.Fatalf("topics = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeReaderConfigStartsAtLatestOffset(t *testing.T) {
|
||||
cfg := realtimeReaderConfig([]string{"127.0.0.1:9092"}, []string{"vehicle.raw.go.jt808.v1"})
|
||||
|
||||
if cfg.StartOffset != kafka.LastOffset {
|
||||
t.Fatalf("StartOffset = %d, want kafka.LastOffset %d", cfg.StartOffset, kafka.LastOffset)
|
||||
}
|
||||
if got := strings.Join(cfg.GroupTopics, ","); got != "vehicle.raw.go.jt808.v1" {
|
||||
t.Fatalf("GroupTopics = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeAPIDoesNotExposeDuplicateMileagePointRoute(t *testing.T) {
|
||||
source, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read main.go: %v", err)
|
||||
}
|
||||
if strings.Contains(string(source), "/api/history/mileage-points") {
|
||||
t.Fatalf("realtime api should expose mileage through /api/history/locations only")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeAPICachesBindingPlateResolver(t *testing.T) {
|
||||
source, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read main.go: %v", err)
|
||||
}
|
||||
for _, want := range []string{"NewCachedPlateResolver", "PLATE_CACHE_TTL_SECONDS"} {
|
||||
if !strings.Contains(string(source), want) {
|
||||
t.Fatalf("realtime api should configure cached plate resolver, missing %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeAPIExposesPostRawFrameQueryRoute(t *testing.T) {
|
||||
source, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read main.go: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(source), `"/api/history/raw-frames/query"`) {
|
||||
t.Fatalf("realtime api should expose POST raw frame query route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeAPIExposesOperationalRealtimeRoutes(t *testing.T) {
|
||||
source, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read main.go: %v", err)
|
||||
}
|
||||
for _, want := range []string{`"/api/realtime/online"`, `"/api/debug/pipeline"`} {
|
||||
if !strings.Contains(string(source), want) {
|
||||
t.Fatalf("realtime api should expose operational route %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeAPIDoesNotExposeMySQLKVRoute(t *testing.T) {
|
||||
source, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read main.go: %v", err)
|
||||
}
|
||||
if strings.Contains(string(source), `"/api/realtime/kv"`) || strings.Contains(string(source), "NewKVQuery") {
|
||||
t.Fatal("realtime api should not expose MySQL realtime kv route")
|
||||
}
|
||||
}
|
||||
|
||||
type contextCheckingRealtimeUpdater struct {
|
||||
ctxErr error
|
||||
count int
|
||||
}
|
||||
|
||||
func (u *contextCheckingRealtimeUpdater) Update(ctx context.Context, _ envelope.FrameEnvelope) error {
|
||||
u.ctxErr = ctx.Err()
|
||||
u.count++
|
||||
return u.ctxErr
|
||||
}
|
||||
|
||||
type contextCheckingMessageCommitter struct {
|
||||
ctxErr error
|
||||
count int
|
||||
}
|
||||
|
||||
func (c *contextCheckingMessageCommitter) CommitMessages(ctx context.Context, _ ...kafka.Message) error {
|
||||
c.ctxErr = ctx.Err()
|
||||
c.count++
|
||||
return c.ctxErr
|
||||
}
|
||||
|
||||
type discardRealtimeLogger struct{}
|
||||
|
||||
func (discardRealtimeLogger) Info(string, ...any) {}
|
||||
func (discardRealtimeLogger) Error(string, ...any) {}
|
||||
func (discardRealtimeLogger) Warn(string, ...any) {}
|
||||
|
||||
type recordingRealtimeLogger struct {
|
||||
mu sync.Mutex
|
||||
errors []string
|
||||
}
|
||||
|
||||
func (l *recordingRealtimeLogger) Info(string, ...any) {}
|
||||
func (l *recordingRealtimeLogger) Warn(string, ...any) {}
|
||||
func (l *recordingRealtimeLogger) Error(message string, _ ...any) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.errors = append(l.errors, message)
|
||||
}
|
||||
|
||||
func (l *recordingRealtimeLogger) containsError(message string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
for _, got := range l.errors {
|
||||
if got == message {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type blockingRealtimeUpdater struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (u *blockingRealtimeUpdater) Update(context.Context, envelope.FrameEnvelope) error {
|
||||
u.once.Do(func() { close(u.started) })
|
||||
<-u.release
|
||||
return nil
|
||||
}
|
||||
|
||||
type failingRealtimeUpdater struct{}
|
||||
|
||||
func (failingRealtimeUpdater) Update(context.Context, envelope.FrameEnvelope) error {
|
||||
return errTestRealtimeUpdate
|
||||
}
|
||||
|
||||
var errTestRealtimeUpdate = errors.New("test realtime update failed")
|
||||
|
||||
type retryOnceRealtimeUpdater struct {
|
||||
err error
|
||||
count int
|
||||
}
|
||||
|
||||
func (u *retryOnceRealtimeUpdater) Update(context.Context, envelope.FrameEnvelope) error {
|
||||
u.count++
|
||||
if u.count == 1 {
|
||||
return u.err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -14,8 +14,11 @@ import (
|
||||
"github.com/segmentio/kafka-go"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -34,6 +37,10 @@ func main() {
|
||||
logger.Error("mysql ping failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-stat-writer", []health.Check{
|
||||
{Name: "mysql", Check: db.PingContext},
|
||||
}, registry))
|
||||
|
||||
writer := stats.NewWriter(db, cfg.Location)
|
||||
if cfg.EnsureSchema {
|
||||
@@ -62,20 +69,84 @@ func main() {
|
||||
logger.Error("kafka fetch failed", "error", err)
|
||||
continue
|
||||
}
|
||||
processStatMessage(ctx, logger, registry, writer, reader, message)
|
||||
}
|
||||
}
|
||||
|
||||
const kafkaMessageOperationTimeout = 30 * time.Second
|
||||
|
||||
type statAppender interface {
|
||||
Append(context.Context, envelope.FrameEnvelope) error
|
||||
}
|
||||
|
||||
type kafkaMessageCommitter interface {
|
||||
CommitMessages(context.Context, ...kafka.Message) error
|
||||
}
|
||||
|
||||
var statWriteDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
||||
|
||||
func processStatMessage(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, message kafka.Message) {
|
||||
messageCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), kafkaMessageOperationTimeout)
|
||||
defer cancel()
|
||||
|
||||
addStatMetric(registry, "vehicle_stat_kafka_messages_total", message, "received")
|
||||
addStatLagMetric(registry, message)
|
||||
var env envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(message.Value, &env); err != nil {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_messages_total", message, "invalid_json")
|
||||
logger.Warn("skip invalid envelope json", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
_ = reader.CommitMessages(ctx, message)
|
||||
continue
|
||||
_ = committer.CommitMessages(messageCtx, message)
|
||||
return
|
||||
}
|
||||
if err := writer.Append(ctx, env); err != nil {
|
||||
started := time.Now()
|
||||
err := appender.Append(messageCtx, env)
|
||||
recordStatWriteDuration(registry, message, statusFromError(err), time.Since(started))
|
||||
if err != nil {
|
||||
addStatMetric(registry, "vehicle_stat_writes_total", message, "error")
|
||||
logger.Error("mysql append failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err)
|
||||
continue
|
||||
return
|
||||
}
|
||||
if err := reader.CommitMessages(ctx, message); err != nil {
|
||||
addStatMetric(registry, "vehicle_stat_writes_total", message, "ok")
|
||||
if err := committer.CommitMessages(messageCtx, message); err != nil {
|
||||
addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "error")
|
||||
logger.Error("kafka commit failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "error", err)
|
||||
return
|
||||
}
|
||||
addStatMetric(registry, "vehicle_stat_kafka_commits_total", message, "ok")
|
||||
}
|
||||
|
||||
func addStatMetric(registry *metrics.Registry, name string, message kafka.Message, status string) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.IncCounter(name, metrics.Labels{"topic": message.Topic, "status": status})
|
||||
}
|
||||
|
||||
func addStatLagMetric(registry *metrics.Registry, message kafka.Message) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.SetKafkaLag("vehicle_stat_kafka_lag", message.Topic, message.Partition, message.Offset, message.HighWaterMark)
|
||||
}
|
||||
|
||||
func recordStatWriteDuration(registry *metrics.Registry, message kafka.Message, status string, elapsed time.Duration) {
|
||||
if registry == nil {
|
||||
return
|
||||
}
|
||||
registry.ObserveHistogram("vehicle_stat_write_duration_ms_histogram", metrics.Labels{
|
||||
"topic": message.Topic,
|
||||
"status": status,
|
||||
}, statWriteDurationBucketsMS, float64(elapsed.Milliseconds()))
|
||||
}
|
||||
|
||||
func statusFromError(err error) string {
|
||||
if err != nil {
|
||||
return "error"
|
||||
}
|
||||
return "ok"
|
||||
}
|
||||
|
||||
type config struct {
|
||||
@@ -94,7 +165,7 @@ func loadConfig() config {
|
||||
}
|
||||
return config{
|
||||
KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")),
|
||||
KafkaTopics: splitCSV(env("KAFKA_TOPICS", "vehicle.raw.gb32960.v1,vehicle.raw.jt808.v1")),
|
||||
KafkaTopics: splitCSV(env("KAFKA_TOPICS", strings.Join([]string{topics.FieldsGB32960, topics.FieldsJT808, topics.FieldsYutongMQTT}, ","))),
|
||||
KafkaGroup: env("KAFKA_GROUP", "go-stat-writer"),
|
||||
MySQLDSN: env("MYSQL_DSN", ""),
|
||||
EnsureSchema: env("MYSQL_ENSURE_SCHEMA", "true") != "false",
|
||||
|
||||
115
go/vehicle-gateway/cmd/stat-writer/main_test.go
Normal file
115
go/vehicle-gateway/cmd/stat-writer/main_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/segmentio/kafka-go"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
)
|
||||
|
||||
func TestProcessStatMessageUsesUncancelledContextForAppendAndCommit(t *testing.T) {
|
||||
parent, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
env := envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
MessageID: "0x0200",
|
||||
Phone: "13307795425",
|
||||
EventTimeMS: 1782918600000,
|
||||
ReceivedAtMS: 1782918600000,
|
||||
ParseStatus: envelope.ParseOK,
|
||||
}
|
||||
payload, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appender := &contextCheckingStatAppender{}
|
||||
committer := &contextCheckingStatCommitter{}
|
||||
|
||||
processStatMessage(parent, discardStatLogger{}, nil, appender, committer, kafka.Message{Value: payload})
|
||||
|
||||
if appender.ctxErr != nil {
|
||||
t.Fatalf("appender saw cancelled context: %v", appender.ctxErr)
|
||||
}
|
||||
if committer.ctxErr != nil {
|
||||
t.Fatalf("committer saw cancelled context: %v", committer.ctxErr)
|
||||
}
|
||||
if appender.count != 1 || committer.count != 1 {
|
||||
t.Fatalf("appends=%d commits=%d", appender.count, committer.count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessStatMessageRecordsMetrics(t *testing.T) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
|
||||
payload, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := metrics.NewRegistry()
|
||||
|
||||
processStatMessage(
|
||||
context.Background(),
|
||||
discardStatLogger{},
|
||||
registry,
|
||||
&contextCheckingStatAppender{},
|
||||
&contextCheckingStatCommitter{},
|
||||
kafka.Message{Topic: "vehicle.raw.jt808.v1", Partition: 4, Offset: 30, HighWaterMark: 41, Value: payload},
|
||||
)
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_stat_kafka_messages_total{status="received",topic="vehicle.raw.jt808.v1"} 1`,
|
||||
`vehicle_stat_writes_total{status="ok",topic="vehicle.raw.jt808.v1"} 1`,
|
||||
`vehicle_stat_kafka_commits_total{status="ok",topic="vehicle.raw.jt808.v1"} 1`,
|
||||
`vehicle_stat_kafka_lag{partition="4",topic="vehicle.raw.jt808.v1"} 10`,
|
||||
`vehicle_stat_write_duration_ms_histogram_bucket{le="+Inf",status="ok",topic="vehicle.raw.jt808.v1"} 1`,
|
||||
`vehicle_stat_write_duration_ms_histogram_count{status="ok",topic="vehicle.raw.jt808.v1"} 1`,
|
||||
`vehicle_stat_write_duration_ms_histogram_sum{status="ok",topic="vehicle.raw.jt808.v1"}`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("metrics missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsToGoRawTopics(t *testing.T) {
|
||||
cfg := loadConfig()
|
||||
|
||||
got := strings.Join(cfg.KafkaTopics, ",")
|
||||
want := "vehicle.fields.go.gb32960.v1,vehicle.fields.go.jt808.v1,vehicle.fields.go.yutong-mqtt.v1"
|
||||
if got != want {
|
||||
t.Fatalf("KafkaTopics = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type contextCheckingStatAppender struct {
|
||||
ctxErr error
|
||||
count int
|
||||
}
|
||||
|
||||
func (a *contextCheckingStatAppender) Append(ctx context.Context, _ envelope.FrameEnvelope) error {
|
||||
a.ctxErr = ctx.Err()
|
||||
a.count++
|
||||
return a.ctxErr
|
||||
}
|
||||
|
||||
type contextCheckingStatCommitter struct {
|
||||
ctxErr error
|
||||
count int
|
||||
}
|
||||
|
||||
func (c *contextCheckingStatCommitter) CommitMessages(ctx context.Context, _ ...kafka.Message) error {
|
||||
c.ctxErr = ctx.Err()
|
||||
c.count++
|
||||
return c.ctxErr
|
||||
}
|
||||
|
||||
type discardStatLogger struct{}
|
||||
|
||||
func (discardStatLogger) Error(string, ...any) {}
|
||||
func (discardStatLogger) Warn(string, ...any) {}
|
||||
849
go/vehicle-gateway/cmd/stats-backfill/main.go
Normal file
849
go/vehicle-gateway/cmd/stats-backfill/main.go
Normal file
@@ -0,0 +1,849 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/taosdata/driver-go/v3/taosWS"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/history"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
MySQLDSN string
|
||||
TDengineDriver string
|
||||
TDengineDSN string
|
||||
TDengineDatabase string
|
||||
DateFrom string
|
||||
DateTo string
|
||||
Protocols []envelope.Protocol
|
||||
Method string
|
||||
Limit int
|
||||
DryRun bool
|
||||
Reset bool
|
||||
Debug bool
|
||||
ProgressEvery int64
|
||||
Location *time.Location
|
||||
}
|
||||
|
||||
type rawFrameRow struct {
|
||||
Protocol envelope.Protocol
|
||||
VIN string
|
||||
Phone string
|
||||
DeviceID string
|
||||
SourceEndpoint string
|
||||
EventID string
|
||||
MessageID string
|
||||
EventTimeMS int64
|
||||
ReceivedAtMS int64
|
||||
ParsedJSON string
|
||||
}
|
||||
|
||||
type metricAgg struct {
|
||||
VIN string
|
||||
Date string
|
||||
Protocol envelope.Protocol
|
||||
FirstKM float64
|
||||
LatestKM float64
|
||||
Count int64
|
||||
SourceKey string
|
||||
Phone string
|
||||
DeviceID string
|
||||
SourceEndpoint string
|
||||
FirstEventTime time.Time
|
||||
LatestEventTime time.Time
|
||||
QualityStatus string
|
||||
QualityReason string
|
||||
}
|
||||
|
||||
type dailySourceLast struct {
|
||||
VIN string
|
||||
SourceKey string
|
||||
Phone string
|
||||
DeviceID string
|
||||
SourceEndpoint string
|
||||
TS time.Time
|
||||
TotalKM float64
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := loadEnvFiles(env("BACKFILL_ENV_FILES", os.Getenv("BACKFILL_ENV_FILE"))); err != nil {
|
||||
fail("load env file", err)
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fail("load config", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
td, err := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN)
|
||||
if err != nil {
|
||||
fail("open tdengine", err)
|
||||
}
|
||||
defer td.Close()
|
||||
if err := td.PingContext(ctx); err != nil {
|
||||
fail("ping tdengine", err)
|
||||
}
|
||||
|
||||
mysqlDB, err := sql.Open("mysql", cfg.MySQLDSN)
|
||||
if err != nil {
|
||||
fail("open mysql", err)
|
||||
}
|
||||
defer mysqlDB.Close()
|
||||
if err := mysqlDB.PingContext(ctx); err != nil {
|
||||
fail("ping mysql", err)
|
||||
}
|
||||
|
||||
schemaWriter := stats.NewWriter(mysqlDB, cfg.Location)
|
||||
if err := schemaWriter.EnsureSchema(ctx); err != nil {
|
||||
fail("ensure schema", err)
|
||||
}
|
||||
if cfg.Reset && !cfg.DryRun {
|
||||
deleted, err := resetStats(ctx, mysqlDB, cfg)
|
||||
if err != nil {
|
||||
fail("reset stats", err)
|
||||
}
|
||||
slog.Info("reset stats rows", "deleted", deleted)
|
||||
}
|
||||
if cfg.Method == "last_diff" {
|
||||
aggregates, err := buildLastDiffAggregates(ctx, td, cfg)
|
||||
if err != nil {
|
||||
fail("build last-diff aggregates", err)
|
||||
}
|
||||
var written int64
|
||||
if !cfg.DryRun {
|
||||
written, err = writeAggregates(ctx, mysqlDB, aggregates, 500)
|
||||
if err != nil {
|
||||
fail("write aggregates", err)
|
||||
}
|
||||
}
|
||||
slog.Info("stats backfill complete", "method", cfg.Method, "dry_run", cfg.DryRun, "aggregates", len(aggregates), "written", written)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := queryRawFrames(ctx, td, cfg)
|
||||
if err != nil {
|
||||
fail("query raw frames", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
aggregates := map[string]*metricAgg{}
|
||||
var scanned, chunked, parsed, sampled, written, skipped int64
|
||||
for rows.Next() {
|
||||
scanned++
|
||||
row, err := scanRawFrame(rows)
|
||||
if err != nil {
|
||||
fail("scan raw frame", err)
|
||||
}
|
||||
text := strings.TrimSpace(row.ParsedJSON)
|
||||
if isChunkManifest(text) {
|
||||
chunked++
|
||||
text, err = readChunkedPayload(ctx, td, cfg.TDengineDatabase, row.EventID, "parsed_fields")
|
||||
if err != nil {
|
||||
slog.Warn("skip chunked parsed fields", "event_id", row.EventID, "error", err)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
fields := fieldsForStats(row.Protocol, row.VIN, text)
|
||||
if cfg.Debug && scanned <= 5 {
|
||||
slog.Info("debug raw frame",
|
||||
"protocol", row.Protocol,
|
||||
"vin", row.VIN,
|
||||
"message_id", row.MessageID,
|
||||
"parsed_len", len(text),
|
||||
"mileage_keys", mileageKeys(row.Protocol),
|
||||
"extracted_fields", fields)
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
parsed++
|
||||
env := envelope.FrameEnvelope{
|
||||
EventID: row.EventID,
|
||||
Protocol: row.Protocol,
|
||||
MessageID: row.MessageID,
|
||||
VIN: row.VIN,
|
||||
Phone: row.Phone,
|
||||
DeviceID: row.DeviceID,
|
||||
SourceEndpoint: row.SourceEndpoint,
|
||||
EventTimeMS: row.EventTimeMS,
|
||||
ReceivedAtMS: row.ReceivedAtMS,
|
||||
Fields: fields,
|
||||
ParsedFields: fields,
|
||||
ParseStatus: envelope.ParseOK,
|
||||
}
|
||||
samples, err := stats.SamplesFromEnvelope(env, cfg.Location)
|
||||
if err != nil {
|
||||
slog.Warn("skip invalid sample", "event_id", row.EventID, "error", err)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if len(samples) == 0 {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
sampled += int64(len(samples))
|
||||
addSamples(aggregates, samples)
|
||||
if cfg.ProgressEvery > 0 && scanned%cfg.ProgressEvery == 0 {
|
||||
slog.Info("stats backfill progress", "scanned", scanned, "sampled", sampled, "aggregates", len(aggregates), "skipped", skipped)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
fail("iterate raw frames", err)
|
||||
}
|
||||
if !cfg.DryRun {
|
||||
var err error
|
||||
written, err = writeAggregates(ctx, mysqlDB, aggregates, 500)
|
||||
if err != nil {
|
||||
fail("write aggregates", err)
|
||||
}
|
||||
}
|
||||
slog.Info("stats backfill complete", "dry_run", cfg.DryRun, "scanned", scanned, "parsed", parsed, "chunked", chunked, "sampled", sampled, "aggregates", len(aggregates), "written", written, "skipped", skipped)
|
||||
}
|
||||
|
||||
func addSamples(aggregates map[string]*metricAgg, samples []stats.MetricSample) {
|
||||
for _, sample := range samples {
|
||||
key := sample.VIN + "|" + sample.StatDate + "|" + string(sample.Protocol) + "|" + sample.SourceKey
|
||||
agg, ok := aggregates[key]
|
||||
if !ok {
|
||||
aggregates[key] = &metricAgg{
|
||||
VIN: sample.VIN,
|
||||
Date: sample.StatDate,
|
||||
Protocol: sample.Protocol,
|
||||
FirstKM: sample.TotalMileageKM,
|
||||
LatestKM: sample.TotalMileageKM,
|
||||
Count: 1,
|
||||
SourceKey: sample.SourceKey,
|
||||
Phone: sample.Phone,
|
||||
DeviceID: sample.DeviceID,
|
||||
SourceEndpoint: sample.SourceEndpoint,
|
||||
FirstEventTime: sample.EventTime,
|
||||
LatestEventTime: sample.EventTime,
|
||||
QualityStatus: stats.QualityNoPreviousBaseline,
|
||||
QualityReason: "scan_backfill_missing_previous_source",
|
||||
}
|
||||
continue
|
||||
}
|
||||
if sample.TotalMileageKM < agg.FirstKM {
|
||||
agg.FirstKM = sample.TotalMileageKM
|
||||
}
|
||||
if sample.TotalMileageKM > agg.LatestKM {
|
||||
agg.LatestKM = sample.TotalMileageKM
|
||||
}
|
||||
if agg.FirstEventTime.IsZero() || sample.EventTime.Before(agg.FirstEventTime) {
|
||||
agg.FirstEventTime = sample.EventTime
|
||||
}
|
||||
if sample.EventTime.After(agg.LatestEventTime) {
|
||||
agg.LatestEventTime = sample.EventTime
|
||||
agg.SourceEndpoint = sample.SourceEndpoint
|
||||
if strings.TrimSpace(sample.Phone) != "" {
|
||||
agg.Phone = sample.Phone
|
||||
}
|
||||
if strings.TrimSpace(sample.DeviceID) != "" {
|
||||
agg.DeviceID = sample.DeviceID
|
||||
}
|
||||
}
|
||||
agg.Count++
|
||||
}
|
||||
}
|
||||
|
||||
func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*metricAgg, batchSize int) (int64, error) {
|
||||
if len(aggregates) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var written int64
|
||||
clearedTargets := map[string]struct{}{}
|
||||
for _, agg := range aggregates {
|
||||
identity := stats.SourceIdentity{
|
||||
Protocol: agg.Protocol,
|
||||
SourceIP: stats.NormalizeSourceIP(agg.SourceEndpoint),
|
||||
SourceEndpoint: agg.SourceEndpoint,
|
||||
}
|
||||
if strings.TrimSpace(identity.SourceIP) == "" {
|
||||
continue
|
||||
}
|
||||
target := agg.VIN + "|" + agg.Date + "|" + string(agg.Protocol)
|
||||
if _, ok := clearedTargets[target]; !ok {
|
||||
if err := clearBackfillTargetMileage(ctx, db, agg.VIN, agg.Date, agg.Protocol); err != nil {
|
||||
return written, err
|
||||
}
|
||||
clearedTargets[target] = struct{}{}
|
||||
}
|
||||
if err := stats.UpsertDataSource(ctx, db, identity, agg.LatestEventTime); err != nil {
|
||||
return written, err
|
||||
}
|
||||
dailyKM := agg.LatestKM - agg.FirstKM
|
||||
candidate := stats.SourceMileageSample{
|
||||
VIN: agg.VIN,
|
||||
StatDate: agg.Date,
|
||||
Protocol: agg.Protocol,
|
||||
SourceKey: agg.SourceKey,
|
||||
SourceIP: identity.SourceIP,
|
||||
SourceEndpoint: agg.SourceEndpoint,
|
||||
Phone: agg.Phone,
|
||||
DeviceID: agg.DeviceID,
|
||||
FirstTotalKM: agg.FirstKM,
|
||||
LatestTotalKM: agg.LatestKM,
|
||||
DailyKM: dailyKM,
|
||||
SampleCount: agg.Count,
|
||||
FirstEventTime: agg.FirstEventTime,
|
||||
LatestEventTime: agg.LatestEventTime,
|
||||
QualityStatus: agg.QualityStatus,
|
||||
QualityReason: agg.QualityReason,
|
||||
}
|
||||
if candidate.QualityStatus == "" {
|
||||
candidate.QualityStatus = stats.QualityOK
|
||||
}
|
||||
if candidate.QualityReason == "" {
|
||||
candidate.QualityReason = "same_source_previous_day"
|
||||
}
|
||||
if candidate.QualityStatus == stats.QualityOK && (candidate.DailyKM < 0 || candidate.DailyKM > maxTrustedDailyMileageKM) {
|
||||
candidate.QualityStatus = stats.QualityInvalidDelta
|
||||
candidate.QualityReason = "outside_daily_range"
|
||||
}
|
||||
if err := stats.UpsertSourceMileage(ctx, db, candidate); err != nil {
|
||||
return written, err
|
||||
}
|
||||
if err := stats.ProjectDailyMileage(ctx, db, agg.VIN, agg.Date, agg.Protocol); err != nil {
|
||||
return written, err
|
||||
}
|
||||
written++
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func clearBackfillTargetMileage(ctx context.Context, db *sql.DB, vin string, statDate string, protocol envelope.Protocol) error {
|
||||
if db == nil || strings.TrimSpace(vin) == "" || strings.TrimSpace(statDate) == "" || strings.TrimSpace(string(protocol)) == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := db.ExecContext(ctx, "DELETE FROM vehicle_daily_mileage_source WHERE vin = ? AND stat_date = ? AND protocol = ?", vin, statDate, string(protocol))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.ExecContext(ctx, "DELETE FROM vehicle_daily_mileage WHERE vin = ? AND stat_date = ? AND protocol = ?", vin, statDate, string(protocol))
|
||||
return err
|
||||
}
|
||||
|
||||
func buildLastDiffAggregates(ctx context.Context, db *sql.DB, cfg config) (map[string]*metricAgg, error) {
|
||||
dates, err := dateRangeWithPrevious(cfg.DateFrom, cfg.DateTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lastByProtocolDate := map[envelope.Protocol]map[string]map[string][]dailySourceLast{}
|
||||
for _, protocol := range cfg.Protocols {
|
||||
lastByProtocolDate[protocol] = map[string]map[string][]dailySourceLast{}
|
||||
for _, date := range dates {
|
||||
rows, err := queryDailyLastSourceRows(ctx, db, cfg, protocol, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lastByProtocolDate[protocol][date] = rows
|
||||
slog.Info("daily last loaded", "protocol", protocol, "date", date, "vehicles", len(rows))
|
||||
}
|
||||
}
|
||||
targetDates, err := dateRange(cfg.DateFrom, cfg.DateTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aggregates := map[string]*metricAgg{}
|
||||
for _, protocol := range cfg.Protocols {
|
||||
for _, date := range targetDates {
|
||||
prevDate := previousDate(date)
|
||||
current := lastByProtocolDate[protocol][date]
|
||||
previous := lastByProtocolDate[protocol][prevDate]
|
||||
for vin, currentSources := range current {
|
||||
previousBySource := map[string]dailySourceLast{}
|
||||
for _, previousRow := range previous[vin] {
|
||||
previousBySource[previousRow.SourceKey] = previousRow
|
||||
}
|
||||
for _, currentRow := range currentSources {
|
||||
key := vin + "|" + date + "|" + string(protocol) + "|" + currentRow.SourceKey
|
||||
agg := &metricAgg{
|
||||
VIN: vin,
|
||||
Date: date,
|
||||
Protocol: protocol,
|
||||
FirstKM: currentRow.TotalKM,
|
||||
LatestKM: currentRow.TotalKM,
|
||||
Count: 1,
|
||||
SourceKey: currentRow.SourceKey,
|
||||
Phone: currentRow.Phone,
|
||||
DeviceID: currentRow.DeviceID,
|
||||
SourceEndpoint: currentRow.SourceEndpoint,
|
||||
FirstEventTime: currentRow.TS,
|
||||
LatestEventTime: currentRow.TS,
|
||||
QualityStatus: stats.QualityNoPreviousBaseline,
|
||||
QualityReason: "missing_previous_source",
|
||||
}
|
||||
if previousRow, ok := previousBySource[currentRow.SourceKey]; ok {
|
||||
agg.FirstKM = previousRow.TotalKM
|
||||
agg.FirstEventTime = previousRow.TS
|
||||
agg.QualityStatus = stats.QualityOK
|
||||
agg.QualityReason = "same_source_previous_day"
|
||||
}
|
||||
aggregates[key] = agg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return aggregates, nil
|
||||
}
|
||||
|
||||
type trustedChoice struct {
|
||||
current dailySourceLast
|
||||
previous dailySourceLast
|
||||
}
|
||||
|
||||
const maxTrustedDailyMileageKM = 1000
|
||||
|
||||
func chooseTrustedSource(current []dailySourceLast, previous []dailySourceLast) (trustedChoice, bool) {
|
||||
previousBySource := map[string]dailySourceLast{}
|
||||
for _, row := range previous {
|
||||
previousBySource[row.SourceKey] = row
|
||||
}
|
||||
var chosen trustedChoice
|
||||
var chosenDelta float64
|
||||
for _, currentRow := range current {
|
||||
previousRow, ok := previousBySource[currentRow.SourceKey]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delta := currentRow.TotalKM - previousRow.TotalKM
|
||||
if delta < 0 || delta > maxTrustedDailyMileageKM {
|
||||
continue
|
||||
}
|
||||
if chosen.current.SourceKey == "" || delta < chosenDelta {
|
||||
chosen = trustedChoice{current: currentRow, previous: previousRow}
|
||||
chosenDelta = delta
|
||||
}
|
||||
}
|
||||
return chosen, chosen.current.SourceKey != ""
|
||||
}
|
||||
|
||||
func queryDailyLastSourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, date string) (map[string][]dailySourceLast, error) {
|
||||
where := []string{
|
||||
fmt.Sprintf("ts >= '%s 00:00:00'", quote(date)),
|
||||
fmt.Sprintf("ts < '%s 00:00:00'", quote(nextDate(date))),
|
||||
"parse_status = 'OK'",
|
||||
"vin IS NOT NULL",
|
||||
"vin <> ''",
|
||||
fmt.Sprintf("protocol = '%s'", quote(string(protocol))),
|
||||
realtimeMileageFramePredicate(),
|
||||
}
|
||||
sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, LAST(ts), LAST(parsed_json)
|
||||
FROM %s.raw_frames
|
||||
WHERE %s
|
||||
GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
|
||||
rows, err := db.QueryContext(ctx, sqlText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
latestBySource := map[string]dailySourceLast{}
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
var phone string
|
||||
var deviceID string
|
||||
var sourceEndpoint string
|
||||
var ts time.Time
|
||||
var parsedJSON string
|
||||
if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &ts, &parsedJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields := fieldsForStats(protocol, vin, parsedJSON)
|
||||
env := envelope.FrameEnvelope{
|
||||
Protocol: protocol,
|
||||
VIN: strings.TrimSpace(vin),
|
||||
EventTimeMS: time.Now().UnixMilli(),
|
||||
ReceivedAtMS: time.Now().UnixMilli(),
|
||||
Fields: fields,
|
||||
ParsedFields: fields,
|
||||
ParseStatus: envelope.ParseOK,
|
||||
}
|
||||
samples, err := stats.SamplesFromEnvelope(env, cfg.Location)
|
||||
if err != nil || len(samples) == 0 {
|
||||
continue
|
||||
}
|
||||
sourceKey := normalizedSourceKey(string(protocol), phone, deviceID, sourceEndpoint)
|
||||
if sourceKey == "" {
|
||||
continue
|
||||
}
|
||||
row := dailySourceLast{
|
||||
VIN: strings.TrimSpace(vin),
|
||||
SourceKey: sourceKey,
|
||||
Phone: strings.TrimSpace(phone),
|
||||
DeviceID: strings.TrimSpace(deviceID),
|
||||
SourceEndpoint: strings.TrimSpace(sourceEndpoint),
|
||||
TS: ts,
|
||||
TotalKM: samples[0].TotalMileageKM,
|
||||
}
|
||||
key := row.VIN + "|" + row.SourceKey
|
||||
if existing, ok := latestBySource[key]; !ok || row.TS.After(existing.TS) {
|
||||
latestBySource[key] = row
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string][]dailySourceLast{}
|
||||
for _, row := range latestBySource {
|
||||
out[row.VIN] = append(out[row.VIN], row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizedSourceKey(protocol string, phone string, deviceID string, endpoint string) string {
|
||||
sourceIP := stats.NormalizeSourceIP(endpoint)
|
||||
return stats.SourceKey(envelope.Protocol(protocol), phone, deviceID, sourceIP)
|
||||
}
|
||||
|
||||
func fieldsForStats(protocol envelope.Protocol, vin string, text string) map[string]any {
|
||||
fields := map[string]any{}
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return fields
|
||||
}
|
||||
parsed := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(text), &parsed); err == nil && len(parsed) > 0 {
|
||||
if flattened, _, ok := realtime.ParsedFieldsForEnvelope(envelope.FrameEnvelope{
|
||||
Protocol: protocol,
|
||||
VIN: vin,
|
||||
Parsed: parsed,
|
||||
}); ok {
|
||||
fields = flattened
|
||||
} else {
|
||||
fields = parsed
|
||||
}
|
||||
}
|
||||
for _, key := range mileageKeys(protocol) {
|
||||
if _, exists := fields[key]; exists {
|
||||
continue
|
||||
}
|
||||
value, ok := extractJSONStringField(text, key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fields[key] = value
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func mileageKeys(protocol envelope.Protocol) []string {
|
||||
switch protocol {
|
||||
case envelope.ProtocolGB32960:
|
||||
return []string{"gb32960.vehicle.total_mileage_km"}
|
||||
case envelope.ProtocolJT808:
|
||||
return []string{"jt808.location.total_mileage_km"}
|
||||
case envelope.ProtocolYutongMQTT:
|
||||
return []string{"yutong_mqtt.data.total_mileage", "yutong_mqtt.root.data.total_mileage"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func extractJSONStringField(text string, key string) (string, bool) {
|
||||
pattern := regexp.QuoteMeta(`"`+key+`"`) + `\s*:\s*"?([^",}]+)"?`
|
||||
match := regexp.MustCompile(pattern).FindStringSubmatch(text)
|
||||
if len(match) != 2 {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(match[1]), true
|
||||
}
|
||||
|
||||
func loadConfig() (config, error) {
|
||||
loc, err := time.LoadLocation(env("LOCAL_TZ", "Asia/Shanghai"))
|
||||
if err != nil {
|
||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
}
|
||||
dateTo := env("BACKFILL_DATE_TO", time.Now().In(loc).Format("2006-01-02"))
|
||||
dateFrom := env("BACKFILL_DATE_FROM", dateTo)
|
||||
protocols, err := parseProtocols(env("BACKFILL_PROTOCOLS", "GB32960,JT808,YUTONG_MQTT"))
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
return config{
|
||||
MySQLDSN: env("MYSQL_DSN", ""),
|
||||
TDengineDriver: env("TDENGINE_DRIVER", "taosWS"),
|
||||
TDengineDSN: env("TDENGINE_DSN", ""),
|
||||
TDengineDatabase: env("TDENGINE_DATABASE", history.DefaultDatabase),
|
||||
DateFrom: dateFrom,
|
||||
DateTo: dateTo,
|
||||
Protocols: protocols,
|
||||
Method: env("BACKFILL_METHOD", "last_diff"),
|
||||
Limit: envInt("BACKFILL_LIMIT", 0),
|
||||
DryRun: envBool("BACKFILL_DRY_RUN", true),
|
||||
Reset: envBool("BACKFILL_RESET", false),
|
||||
Debug: envBool("BACKFILL_DEBUG", false),
|
||||
ProgressEvery: int64(envInt("BACKFILL_PROGRESS_EVERY", 100000)),
|
||||
Location: loc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func queryRawFrames(ctx context.Context, db *sql.DB, cfg config) (*sql.Rows, error) {
|
||||
where := []string{
|
||||
fmt.Sprintf("ts >= '%s 00:00:00'", quote(cfg.DateFrom)),
|
||||
fmt.Sprintf("ts < '%s 00:00:00'", quote(nextDate(cfg.DateTo))),
|
||||
"parse_status = 'OK'",
|
||||
"vin IS NOT NULL",
|
||||
"vin <> ''",
|
||||
}
|
||||
if len(cfg.Protocols) > 0 {
|
||||
quoted := make([]string, 0, len(cfg.Protocols))
|
||||
for _, protocol := range cfg.Protocols {
|
||||
quoted = append(quoted, "'"+quote(string(protocol))+"'")
|
||||
}
|
||||
where = append(where, "protocol IN ("+strings.Join(quoted, ",")+")")
|
||||
}
|
||||
where = append(where, realtimeMileageFramePredicate())
|
||||
sqlText := fmt.Sprintf(`SELECT protocol, vin, phone, device_id, source_endpoint, event_id, message_id, event_time, received_at, parsed_json
|
||||
FROM %s.raw_frames
|
||||
WHERE %s
|
||||
ORDER BY ts ASC`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
|
||||
if cfg.Limit > 0 {
|
||||
sqlText += fmt.Sprintf(" LIMIT %d", cfg.Limit)
|
||||
}
|
||||
slog.Info("query raw frames", "date_from", cfg.DateFrom, "date_to", cfg.DateTo, "protocols", cfg.Protocols, "limit", cfg.Limit)
|
||||
return db.QueryContext(ctx, sqlText)
|
||||
}
|
||||
|
||||
func realtimeMileageFramePredicate() string {
|
||||
return `(
|
||||
(protocol = 'GB32960' AND message_id IN (2,3))
|
||||
OR (protocol = 'JT808' AND message_id = 512)
|
||||
OR (protocol = 'YUTONG_MQTT')
|
||||
)`
|
||||
}
|
||||
|
||||
func scanRawFrame(rows *sql.Rows) (rawFrameRow, error) {
|
||||
var protocol, vin, phone, deviceID, sourceEndpoint, eventID, parsed string
|
||||
var messageID int64
|
||||
var eventTime, receivedAt time.Time
|
||||
if err := rows.Scan(&protocol, &vin, &phone, &deviceID, &sourceEndpoint, &eventID, &messageID, &eventTime, &receivedAt, &parsed); err != nil {
|
||||
return rawFrameRow{}, err
|
||||
}
|
||||
return rawFrameRow{
|
||||
Protocol: envelope.Protocol(strings.TrimSpace(protocol)),
|
||||
VIN: strings.TrimSpace(vin),
|
||||
Phone: strings.TrimSpace(phone),
|
||||
DeviceID: strings.TrimSpace(deviceID),
|
||||
SourceEndpoint: strings.TrimSpace(sourceEndpoint),
|
||||
EventID: strings.TrimSpace(eventID),
|
||||
MessageID: fmt.Sprintf("0x%04X", messageID),
|
||||
EventTimeMS: eventTime.UnixMilli(),
|
||||
ReceivedAtMS: receivedAt.UnixMilli(),
|
||||
ParsedJSON: parsed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isChunkManifest(text string) bool {
|
||||
if !strings.Contains(text, "chunked") || !strings.Contains(text, "payload_kind") {
|
||||
return false
|
||||
}
|
||||
var manifest struct {
|
||||
Chunked bool `json:"chunked"`
|
||||
}
|
||||
return json.Unmarshal([]byte(text), &manifest) == nil && manifest.Chunked
|
||||
}
|
||||
|
||||
func readChunkedPayload(ctx context.Context, db *sql.DB, database string, eventID string, kind string) (string, error) {
|
||||
sqlText := fmt.Sprintf(`SELECT chunk_text FROM %s.raw_frame_payload_chunks
|
||||
WHERE event_id = '%s' AND payload_kind = '%s'
|
||||
ORDER BY chunk_index ASC`, ident(database), quote(eventID), quote(kind))
|
||||
rows, err := db.QueryContext(ctx, sqlText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
var b strings.Builder
|
||||
for rows.Next() {
|
||||
var chunk string
|
||||
if err := rows.Scan(&chunk); err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteString(chunk)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return "", fmt.Errorf("no chunks found")
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func resetStats(ctx context.Context, db *sql.DB, cfg config) (int64, error) {
|
||||
clauses := []string{"stat_date >= ?", "stat_date <= ?"}
|
||||
args := []any{cfg.DateFrom, cfg.DateTo}
|
||||
if len(cfg.Protocols) > 0 {
|
||||
placeholders := make([]string, 0, len(cfg.Protocols))
|
||||
for _, protocol := range cfg.Protocols {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, string(protocol))
|
||||
}
|
||||
clauses = append(clauses, "protocol IN ("+strings.Join(placeholders, ",")+")")
|
||||
}
|
||||
var deleted int64
|
||||
for _, table := range []string{"vehicle_daily_mileage_source", "vehicle_daily_mileage"} {
|
||||
result, err := db.ExecContext(ctx, "DELETE FROM "+table+" WHERE "+strings.Join(clauses, " AND "), args...)
|
||||
if err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
deleted += affected
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func parseProtocols(value string) ([]envelope.Protocol, error) {
|
||||
var protocols []envelope.Protocol
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = strings.ToUpper(strings.TrimSpace(part))
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
switch envelope.Protocol(part) {
|
||||
case envelope.ProtocolGB32960, envelope.ProtocolJT808, envelope.ProtocolYutongMQTT:
|
||||
protocols = append(protocols, envelope.Protocol(part))
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported protocol %q", part)
|
||||
}
|
||||
}
|
||||
return protocols, nil
|
||||
}
|
||||
|
||||
func loadEnvFiles(paths string) error {
|
||||
for _, path := range strings.Split(paths, ",") {
|
||||
if err := loadEnvFile(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadEnvFile(path string) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
|
||||
continue
|
||||
}
|
||||
key, value, _ := strings.Cut(line, "=")
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.Trim(strings.TrimSpace(value), "\"'")
|
||||
if key != "" && os.Getenv(key) == "" {
|
||||
_ = os.Setenv(key, value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func env(key string, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envBool(key string, fallback bool) bool {
|
||||
value := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value == "1" || value == "true" || value == "yes" || value == "y"
|
||||
}
|
||||
|
||||
func nextDate(date string) string {
|
||||
parsed, err := time.Parse("2006-01-02", date)
|
||||
if err != nil {
|
||||
return date
|
||||
}
|
||||
return parsed.AddDate(0, 0, 1).Format("2006-01-02")
|
||||
}
|
||||
|
||||
func previousDate(date string) string {
|
||||
parsed, err := time.Parse("2006-01-02", date)
|
||||
if err != nil {
|
||||
return date
|
||||
}
|
||||
return parsed.AddDate(0, 0, -1).Format("2006-01-02")
|
||||
}
|
||||
|
||||
func dateRangeWithPrevious(from string, to string) ([]string, error) {
|
||||
dates, err := dateRange(from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append([]string{previousDate(from)}, dates...), nil
|
||||
}
|
||||
|
||||
func dateRange(from string, to string) ([]string, error) {
|
||||
start, err := time.Parse("2006-01-02", from)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
end, err := time.Parse("2006-01-02", to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if end.Before(start) {
|
||||
return nil, fmt.Errorf("dateTo before dateFrom")
|
||||
}
|
||||
var dates []string
|
||||
for day := start; !day.After(end); day = day.AddDate(0, 0, 1) {
|
||||
dates = append(dates, day.Format("2006-01-02"))
|
||||
}
|
||||
return dates, nil
|
||||
}
|
||||
|
||||
func ident(value string) string {
|
||||
value = strings.TrimSpace(strings.Trim(value, "`"))
|
||||
if value == "" {
|
||||
return history.DefaultDatabase
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func quote(value string) string {
|
||||
return strings.ReplaceAll(value, "'", "''")
|
||||
}
|
||||
|
||||
func fail(stage string, err error) {
|
||||
slog.Error(stage, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
266
go/vehicle-gateway/cmd/stats-backfill/main_test.go
Normal file
266
go/vehicle-gateway/cmd/stats-backfill/main_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats"
|
||||
)
|
||||
|
||||
func TestChooseTrustedSourceKeepsContinuingSourceAndRejectsNewJump(t *testing.T) {
|
||||
previous := []dailySourceLast{{
|
||||
VIN: "LA9GG64L7PBAF4001",
|
||||
SourceKey: normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:42630"),
|
||||
Phone: "13307765812",
|
||||
SourceEndpoint: "115.231.168.135:42630",
|
||||
TotalKM: 4100.8,
|
||||
}}
|
||||
current := []dailySourceLast{
|
||||
{
|
||||
VIN: "LA9GG64L7PBAF4001",
|
||||
SourceKey: normalizedSourceKey("JT808", "13307765812", "", "115.159.85.149:28316"),
|
||||
Phone: "13307765812",
|
||||
SourceEndpoint: "115.159.85.149:28316",
|
||||
TotalKM: 42447.2,
|
||||
},
|
||||
{
|
||||
VIN: "LA9GG64L7PBAF4001",
|
||||
SourceKey: normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:20215"),
|
||||
Phone: "13307765812",
|
||||
SourceEndpoint: "115.231.168.135:20215",
|
||||
TotalKM: 4123.9,
|
||||
},
|
||||
}
|
||||
|
||||
chosen, ok := chooseTrustedSource(current, previous)
|
||||
if !ok {
|
||||
t.Fatal("chooseTrustedSource() did not choose a source")
|
||||
}
|
||||
if chosen.current.SourceKey != normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:20215") {
|
||||
t.Fatalf("chosen source = %q", chosen.current.SourceKey)
|
||||
}
|
||||
if delta := chosen.current.TotalKM - chosen.previous.TotalKM; delta < 23 || delta > 24 {
|
||||
t.Fatalf("delta = %v, want about 23.1", delta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailySourceLastBuildsCandidateKeysBySourceIP(t *testing.T) {
|
||||
sourceA := dailySourceLast{
|
||||
VIN: "LA9GG64L7PBAF4001",
|
||||
SourceKey: normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:20215"),
|
||||
Phone: "13307765812",
|
||||
SourceEndpoint: "115.231.168.135:20215",
|
||||
TotalKM: 4123.9,
|
||||
}
|
||||
sourceB := dailySourceLast{
|
||||
VIN: "LA9GG64L7PBAF4001",
|
||||
SourceKey: normalizedSourceKey("JT808", "13307765812", "", "115.231.168.135:42630"),
|
||||
Phone: "13307765812",
|
||||
SourceEndpoint: "115.231.168.135:42630",
|
||||
TotalKM: 4100.8,
|
||||
}
|
||||
if sourceA.SourceKey != sourceB.SourceKey {
|
||||
t.Fatalf("same source IP should produce same source key: %q vs %q", sourceA.SourceKey, sourceB.SourceKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearBackfillTargetMileageClearsExactKey(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectExec(`DELETE FROM vehicle_daily_mileage_source WHERE vin = \? AND stat_date = \? AND protocol = \?`).
|
||||
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`DELETE FROM vehicle_daily_mileage WHERE vin = \? AND stat_date = \? AND protocol = \?`).
|
||||
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
if err := clearBackfillTargetMileage(context.Background(), db, "LA9GG64L7PBAF4001", "2026-07-08", envelope.ProtocolJT808); err != nil {
|
||||
t.Fatalf("clearBackfillTargetMileage() error = %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAggregatesClearsTargetRowsBeforeStaleCandidateUpsert(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.MatchExpectationsInOrder(true)
|
||||
|
||||
projNow := time.Date(2026, 7, 8, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
aggregates := map[string]*metricAgg{
|
||||
"LA9GG64L7PBAF4001|2026-07-08|JT808|JT808:13307765812@115.231.168.135": {
|
||||
VIN: "LA9GG64L7PBAF4001",
|
||||
Date: "2026-07-08",
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
FirstKM: 4100,
|
||||
LatestKM: 4101,
|
||||
Count: 1,
|
||||
SourceKey: "JT808:13307765812@115.231.168.135",
|
||||
Phone: "13307765812",
|
||||
SourceEndpoint: "115.231.168.135:20215",
|
||||
FirstEventTime: projNow,
|
||||
LatestEventTime: projNow,
|
||||
QualityStatus: stats.QualityInvalidDelta,
|
||||
QualityReason: "outside_daily_range",
|
||||
},
|
||||
}
|
||||
|
||||
mock.ExpectExec(`DELETE FROM vehicle_daily_mileage_source WHERE vin = \? AND stat_date = \? AND protocol = \?`).
|
||||
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`DELETE FROM vehicle_daily_mileage WHERE vin = \? AND stat_date = \? AND protocol = \?`).
|
||||
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO vehicle_data_source`).
|
||||
WithArgs("JT808", "115.231.168.135", "115.231.168.135:20215", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
|
||||
WithArgs(
|
||||
"LA9GG64L7PBAF4001",
|
||||
"2026-07-08",
|
||||
"JT808",
|
||||
"JT808:13307765812@115.231.168.135",
|
||||
"115.231.168.135",
|
||||
"115.231.168.135:20215",
|
||||
"13307765812",
|
||||
"",
|
||||
"",
|
||||
float64(4100),
|
||||
float64(4101),
|
||||
float64(1),
|
||||
int64(1),
|
||||
projNow,
|
||||
projNow,
|
||||
stats.QualityInvalidDelta,
|
||||
"outside_daily_range",
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source`).
|
||||
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
|
||||
WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(1000)).
|
||||
WillReturnResult(sqlmock.NewResult(1, 0))
|
||||
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source s`).
|
||||
WithArgs(
|
||||
"LA9GG64L7PBAF4001",
|
||||
"2026-07-08",
|
||||
"JT808",
|
||||
int64(1000),
|
||||
"LA9GG64L7PBAF4001",
|
||||
"2026-07-08",
|
||||
"JT808",
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(`DELETE FROM vehicle_daily_mileage`).
|
||||
WithArgs(
|
||||
"LA9GG64L7PBAF4001",
|
||||
"2026-07-08",
|
||||
"JT808",
|
||||
"LA9GG64L7PBAF4001",
|
||||
"2026-07-08",
|
||||
"JT808",
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
written, err := writeAggregates(context.Background(), db, aggregates, 500)
|
||||
if err != nil {
|
||||
t.Fatalf("writeAggregates() error = %v", err)
|
||||
}
|
||||
if written != 1 {
|
||||
t.Fatalf("written = %d, want 1", written)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAggregatesSkipsBlankSourceBeforeClearingTarget(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
written, err := writeAggregates(context.Background(), db, map[string]*metricAgg{
|
||||
"LA9GG64L7PBAF4001|2026-07-08|JT808|JT808:13307765812@": {
|
||||
VIN: "LA9GG64L7PBAF4001",
|
||||
Date: "2026-07-08",
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
FirstKM: 4100,
|
||||
LatestKM: 4101,
|
||||
Count: 1,
|
||||
SourceKey: "JT808:13307765812@",
|
||||
Phone: "13307765812",
|
||||
SourceEndpoint: "",
|
||||
FirstEventTime: time.Date(2026, 7, 8, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
|
||||
LatestEventTime: time.Date(2026, 7, 8, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
|
||||
QualityStatus: stats.QualityNoPreviousBaseline,
|
||||
QualityReason: "missing_previous_source",
|
||||
},
|
||||
}, 500)
|
||||
if err != nil {
|
||||
t.Fatalf("writeAggregates() error = %v", err)
|
||||
}
|
||||
if written != 0 {
|
||||
t.Fatalf("written = %d, want 0", written)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("sql expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddSamplesKeepsScanCandidatesNoPreviousBaseline(t *testing.T) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
samples, err := stats.SamplesFromEnvelope(envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
VIN: "LA9GG64L7PBAF4001",
|
||||
Phone: "13307765812",
|
||||
SourceEndpoint: "115.231.168.135:20215",
|
||||
EventTimeMS: time.Date(2026, 7, 8, 12, 0, 0, 0, loc).UnixMilli(),
|
||||
Fields: map[string]any{
|
||||
"jt808.location.total_mileage_km": 4123.9,
|
||||
},
|
||||
}, loc)
|
||||
if err != nil {
|
||||
t.Fatalf("SamplesFromEnvelope() error = %v", err)
|
||||
}
|
||||
aggregates := map[string]*metricAgg{}
|
||||
addSamples(aggregates, samples)
|
||||
if len(aggregates) != 1 {
|
||||
t.Fatalf("aggregate count = %d, want 1", len(aggregates))
|
||||
}
|
||||
for _, agg := range aggregates {
|
||||
if agg.QualityStatus != stats.QualityNoPreviousBaseline {
|
||||
t.Fatalf("quality = %q, want %q", agg.QualityStatus, stats.QualityNoPreviousBaseline)
|
||||
}
|
||||
if agg.QualityReason != "scan_backfill_missing_previous_source" {
|
||||
t.Fatalf("quality reason = %q", agg.QualityReason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaultsBackfillMethodToLastDiff(t *testing.T) {
|
||||
t.Setenv("BACKFILL_METHOD", "")
|
||||
t.Setenv("BACKFILL_DATE_FROM", "2026-07-08")
|
||||
t.Setenv("BACKFILL_DATE_TO", "2026-07-08")
|
||||
t.Setenv("BACKFILL_PROTOCOLS", "JT808")
|
||||
t.Setenv("LOCAL_TZ", "Asia/Shanghai")
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
if cfg.Method != "last_diff" {
|
||||
t.Fatalf("method = %q, want last_diff", cfg.Method)
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,11 @@ require (
|
||||
github.com/alicebob/miniredis/v2 v2.35.0
|
||||
github.com/eclipse/paho.mqtt.golang v1.5.1
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/nats-io/nats.go v1.52.0
|
||||
github.com/redis/go-redis/v9 v9.17.2
|
||||
github.com/segmentio/kafka-go v0.4.49
|
||||
github.com/taosdata/driver-go/v3 v3.8.1
|
||||
golang.org/x/text v0.29.0
|
||||
golang.org/x/text v0.35.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -20,11 +21,15 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.15.9 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.15 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
golang.org/x/net v0.44.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
)
|
||||
|
||||
@@ -28,12 +28,18 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
|
||||
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
|
||||
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
|
||||
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
|
||||
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -61,12 +67,16 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
148
go/vehicle-gateway/internal/capacity/check.go
Normal file
148
go/vehicle-gateway/internal/capacity/check.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package capacity
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusOK Status = "ok"
|
||||
StatusDegraded Status = "degraded"
|
||||
)
|
||||
|
||||
type Report struct {
|
||||
Status Status `json:"status"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
Totals Totals `json:"totals"`
|
||||
Services []ServiceCheck `json:"services"`
|
||||
Findings []string `json:"findings"`
|
||||
}
|
||||
|
||||
type Totals struct {
|
||||
ActiveConnections int64 `json:"active_connections"`
|
||||
KafkaLag float64 `json:"kafka_lag"`
|
||||
BridgeConsumerPending float64 `json:"bridge_consumer_pending"`
|
||||
BridgeAckPending float64 `json:"bridge_ack_pending"`
|
||||
BridgeBatchPendingMessages float64 `json:"bridge_batch_pending_messages"`
|
||||
FastWriterConsumerPending float64 `json:"fast_writer_consumer_pending"`
|
||||
FastWriterAckPending float64 `json:"fast_writer_ack_pending"`
|
||||
FastWriterBatchPending float64 `json:"fast_writer_batch_pending_messages"`
|
||||
HistoryBatchPending float64 `json:"history_batch_pending_messages"`
|
||||
HistoryRowsPending float64 `json:"history_rows_pending"`
|
||||
}
|
||||
|
||||
type ServiceCheck struct {
|
||||
Name string `json:"name"`
|
||||
Metrics map[string]float64 `json:"metrics"`
|
||||
}
|
||||
|
||||
func Evaluate(metricsByService map[string]string) Report {
|
||||
report := Report{
|
||||
Status: StatusOK,
|
||||
CheckedAt: time.Now().UTC(),
|
||||
Services: make([]ServiceCheck, 0, len(metricsByService)),
|
||||
}
|
||||
var names []string
|
||||
for name := range metricsByService {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
metrics := parseMetrics(metricsByService[name])
|
||||
report.Services = append(report.Services, ServiceCheck{Name: name, Metrics: metrics})
|
||||
report.Totals.ActiveConnections += int64(metrics["vehicle_gateway_active_connections"])
|
||||
report.Totals.KafkaLag += metrics["vehicle_history_kafka_lag"]
|
||||
report.Totals.KafkaLag += metrics["vehicle_stat_kafka_lag"]
|
||||
report.Totals.KafkaLag += metrics["vehicle_realtime_kafka_lag"]
|
||||
report.Totals.BridgeConsumerPending += metrics["vehicle_bridge_nats_consumer_pending"]
|
||||
report.Totals.BridgeAckPending += metrics["vehicle_bridge_nats_consumer_ack_pending"]
|
||||
report.Totals.BridgeBatchPendingMessages += metrics["vehicle_bridge_batch_pending_messages"]
|
||||
report.Totals.FastWriterConsumerPending += metrics["vehicle_fast_writer_nats_consumer_pending"]
|
||||
report.Totals.FastWriterAckPending += metrics["vehicle_fast_writer_nats_consumer_ack_pending"]
|
||||
report.Totals.FastWriterBatchPending += metrics["vehicle_fast_writer_batch_pending_messages"]
|
||||
report.Totals.HistoryBatchPending += metrics["vehicle_history_batch_pending_messages"]
|
||||
report.Totals.HistoryRowsPending += metrics["vehicle_history_batch_pending_rows"]
|
||||
report.Findings = append(report.Findings, findingsForMetrics(metrics)...)
|
||||
}
|
||||
if len(report.Findings) > 0 {
|
||||
report.Status = StatusDegraded
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func findingsForMetrics(metrics map[string]float64) []string {
|
||||
var findings []string
|
||||
if value := metrics["vehicle_gateway_connection_rejections_total"]; value > 0 {
|
||||
findings = append(findings, fmt.Sprintf("gateway connection rejections %.0f", value))
|
||||
}
|
||||
if value := metrics["vehicle_async_sink_queue_depth"]; value > 10_000 {
|
||||
findings = append(findings, fmt.Sprintf("async sink queue depth %.0f exceeds 10000", value))
|
||||
}
|
||||
if value := metrics["vehicle_bridge_nats_consumer_ack_pending"]; value > 100 {
|
||||
findings = append(findings, fmt.Sprintf("bridge ack pending %.0f exceeds 100", value))
|
||||
}
|
||||
if value := metrics["vehicle_bridge_nats_consumer_pending"]; value > 10_000 {
|
||||
findings = append(findings, fmt.Sprintf("bridge consumer pending %.0f exceeds 10000", value))
|
||||
}
|
||||
if value := metrics["vehicle_bridge_batch_pending_messages"]; value > 1000 {
|
||||
findings = append(findings, fmt.Sprintf("bridge batch pending %.0f exceeds 1000", value))
|
||||
}
|
||||
if value := metrics["vehicle_fast_writer_nats_consumer_ack_pending"]; value > 10 {
|
||||
findings = append(findings, fmt.Sprintf("fast writer ack pending %.0f exceeds 10", value))
|
||||
}
|
||||
if value := metrics["vehicle_fast_writer_nats_consumer_pending"]; value > 10_000 {
|
||||
findings = append(findings, fmt.Sprintf("fast writer consumer pending %.0f exceeds 10000", value))
|
||||
}
|
||||
if value := metrics["vehicle_fast_writer_batch_pending_messages"]; value > 0 {
|
||||
findings = append(findings, fmt.Sprintf("fast writer batch pending %.0f", value))
|
||||
}
|
||||
if value := metrics["vehicle_history_batch_pending_messages"]; value > 0 {
|
||||
findings = append(findings, fmt.Sprintf("history batch pending %.0f", value))
|
||||
}
|
||||
if value := metrics["vehicle_history_batch_pending_rows"]; value > 0 {
|
||||
findings = append(findings, fmt.Sprintf("history rows pending %.0f", value))
|
||||
}
|
||||
if value := metrics["vehicle_history_kafka_lag"] + metrics["vehicle_stat_kafka_lag"] + metrics["vehicle_realtime_kafka_lag"]; value > 0 {
|
||||
findings = append(findings, fmt.Sprintf("kafka lag %.0f", value))
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
func parseMetrics(text string) map[string]float64 {
|
||||
out := map[string]float64{}
|
||||
scanner := bufio.NewScanner(strings.NewReader(text))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
name, value, ok := parseMetricLine(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[name] += value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseMetricLine(line string) (string, float64, bool) {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
return "", 0, false
|
||||
}
|
||||
name := parts[0]
|
||||
if index := strings.IndexByte(name, '{'); index >= 0 {
|
||||
name = name[:index]
|
||||
}
|
||||
value, err := strconv.ParseFloat(parts[1], 64)
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
return name, value, true
|
||||
}
|
||||
85
go/vehicle-gateway/internal/capacity/check_test.go
Normal file
85
go/vehicle-gateway/internal/capacity/check_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package capacity
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEvaluateReportsOKWhenCriticalBacklogsAreZero(t *testing.T) {
|
||||
report := Evaluate(map[string]string{
|
||||
"gateway": `vehicle_gateway_active_connections{protocol="JT808"} 50000
|
||||
vehicle_gateway_connection_rejections_total{protocol="JT808",reason="max_connections"} 0
|
||||
vehicle_async_sink_queue_depth{sink="nats"} 0`,
|
||||
"fast-writer": `vehicle_fast_writer_nats_consumer_ack_pending{consumer="vehicle-fast-writer",stream="VEHICLE_INGEST"} 0
|
||||
vehicle_fast_writer_nats_consumer_pending{consumer="vehicle-fast-writer",stream="VEHICLE_INGEST"} 0
|
||||
vehicle_fast_writer_batch_pending_messages 0`,
|
||||
"history": `vehicle_history_batch_pending_messages 0
|
||||
vehicle_history_batch_pending_rows 0
|
||||
vehicle_history_kafka_lag{topic="vehicle.raw.go.jt808.v1",partition="0"} 0`,
|
||||
})
|
||||
|
||||
if report.Status != StatusOK {
|
||||
t.Fatalf("status = %s, want ok: %#v", report.Status, report)
|
||||
}
|
||||
if report.Totals.ActiveConnections != 50000 {
|
||||
t.Fatalf("active connections = %d, want 50000", report.Totals.ActiveConnections)
|
||||
}
|
||||
if len(report.Findings) != 0 {
|
||||
t.Fatalf("findings = %#v, want none", report.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateReportsDegradedForPendingAndRejects(t *testing.T) {
|
||||
report := Evaluate(map[string]string{
|
||||
"gateway": `vehicle_gateway_active_connections{protocol="JT808"} 120000
|
||||
vehicle_gateway_connection_rejections_total{protocol="JT808",reason="max_connections"} 3
|
||||
vehicle_async_sink_queue_depth{sink="nats"} 25000`,
|
||||
"bridge": `vehicle_bridge_nats_consumer_ack_pending{consumer="vehicle-kafka-bridge",stream="VEHICLE_INGEST"} 101
|
||||
vehicle_bridge_nats_consumer_pending{consumer="vehicle-kafka-bridge",stream="VEHICLE_INGEST"} 12001
|
||||
vehicle_bridge_batch_pending_messages 1001`,
|
||||
"fast-writer": `vehicle_fast_writer_nats_consumer_ack_pending{consumer="vehicle-fast-writer",stream="VEHICLE_INGEST"} 11
|
||||
vehicle_fast_writer_nats_consumer_pending{consumer="vehicle-fast-writer",stream="VEHICLE_INGEST"} 12001
|
||||
vehicle_fast_writer_batch_pending_messages 9`,
|
||||
"history": `vehicle_history_batch_pending_messages 6
|
||||
vehicle_history_batch_pending_rows 7
|
||||
vehicle_history_kafka_lag{topic="vehicle.raw.go.jt808.v1",partition="0"} 42`,
|
||||
})
|
||||
|
||||
if report.Status != StatusDegraded {
|
||||
t.Fatalf("status = %s, want degraded: %#v", report.Status, report)
|
||||
}
|
||||
joined := strings.Join(report.Findings, "\n")
|
||||
for _, want := range []string{
|
||||
"gateway connection rejections",
|
||||
"async sink queue depth",
|
||||
"bridge ack pending",
|
||||
"bridge consumer pending",
|
||||
"bridge batch pending",
|
||||
"fast writer ack pending",
|
||||
"fast writer consumer pending",
|
||||
"history batch pending",
|
||||
"kafka lag",
|
||||
} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("finding missing %q in:\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
if report.Totals.BridgeAckPending != 101 {
|
||||
t.Fatalf("bridge ack pending = %v, want 101", report.Totals.BridgeAckPending)
|
||||
}
|
||||
if report.Totals.BridgeConsumerPending != 12001 {
|
||||
t.Fatalf("bridge consumer pending = %v, want 12001", report.Totals.BridgeConsumerPending)
|
||||
}
|
||||
if report.Totals.FastWriterAckPending != 11 {
|
||||
t.Fatalf("fast writer ack pending = %v, want 11", report.Totals.FastWriterAckPending)
|
||||
}
|
||||
if report.Totals.FastWriterBatchPending != 9 {
|
||||
t.Fatalf("fast writer batch pending = %v, want 9", report.Totals.FastWriterBatchPending)
|
||||
}
|
||||
if report.Totals.HistoryBatchPending != 6 {
|
||||
t.Fatalf("history batch pending = %v, want 6", report.Totals.HistoryBatchPending)
|
||||
}
|
||||
if report.Totals.HistoryRowsPending != 7 {
|
||||
t.Fatalf("history rows pending = %v, want 7", report.Totals.HistoryRowsPending)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ type FrameEnvelope struct {
|
||||
RawHex string `json:"raw_hex,omitempty"`
|
||||
RawText string `json:"raw_text,omitempty"`
|
||||
Parsed map[string]any `json:"parsed,omitempty"`
|
||||
ParsedFields map[string]any `json:"parsed_fields,omitempty"`
|
||||
ParsedFieldTypes map[string]string `json:"parsed_field_types,omitempty"`
|
||||
Fields map[string]any `json:"fields,omitempty"`
|
||||
ParseStatus ParseStatus `json:"parse_status"`
|
||||
ParseError string `json:"parse_error,omitempty"`
|
||||
@@ -58,12 +60,12 @@ func (e FrameEnvelope) VehicleKey() string {
|
||||
if key := strings.TrimSpace(e.VIN); key != "" {
|
||||
return key
|
||||
}
|
||||
if key := strings.TrimSpace(e.VehicleKeyHint); key != "" {
|
||||
return key
|
||||
}
|
||||
if key := strings.TrimSpace(e.Phone); key != "" {
|
||||
return string(e.Protocol) + ":" + key
|
||||
}
|
||||
if key := strings.TrimSpace(e.VehicleKeyHint); key != "" {
|
||||
return key
|
||||
}
|
||||
if key := strings.TrimSpace(e.DeviceID); key != "" {
|
||||
return string(e.Protocol) + ":" + key
|
||||
}
|
||||
|
||||
@@ -6,15 +6,15 @@ import (
|
||||
)
|
||||
|
||||
func TestFrameEnvelopeVehicleKeyPrefersVIN(t *testing.T) {
|
||||
e := FrameEnvelope{Protocol: ProtocolJT808, VIN: "LNBVIN00000000001", Phone: "013307795425"}
|
||||
e := FrameEnvelope{Protocol: ProtocolJT808, VIN: "LNBVIN00000000001", Phone: "13307795425"}
|
||||
if got := e.VehicleKey(); got != "LNBVIN00000000001" {
|
||||
t.Fatalf("VehicleKey() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameEnvelopeVehicleKeyFallsBackToPhone(t *testing.T) {
|
||||
e := FrameEnvelope{Protocol: ProtocolJT808, Phone: "013307795425"}
|
||||
if got := e.VehicleKey(); got != "JT808:013307795425" {
|
||||
e := FrameEnvelope{Protocol: ProtocolJT808, Phone: "13307795425"}
|
||||
if got := e.VehicleKey(); got != "JT808:13307795425" {
|
||||
t.Fatalf("VehicleKey() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
51
go/vehicle-gateway/internal/envelope/realtime.go
Normal file
51
go/vehicle-gateway/internal/envelope/realtime.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package envelope
|
||||
|
||||
import "strings"
|
||||
|
||||
func IsRealtimeTelemetryFrame(env FrameEnvelope) bool {
|
||||
if env.ParseStatus == ParseBadFrame {
|
||||
return false
|
||||
}
|
||||
switch env.Protocol {
|
||||
case ProtocolGB32960:
|
||||
command := strings.TrimSpace(env.MessageID)
|
||||
if command == "" && env.Parsed != nil {
|
||||
if header, ok := env.Parsed["header"].(map[string]any); ok {
|
||||
command = strings.TrimSpace(stringAny(header["command"]))
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(command, "0x02") || strings.EqualFold(command, "0x03") {
|
||||
return true
|
||||
}
|
||||
_, hasDataUnits := env.Parsed["data_units"]
|
||||
return hasDataUnits || hasRealtimeField(env)
|
||||
case ProtocolJT808:
|
||||
if strings.EqualFold(strings.TrimSpace(env.MessageID), "0x0200") {
|
||||
return true
|
||||
}
|
||||
if _, ok := env.Parsed["location"]; ok {
|
||||
return true
|
||||
}
|
||||
return hasRealtimeField(env)
|
||||
case ProtocolYutongMQTT:
|
||||
data, ok := env.Parsed["data"].(map[string]any)
|
||||
return ok && len(data) > 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hasRealtimeField(env FrameEnvelope) bool {
|
||||
return env.Fields[FieldLatitude] != nil ||
|
||||
env.Fields[FieldLongitude] != nil ||
|
||||
env.Fields[FieldTotalMileageKM] != nil ||
|
||||
env.Fields[FieldSpeedKMH] != nil ||
|
||||
env.Fields[FieldSOCPercent] != nil
|
||||
}
|
||||
|
||||
func stringAny(value any) string {
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
186
go/vehicle-gateway/internal/eventbus/async_sink.go
Normal file
186
go/vehicle-gateway/internal/eventbus/async_sink.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
)
|
||||
|
||||
type AsyncConfig struct {
|
||||
QueueSize int
|
||||
Workers int
|
||||
OperationTimeout time.Duration
|
||||
OnError func(error)
|
||||
Metrics *metrics.Registry
|
||||
Name string
|
||||
}
|
||||
|
||||
type AsyncSink struct {
|
||||
delegate Sink
|
||||
jobs chan asyncJob
|
||||
timeout time.Duration
|
||||
onError func(error)
|
||||
metrics *metrics.Registry
|
||||
name string
|
||||
|
||||
closeOnce sync.Once
|
||||
closed chan struct{}
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
type asyncJob struct {
|
||||
kind string
|
||||
env envelope.FrameEnvelope
|
||||
}
|
||||
|
||||
var ErrAsyncSinkClosed = errors.New("async sink is closed")
|
||||
|
||||
var asyncSinkPublishDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
||||
|
||||
func NewAsyncSink(delegate Sink, cfg AsyncConfig) *AsyncSink {
|
||||
if delegate == nil {
|
||||
panic("async delegate sink must not be nil")
|
||||
}
|
||||
if cfg.QueueSize <= 0 {
|
||||
cfg.QueueSize = 10_000
|
||||
}
|
||||
if cfg.Workers <= 0 {
|
||||
cfg.Workers = 1
|
||||
}
|
||||
if cfg.OperationTimeout <= 0 {
|
||||
cfg.OperationTimeout = 30 * time.Second
|
||||
}
|
||||
if cfg.Name == "" {
|
||||
cfg.Name = "async"
|
||||
}
|
||||
s := &AsyncSink{
|
||||
delegate: delegate,
|
||||
jobs: make(chan asyncJob, cfg.QueueSize),
|
||||
timeout: cfg.OperationTimeout,
|
||||
onError: cfg.OnError,
|
||||
metrics: cfg.Metrics,
|
||||
name: cfg.Name,
|
||||
closed: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
s.wg.Add(cfg.Workers)
|
||||
for i := 0; i < cfg.Workers; i++ {
|
||||
go s.worker()
|
||||
}
|
||||
go func() {
|
||||
s.wg.Wait()
|
||||
close(s.done)
|
||||
}()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *AsyncSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
return s.enqueue(ctx, asyncJob{kind: "raw", env: env})
|
||||
}
|
||||
|
||||
func (s *AsyncSink) PublishUnified(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
return s.enqueue(ctx, asyncJob{kind: "unified", env: env})
|
||||
}
|
||||
|
||||
func (s *AsyncSink) PublishFields(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
return s.enqueue(ctx, asyncJob{kind: "fields", env: env})
|
||||
}
|
||||
|
||||
func (s *AsyncSink) Close() error {
|
||||
s.closeOnce.Do(func() {
|
||||
close(s.closed)
|
||||
close(s.jobs)
|
||||
})
|
||||
<-s.done
|
||||
return s.delegate.Close()
|
||||
}
|
||||
|
||||
func (s *AsyncSink) enqueue(ctx context.Context, job asyncJob) error {
|
||||
select {
|
||||
case <-s.closed:
|
||||
s.recordEnqueue(job.kind, "closed")
|
||||
return ErrAsyncSinkClosed
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case s.jobs <- job:
|
||||
s.recordEnqueue(job.kind, "queued")
|
||||
s.recordQueueDepth()
|
||||
return nil
|
||||
case <-s.closed:
|
||||
s.recordEnqueue(job.kind, "closed")
|
||||
return ErrAsyncSinkClosed
|
||||
case <-ctx.Done():
|
||||
s.recordEnqueue(job.kind, "timeout")
|
||||
s.recordQueueDepth()
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AsyncSink) worker() {
|
||||
defer s.wg.Done()
|
||||
for job := range s.jobs {
|
||||
s.recordQueueDepth()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
|
||||
started := time.Now()
|
||||
var err error
|
||||
switch job.kind {
|
||||
case "raw":
|
||||
err = s.delegate.PublishRaw(ctx, job.env)
|
||||
case "unified":
|
||||
err = s.delegate.PublishUnified(ctx, job.env)
|
||||
case "fields":
|
||||
err = s.delegate.PublishFields(ctx, job.env)
|
||||
}
|
||||
cancel()
|
||||
status := "ok"
|
||||
if err != nil {
|
||||
status = "error"
|
||||
}
|
||||
s.recordPublish(job.kind, status, time.Since(started))
|
||||
if err != nil && s.onError != nil {
|
||||
s.onError(err)
|
||||
}
|
||||
s.recordQueueDepth()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AsyncSink) recordEnqueue(kind string, status string) {
|
||||
if s.metrics == nil {
|
||||
return
|
||||
}
|
||||
s.metrics.IncCounter("vehicle_async_sink_enqueue_total", metrics.Labels{
|
||||
"sink": s.name,
|
||||
"kind": kind,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *AsyncSink) recordPublish(kind string, status string, elapsed time.Duration) {
|
||||
if s.metrics == nil {
|
||||
return
|
||||
}
|
||||
labels := metrics.Labels{
|
||||
"sink": s.name,
|
||||
"kind": kind,
|
||||
"status": status,
|
||||
}
|
||||
s.metrics.IncCounter("vehicle_async_sink_publish_total", labels)
|
||||
elapsedMS := float64(elapsed.Milliseconds())
|
||||
s.metrics.SetGauge("vehicle_async_sink_publish_duration_ms", labels, elapsedMS)
|
||||
s.metrics.ObserveHistogram("vehicle_async_sink_publish_duration_ms_histogram", labels, asyncSinkPublishDurationBucketsMS, elapsedMS)
|
||||
}
|
||||
|
||||
func (s *AsyncSink) recordQueueDepth() {
|
||||
if s.metrics == nil {
|
||||
return
|
||||
}
|
||||
s.metrics.SetGauge("vehicle_async_sink_queue_depth", metrics.Labels{
|
||||
"sink": s.name,
|
||||
}, float64(len(s.jobs)))
|
||||
}
|
||||
198
go/vehicle-gateway/internal/eventbus/async_sink_test.go
Normal file
198
go/vehicle-gateway/internal/eventbus/async_sink_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
)
|
||||
|
||||
func TestAsyncSinkPublishRawReturnsAfterQueueing(t *testing.T) {
|
||||
delegate := newBlockingSink()
|
||||
sink := NewAsyncSink(delegate, AsyncConfig{QueueSize: 1, Workers: 1, OperationTimeout: time.Second})
|
||||
|
||||
start := time.Now()
|
||||
if err := sink.PublishRaw(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "LNBSCB3D4R1234567"}); err != nil {
|
||||
t.Fatalf("PublishRaw() error = %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||
t.Fatalf("PublishRaw() blocked for %s, want quick queueing", elapsed)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-delegate.rawStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("delegate raw publish was not started")
|
||||
}
|
||||
delegate.release()
|
||||
if err := sink.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncSinkPublishesInFIFOOrderWithSingleWorker(t *testing.T) {
|
||||
delegate := &orderedSink{}
|
||||
sink := NewAsyncSink(delegate, AsyncConfig{QueueSize: 4, Workers: 1, OperationTimeout: time.Second})
|
||||
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425"}
|
||||
if err := sink.PublishRaw(context.Background(), env); err != nil {
|
||||
t.Fatalf("PublishRaw() error = %v", err)
|
||||
}
|
||||
if err := sink.PublishUnified(context.Background(), env); err != nil {
|
||||
t.Fatalf("PublishUnified() error = %v", err)
|
||||
}
|
||||
if err := sink.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
|
||||
if got, want := delegate.calls, []string{"raw", "unified"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("calls = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncSinkRecordsQueueMetrics(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
delegate := newBlockingSink()
|
||||
sink := NewAsyncSink(delegate, AsyncConfig{
|
||||
QueueSize: 2,
|
||||
Workers: 1,
|
||||
OperationTimeout: time.Second,
|
||||
Metrics: registry,
|
||||
Name: "nats",
|
||||
})
|
||||
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, VIN: "VIN001"}
|
||||
if err := sink.PublishRaw(context.Background(), env); err != nil {
|
||||
t.Fatalf("PublishRaw() error = %v", err)
|
||||
}
|
||||
if err := sink.PublishFields(context.Background(), env); err != nil {
|
||||
t.Fatalf("PublishFields() error = %v", err)
|
||||
}
|
||||
delegate.release()
|
||||
if err := sink.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_async_sink_enqueue_total{kind="raw",sink="nats",status="queued"} 1`,
|
||||
`vehicle_async_sink_enqueue_total{kind="fields",sink="nats",status="queued"} 1`,
|
||||
`vehicle_async_sink_queue_depth{sink="nats"}`,
|
||||
`vehicle_async_sink_publish_total{kind="raw",sink="nats",status="ok"} 1`,
|
||||
`vehicle_async_sink_publish_total{kind="fields",sink="nats",status="ok"} 1`,
|
||||
`vehicle_async_sink_publish_duration_ms_histogram_bucket{le="+Inf",kind="raw",sink="nats",status="ok"} 1`,
|
||||
`vehicle_async_sink_publish_duration_ms_histogram_count{kind="raw",sink="nats",status="ok"} 1`,
|
||||
`vehicle_async_sink_publish_duration_ms_histogram_sum{kind="raw",sink="nats",status="ok"}`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("async sink metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncSinkRecordsEnqueueTimeoutWhenQueueIsFull(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
delegate := newBlockingSink()
|
||||
sink := NewAsyncSink(delegate, AsyncConfig{
|
||||
QueueSize: 1,
|
||||
Workers: 1,
|
||||
OperationTimeout: time.Second,
|
||||
Metrics: registry,
|
||||
Name: "nats",
|
||||
})
|
||||
defer sink.Close()
|
||||
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425"}
|
||||
if err := sink.PublishRaw(context.Background(), env); err != nil {
|
||||
t.Fatalf("first PublishRaw() error = %v", err)
|
||||
}
|
||||
select {
|
||||
case <-delegate.rawStarted:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("delegate raw publish was not started")
|
||||
}
|
||||
if err := sink.PublishFields(context.Background(), env); err != nil {
|
||||
t.Fatalf("second PublishFields() error = %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||
defer cancel()
|
||||
if err := sink.PublishUnified(ctx, env); err == nil {
|
||||
t.Fatal("third PublishUnified() error = nil, want context deadline exceeded")
|
||||
}
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_async_sink_enqueue_total{kind="unified",sink="nats",status="timeout"} 1`,
|
||||
`vehicle_async_sink_queue_depth{sink="nats"} 1`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("async sink timeout metric missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
delegate.release()
|
||||
}
|
||||
|
||||
type blockingSink struct {
|
||||
rawStarted chan struct{}
|
||||
releaseRaw chan struct{}
|
||||
}
|
||||
|
||||
func newBlockingSink() *blockingSink {
|
||||
return &blockingSink{
|
||||
rawStarted: make(chan struct{}),
|
||||
releaseRaw: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *blockingSink) PublishRaw(context.Context, envelope.FrameEnvelope) error {
|
||||
close(s.rawStarted)
|
||||
<-s.releaseRaw
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *blockingSink) PublishUnified(context.Context, envelope.FrameEnvelope) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *blockingSink) PublishFields(context.Context, envelope.FrameEnvelope) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *blockingSink) Close() error {
|
||||
s.release()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *blockingSink) release() {
|
||||
select {
|
||||
case <-s.releaseRaw:
|
||||
default:
|
||||
close(s.releaseRaw)
|
||||
}
|
||||
}
|
||||
|
||||
type orderedSink struct {
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (s *orderedSink) PublishRaw(context.Context, envelope.FrameEnvelope) error {
|
||||
s.calls = append(s.calls, "raw")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *orderedSink) PublishUnified(context.Context, envelope.FrameEnvelope) error {
|
||||
s.calls = append(s.calls, "unified")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *orderedSink) PublishFields(context.Context, envelope.FrameEnvelope) error {
|
||||
s.calls = append(s.calls, "fields")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *orderedSink) Close() error {
|
||||
return nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
|
||||
type DurableConfig struct {
|
||||
Directory string
|
||||
ReplayBatchSize int
|
||||
}
|
||||
|
||||
type DurableSink struct {
|
||||
@@ -25,6 +27,7 @@ type DurableSink struct {
|
||||
mu sync.Mutex
|
||||
seq uint64
|
||||
rawPending map[string]struct{}
|
||||
replayBatchSize int
|
||||
}
|
||||
|
||||
type durableRecord struct {
|
||||
@@ -32,6 +35,17 @@ type durableRecord struct {
|
||||
Envelope envelope.FrameEnvelope `json:"envelope"`
|
||||
}
|
||||
|
||||
type durableRecordFile struct {
|
||||
path string
|
||||
record durableRecord
|
||||
}
|
||||
|
||||
type recordPublishingSink interface {
|
||||
PublishRecords(context.Context, []durableRecord) error
|
||||
}
|
||||
|
||||
const durableReplayOperationTimeout = 30 * time.Second
|
||||
|
||||
func NewDurableSink(delegate Sink, cfg DurableConfig) *DurableSink {
|
||||
if delegate == nil {
|
||||
panic("durable delegate sink must not be nil")
|
||||
@@ -40,6 +54,7 @@ func NewDurableSink(delegate Sink, cfg DurableConfig) *DurableSink {
|
||||
delegate: delegate,
|
||||
dir: strings.TrimSpace(cfg.Directory),
|
||||
rawPending: map[string]struct{}{},
|
||||
replayBatchSize: cfg.ReplayBatchSize,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,28 +79,88 @@ func (s *DurableSink) PublishUnified(ctx context.Context, env envelope.FrameEnve
|
||||
return s.spool("unified", env)
|
||||
}
|
||||
|
||||
func (s *DurableSink) PublishFields(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
if s.isRawPending(env) {
|
||||
return s.spool("fields", env)
|
||||
}
|
||||
if err := s.delegate.PublishFields(ctx, env); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.spool("fields", env)
|
||||
}
|
||||
|
||||
func (s *DurableSink) ReplayOnce(ctx context.Context) error {
|
||||
files, err := filepath.Glob(filepath.Join(s.dir, "*.json"))
|
||||
return s.replay(ctx, 0)
|
||||
}
|
||||
|
||||
func (s *DurableSink) replay(ctx context.Context, limit int) error {
|
||||
files, err := durableFiles(s.dir, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(files)
|
||||
records := make([]durableRecordFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
record, err := readDurableRecord(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.publishRecord(ctx, record); err != nil {
|
||||
records = append(records, durableRecordFile{path: file, record: record})
|
||||
}
|
||||
sortDurableRecords(records)
|
||||
if publisher, ok := s.delegate.(recordPublishingSink); ok {
|
||||
durableRecords := make([]durableRecord, 0, len(records))
|
||||
for _, item := range records {
|
||||
durableRecords = append(durableRecords, item.record)
|
||||
}
|
||||
if err := publisher.PublishRecords(ctx, durableRecords); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(file); err != nil {
|
||||
for _, item := range records {
|
||||
if err := os.Remove(item.path); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.Kind == "raw" {
|
||||
s.clearRawPending(record.Envelope)
|
||||
if item.record.Kind == "raw" {
|
||||
s.clearRawPending(item.record.Envelope)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, item := range records {
|
||||
if err := s.publishRecord(ctx, item.record); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(item.path); err != nil {
|
||||
return err
|
||||
}
|
||||
if item.record.Kind == "raw" {
|
||||
s.clearRawPending(item.record.Envelope)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortDurableRecords(records []durableRecordFile) {
|
||||
sort.SliceStable(records, func(i, j int) bool {
|
||||
leftEvent := records[i].record.Envelope.StableEventID()
|
||||
rightEvent := records[j].record.Envelope.StableEventID()
|
||||
if leftEvent == rightEvent {
|
||||
return durableKindOrder(records[i].record.Kind) < durableKindOrder(records[j].record.Kind)
|
||||
}
|
||||
return records[i].path < records[j].path
|
||||
})
|
||||
}
|
||||
|
||||
func durableKindOrder(kind string) int {
|
||||
if kind == "raw" {
|
||||
return 0
|
||||
}
|
||||
if kind == "unified" {
|
||||
return 1
|
||||
}
|
||||
if kind == "fields" {
|
||||
return 2
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
func (s *DurableSink) ReplayLoop(ctx context.Context, interval time.Duration, onError func(error)) {
|
||||
@@ -99,13 +174,19 @@ func (s *DurableSink) ReplayLoop(ctx context.Context, interval time.Duration, on
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := s.ReplayOnce(ctx); err != nil && onError != nil {
|
||||
if err := s.replayOnceFromLoop(ctx); err != nil && onError != nil {
|
||||
onError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DurableSink) replayOnceFromLoop(ctx context.Context) error {
|
||||
replayCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), durableReplayOperationTimeout)
|
||||
defer cancel()
|
||||
return s.replay(replayCtx, s.replayBatchSize)
|
||||
}
|
||||
|
||||
func (s *DurableSink) Close() error {
|
||||
return s.delegate.Close()
|
||||
}
|
||||
@@ -116,11 +197,17 @@ func (s *DurableSink) publishRecord(ctx context.Context, record durableRecord) e
|
||||
return s.delegate.PublishRaw(ctx, record.Envelope)
|
||||
case "unified":
|
||||
return s.delegate.PublishUnified(ctx, record.Envelope)
|
||||
case "fields":
|
||||
return s.delegate.PublishFields(ctx, record.Envelope)
|
||||
default:
|
||||
return fmt.Errorf("unknown durable record kind %q", record.Kind)
|
||||
return errUnknownRecordKind(record.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func errUnknownRecordKind(kind string) error {
|
||||
return fmt.Errorf("unknown durable record kind %q", kind)
|
||||
}
|
||||
|
||||
func (s *DurableSink) spool(kind string, env envelope.FrameEnvelope) error {
|
||||
if s.dir == "" {
|
||||
return fmt.Errorf("durable spool directory is empty")
|
||||
@@ -185,3 +272,61 @@ func readDurableRecord(path string) (durableRecord, error) {
|
||||
}
|
||||
return record, json.Unmarshal(payload, &record)
|
||||
}
|
||||
|
||||
func durableFiles(dir string, limit int) ([]string, error) {
|
||||
if limit > 0 {
|
||||
handle, err := os.Open(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer handle.Close()
|
||||
return durableFilesFromReader(dir, limit, handle)
|
||||
}
|
||||
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
type durableNameReader interface {
|
||||
Readdirnames(int) ([]string, error)
|
||||
}
|
||||
|
||||
func durableFilesFromReader(dir string, limit int, reader durableNameReader) ([]string, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
files := make([]string, 0, limit)
|
||||
for len(files) < limit {
|
||||
names, err := reader.Readdirnames(limit - len(files))
|
||||
for _, name := range names {
|
||||
if !strings.HasSuffix(name, ".json") {
|
||||
continue
|
||||
}
|
||||
files = append(files, filepath.Join(dir, name))
|
||||
if len(files) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errorsIsEOF(err) {
|
||||
break
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(names) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func errorsIsEOF(err error) bool {
|
||||
return err == io.EOF
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package eventbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -70,6 +72,127 @@ func TestDurableSinkReplayPublishesInFileOrderAndDeletesFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableSinkReplayPublishesRawBeforeUnifiedEvenWhenFilesAreOutOfOrder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := durableTestEnvelope()
|
||||
writeDurableRecord(t, filepath.Join(dir, "0001-unified.json"), durableRecord{Kind: "unified", Envelope: env})
|
||||
writeDurableRecord(t, filepath.Join(dir, "0002-raw.json"), durableRecord{Kind: "raw", Envelope: env})
|
||||
delegate := &scriptedSink{}
|
||||
sink := NewDurableSink(delegate, DurableConfig{Directory: dir})
|
||||
|
||||
if err := sink.ReplayOnce(context.Background()); err != nil {
|
||||
t.Fatalf("ReplayOnce() error = %v", err)
|
||||
}
|
||||
|
||||
got := delegate.calls
|
||||
want := []string{"raw", "unified"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("calls = %#v, want %#v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("calls[%d] = %q, want %q; all=%#v", i, got[i], want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableSinkReplayLoopTickUsesUncancelledContext(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := durableTestEnvelope()
|
||||
writeDurableRecord(t, filepath.Join(dir, "0001-raw.json"), durableRecord{Kind: "raw", Envelope: env})
|
||||
delegate := &contextCheckingReplaySink{}
|
||||
sink := NewDurableSink(delegate, DurableConfig{Directory: dir})
|
||||
parent, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if err := sink.replayOnceFromLoop(parent); err != nil {
|
||||
t.Fatalf("replayOnceFromLoop() error = %v", err)
|
||||
}
|
||||
if delegate.rawCtxErr != nil {
|
||||
t.Fatalf("delegate saw cancelled context: %v", delegate.rawCtxErr)
|
||||
}
|
||||
if delegate.rawCalls != 1 {
|
||||
t.Fatalf("raw calls = %d, want 1", delegate.rawCalls)
|
||||
}
|
||||
if files := spoolFiles(t, dir); len(files) != 0 {
|
||||
t.Fatalf("spool files after replay = %#v, want none", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableSinkReplayLoopTickRespectsBatchSize(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := durableTestEnvelope()
|
||||
for index := 0; index < 5; index++ {
|
||||
writeDurableRecord(t, filepath.Join(dir, "000"+string(rune('1'+index))+"-raw.json"), durableRecord{Kind: "raw", Envelope: env})
|
||||
}
|
||||
delegate := &contextCheckingReplaySink{}
|
||||
sink := NewDurableSink(delegate, DurableConfig{Directory: dir, ReplayBatchSize: 2})
|
||||
|
||||
if err := sink.replayOnceFromLoop(context.Background()); err != nil {
|
||||
t.Fatalf("replayOnceFromLoop() error = %v", err)
|
||||
}
|
||||
if delegate.rawCalls != 2 {
|
||||
t.Fatalf("raw calls = %d, want 2", delegate.rawCalls)
|
||||
}
|
||||
if files := spoolFiles(t, dir); len(files) != 3 {
|
||||
t.Fatalf("spool files after replay = %d, want 3: %#v", len(files), files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableSinkReplayUsesBatchPublisherWhenAvailable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := durableTestEnvelope()
|
||||
writeDurableRecord(t, filepath.Join(dir, "0001-raw.json"), durableRecord{Kind: "raw", Envelope: env})
|
||||
writeDurableRecord(t, filepath.Join(dir, "0002-unified.json"), durableRecord{Kind: "unified", Envelope: env})
|
||||
delegate := &batchRecordingSink{}
|
||||
sink := NewDurableSink(delegate, DurableConfig{Directory: dir})
|
||||
|
||||
if err := sink.ReplayOnce(context.Background()); err != nil {
|
||||
t.Fatalf("ReplayOnce() error = %v", err)
|
||||
}
|
||||
if delegate.batchCalls != 1 {
|
||||
t.Fatalf("batch calls = %d, want 1", delegate.batchCalls)
|
||||
}
|
||||
if delegate.rawCalls != 0 || delegate.unifiedCalls != 0 {
|
||||
t.Fatalf("individual publish should not be used, raw=%d unified=%d", delegate.rawCalls, delegate.unifiedCalls)
|
||||
}
|
||||
if got, want := len(delegate.records), 2; got != want {
|
||||
t.Fatalf("batch record count = %d, want %d", got, want)
|
||||
}
|
||||
if delegate.records[0].Kind != "raw" || delegate.records[1].Kind != "unified" {
|
||||
t.Fatalf("batch order = %#v", delegate.records)
|
||||
}
|
||||
if files := spoolFiles(t, dir); len(files) != 0 {
|
||||
t.Fatalf("spool files after replay = %#v, want none", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableFilesFromReaderStopsAfterLimitedJSONBatch(t *testing.T) {
|
||||
reader := &fakeNameReader{
|
||||
batches: [][]string{
|
||||
{"0001-raw.json", "notes.txt", "0002-unified.json"},
|
||||
{"0003-raw.json"},
|
||||
},
|
||||
}
|
||||
|
||||
files, err := durableFilesFromReader("/spool", 2, reader)
|
||||
if err != nil {
|
||||
t.Fatalf("durableFilesFromReader() error = %v", err)
|
||||
}
|
||||
if reader.calls != 1 {
|
||||
t.Fatalf("Readdirnames calls = %d, want 1", reader.calls)
|
||||
}
|
||||
want := []string{filepath.Join("/spool", "0001-raw.json"), filepath.Join("/spool", "0002-unified.json")}
|
||||
if len(files) != len(want) {
|
||||
t.Fatalf("files = %#v, want %#v", files, want)
|
||||
}
|
||||
for i := range want {
|
||||
if files[i] != want[i] {
|
||||
t.Fatalf("files[%d] = %q, want %q; all=%#v", i, files[i], want[i], files)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func durableTestEnvelope() envelope.FrameEnvelope {
|
||||
return envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
@@ -83,6 +206,35 @@ func durableTestEnvelope() envelope.FrameEnvelope {
|
||||
}
|
||||
}
|
||||
|
||||
type fakeNameReader struct {
|
||||
batches [][]string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *fakeNameReader) Readdirnames(int) ([]string, error) {
|
||||
r.calls++
|
||||
if len(r.batches) == 0 {
|
||||
return nil, io.EOF
|
||||
}
|
||||
batch := r.batches[0]
|
||||
r.batches = r.batches[1:]
|
||||
if len(r.batches) == 0 {
|
||||
return batch, io.EOF
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
func writeDurableRecord(t *testing.T, path string, record durableRecord) {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal durable record: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, payload, 0o640); err != nil {
|
||||
t.Fatalf("write durable record: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func spoolFiles(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
matches, err := filepath.Glob(filepath.Join(dir, "*.json"))
|
||||
@@ -148,6 +300,69 @@ func (s *scriptedSink) PublishUnified(context.Context, envelope.FrameEnvelope) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scriptedSink) PublishFields(context.Context, envelope.FrameEnvelope) error {
|
||||
s.calls = append(s.calls, "fields")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scriptedSink) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type contextCheckingReplaySink struct {
|
||||
rawCtxErr error
|
||||
unifiedCtxErr error
|
||||
rawCalls int
|
||||
unifiedCalls int
|
||||
}
|
||||
|
||||
func (s *contextCheckingReplaySink) PublishRaw(ctx context.Context, _ envelope.FrameEnvelope) error {
|
||||
s.rawCtxErr = ctx.Err()
|
||||
s.rawCalls++
|
||||
return s.rawCtxErr
|
||||
}
|
||||
|
||||
func (s *contextCheckingReplaySink) PublishUnified(ctx context.Context, _ envelope.FrameEnvelope) error {
|
||||
s.unifiedCtxErr = ctx.Err()
|
||||
s.unifiedCalls++
|
||||
return s.unifiedCtxErr
|
||||
}
|
||||
|
||||
func (s *contextCheckingReplaySink) PublishFields(ctx context.Context, _ envelope.FrameEnvelope) error {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func (s *contextCheckingReplaySink) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type batchRecordingSink struct {
|
||||
batchCalls int
|
||||
records []durableRecord
|
||||
rawCalls int
|
||||
unifiedCalls int
|
||||
}
|
||||
|
||||
func (s *batchRecordingSink) PublishRecords(_ context.Context, records []durableRecord) error {
|
||||
s.batchCalls++
|
||||
s.records = append(s.records, records...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *batchRecordingSink) PublishRaw(context.Context, envelope.FrameEnvelope) error {
|
||||
s.rawCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *batchRecordingSink) PublishUnified(context.Context, envelope.FrameEnvelope) error {
|
||||
s.unifiedCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *batchRecordingSink) PublishFields(context.Context, envelope.FrameEnvelope) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *batchRecordingSink) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,17 +8,20 @@ import (
|
||||
"github.com/segmentio/kafka-go"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||||
)
|
||||
|
||||
type Sink interface {
|
||||
PublishRaw(context.Context, envelope.FrameEnvelope) error
|
||||
PublishUnified(context.Context, envelope.FrameEnvelope) error
|
||||
PublishFields(context.Context, envelope.FrameEnvelope) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type KafkaSink struct {
|
||||
writer kafkaWriter
|
||||
rawTopics map[envelope.Protocol]string
|
||||
fieldsTopics map[envelope.Protocol]string
|
||||
unifiedTopic string
|
||||
}
|
||||
|
||||
@@ -30,6 +33,7 @@ type kafkaWriter interface {
|
||||
type KafkaConfig struct {
|
||||
Brokers []string
|
||||
RawTopics map[envelope.Protocol]string
|
||||
FieldsTopics map[envelope.Protocol]string
|
||||
UnifiedTopic string
|
||||
}
|
||||
|
||||
@@ -41,25 +45,37 @@ func NewKafkaSink(cfg KafkaConfig) (*KafkaSink, error) {
|
||||
Addr: kafka.TCP(cfg.Brokers...),
|
||||
Balancer: &kafka.Hash{},
|
||||
AllowAutoTopicCreation: false,
|
||||
RequiredAcks: kafka.RequireAll,
|
||||
Async: false,
|
||||
}, cfg), nil
|
||||
}
|
||||
|
||||
func newKafkaSinkWithWriter(writer kafkaWriter, cfg KafkaConfig) *KafkaSink {
|
||||
rawTopics := map[envelope.Protocol]string{
|
||||
envelope.ProtocolGB32960: "vehicle.raw.gb32960.v1",
|
||||
envelope.ProtocolJT808: "vehicle.raw.jt808.v1",
|
||||
envelope.ProtocolYutongMQTT: "vehicle.raw.yutong-mqtt.v1",
|
||||
envelope.ProtocolGB32960: topics.RawGB32960,
|
||||
envelope.ProtocolJT808: topics.RawJT808,
|
||||
envelope.ProtocolYutongMQTT: topics.RawYutongMQTT,
|
||||
}
|
||||
for protocol, topic := range cfg.RawTopics {
|
||||
if topic != "" {
|
||||
rawTopics[protocol] = topic
|
||||
}
|
||||
}
|
||||
fieldsTopics := map[envelope.Protocol]string{
|
||||
envelope.ProtocolGB32960: topics.FieldsGB32960,
|
||||
envelope.ProtocolJT808: topics.FieldsJT808,
|
||||
envelope.ProtocolYutongMQTT: topics.FieldsYutongMQTT,
|
||||
}
|
||||
for protocol, topic := range cfg.FieldsTopics {
|
||||
if topic != "" {
|
||||
fieldsTopics[protocol] = topic
|
||||
}
|
||||
}
|
||||
unifiedTopic := cfg.UnifiedTopic
|
||||
if unifiedTopic == "" {
|
||||
unifiedTopic = "vehicle.event.unified.v1"
|
||||
unifiedTopic = topics.Unified
|
||||
}
|
||||
return &KafkaSink{writer: writer, rawTopics: rawTopics, unifiedTopic: unifiedTopic}
|
||||
return &KafkaSink{writer: writer, rawTopics: rawTopics, fieldsTopics: fieldsTopics, unifiedTopic: unifiedTopic}
|
||||
}
|
||||
|
||||
func (s *KafkaSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
@@ -74,6 +90,33 @@ func (s *KafkaSink) PublishUnified(ctx context.Context, env envelope.FrameEnvelo
|
||||
return s.publish(ctx, s.unifiedTopic, env)
|
||||
}
|
||||
|
||||
func (s *KafkaSink) PublishFields(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
topic, ok := s.fieldsTopics[env.Protocol]
|
||||
if !ok || topic == "" {
|
||||
return fmt.Errorf("fields topic not configured for protocol %s", env.Protocol)
|
||||
}
|
||||
return s.publish(ctx, topic, env)
|
||||
}
|
||||
|
||||
func (s *KafkaSink) PublishRecords(ctx context.Context, records []durableRecord) error {
|
||||
messages := make([]kafka.Message, 0, len(records))
|
||||
for _, record := range records {
|
||||
topic, err := s.topicForRecord(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := kafkaMessage(topic, record.Envelope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messages = append(messages, message)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.writer.WriteMessages(ctx, messages...)
|
||||
}
|
||||
|
||||
func (s *KafkaSink) Close() error {
|
||||
if s == nil || s.writer == nil {
|
||||
return nil
|
||||
@@ -82,13 +125,42 @@ func (s *KafkaSink) Close() error {
|
||||
}
|
||||
|
||||
func (s *KafkaSink) publish(ctx context.Context, topic string, env envelope.FrameEnvelope) error {
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
message, err := kafkaMessage(topic, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writer.WriteMessages(ctx, kafka.Message{
|
||||
return s.writer.WriteMessages(ctx, message)
|
||||
}
|
||||
|
||||
func (s *KafkaSink) topicForRecord(record durableRecord) (string, error) {
|
||||
switch record.Kind {
|
||||
case "raw":
|
||||
topic, ok := s.rawTopics[record.Envelope.Protocol]
|
||||
if !ok || topic == "" {
|
||||
return "", fmt.Errorf("raw topic not configured for protocol %s", record.Envelope.Protocol)
|
||||
}
|
||||
return topic, nil
|
||||
case "unified":
|
||||
return s.unifiedTopic, nil
|
||||
case "fields":
|
||||
topic, ok := s.fieldsTopics[record.Envelope.Protocol]
|
||||
if !ok || topic == "" {
|
||||
return "", fmt.Errorf("fields topic not configured for protocol %s", record.Envelope.Protocol)
|
||||
}
|
||||
return topic, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown durable record kind %q", record.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func kafkaMessage(topic string, env envelope.FrameEnvelope) (kafka.Message, error) {
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
return kafka.Message{}, err
|
||||
}
|
||||
return kafka.Message{
|
||||
Topic: topic,
|
||||
Key: env.KafkaKey(),
|
||||
Value: payload,
|
||||
})
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package eventbus
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/segmentio/kafka-go"
|
||||
@@ -14,7 +15,7 @@ func TestKafkaSinkRoutesRawTopics(t *testing.T) {
|
||||
writer := &recordingWriter{}
|
||||
sink := newKafkaSinkWithWriter(writer, KafkaConfig{})
|
||||
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "013307795425", MessageID: "0x0200"}
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
|
||||
if err := sink.PublishRaw(context.Background(), env); err != nil {
|
||||
t.Fatalf("PublishRaw() error = %v", err)
|
||||
}
|
||||
@@ -22,10 +23,10 @@ func TestKafkaSinkRoutesRawTopics(t *testing.T) {
|
||||
t.Fatalf("messages = %d", len(writer.messages))
|
||||
}
|
||||
msg := writer.messages[0]
|
||||
if msg.Topic != "vehicle.raw.jt808.v1" {
|
||||
t.Fatalf("topic = %q", msg.Topic)
|
||||
if got, want := msg.Topic, "vehicle.raw.go.jt808.v1"; got != want {
|
||||
t.Fatalf("topic = %q, want %q", got, want)
|
||||
}
|
||||
if string(msg.Key) != "JT808:013307795425" {
|
||||
if string(msg.Key) != "JT808:13307795425" {
|
||||
t.Fatalf("key = %q", string(msg.Key))
|
||||
}
|
||||
var decoded envelope.FrameEnvelope
|
||||
@@ -63,11 +64,61 @@ func TestKafkaSinkRejectsUnknownProtocol(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKafkaSinkPublishesDurableRecordsInSingleWriterCall(t *testing.T) {
|
||||
writer := &recordingWriter{}
|
||||
sink := newKafkaSinkWithWriter(writer, KafkaConfig{UnifiedTopic: "custom.unified"})
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
|
||||
|
||||
err := sink.PublishRecords(context.Background(), []durableRecord{
|
||||
{Kind: "raw", Envelope: env},
|
||||
{Kind: "unified", Envelope: env},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PublishRecords() error = %v", err)
|
||||
}
|
||||
if writer.writeCalls != 1 {
|
||||
t.Fatalf("WriteMessages calls = %d, want 1", writer.writeCalls)
|
||||
}
|
||||
if len(writer.messages) != 2 {
|
||||
t.Fatalf("messages = %d, want 2", len(writer.messages))
|
||||
}
|
||||
if writer.messages[0].Topic != "vehicle.raw.go.jt808.v1" || writer.messages[1].Topic != "custom.unified" {
|
||||
t.Fatalf("topics = %q, %q", writer.messages[0].Topic, writer.messages[1].Topic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewKafkaSinkUsesProductionDeliveryGuarantees(t *testing.T) {
|
||||
sink, err := NewKafkaSink(KafkaConfig{Brokers: []string{"127.0.0.1:9092"}})
|
||||
if err != nil {
|
||||
t.Fatalf("NewKafkaSink() error = %v", err)
|
||||
}
|
||||
defer sink.Close()
|
||||
|
||||
writer, ok := sink.writer.(*kafka.Writer)
|
||||
if !ok {
|
||||
t.Fatalf("writer type = %s", reflect.TypeOf(sink.writer))
|
||||
}
|
||||
if writer.RequiredAcks != kafka.RequireAll {
|
||||
t.Fatalf("RequiredAcks = %v, want %v", writer.RequiredAcks, kafka.RequireAll)
|
||||
}
|
||||
if writer.Async {
|
||||
t.Fatal("writer must publish synchronously so Kafka errors propagate")
|
||||
}
|
||||
if writer.AllowAutoTopicCreation {
|
||||
t.Fatal("writer must not auto-create production topics")
|
||||
}
|
||||
if _, ok := writer.Balancer.(*kafka.Hash); !ok {
|
||||
t.Fatalf("balancer type = %s, want *kafka.Hash", reflect.TypeOf(writer.Balancer))
|
||||
}
|
||||
}
|
||||
|
||||
type recordingWriter struct {
|
||||
messages []kafka.Message
|
||||
writeCalls int
|
||||
}
|
||||
|
||||
func (w *recordingWriter) WriteMessages(_ context.Context, messages ...kafka.Message) error {
|
||||
w.writeCalls++
|
||||
w.messages = append(w.messages, messages...)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ func (s *LogSink) PublishUnified(_ context.Context, env envelope.FrameEnvelope)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LogSink) PublishFields(_ context.Context, env envelope.FrameEnvelope) error {
|
||||
s.logger.Info("fields envelope", "protocol", env.Protocol, "event_id", env.StableEventID(), "vehicle_key", env.VehicleKey(), "message_id", env.MessageID, "field_count", len(env.Fields))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LogSink) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
139
go/vehicle-gateway/internal/eventbus/nats_sink.go
Normal file
139
go/vehicle-gateway/internal/eventbus/nats_sink.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||||
)
|
||||
|
||||
type NATSConfig struct {
|
||||
URL string
|
||||
Name string
|
||||
RawSubjects map[envelope.Protocol]string
|
||||
FieldsSubjects map[envelope.Protocol]string
|
||||
UnifiedSubject string
|
||||
}
|
||||
|
||||
type NATSSink struct {
|
||||
conn *nats.Conn
|
||||
publisher natsPublisher
|
||||
rawSubjects map[envelope.Protocol]string
|
||||
fieldsSubjects map[envelope.Protocol]string
|
||||
unifiedSubject string
|
||||
}
|
||||
|
||||
type NATSPublishOption = nats.PubOpt
|
||||
|
||||
type natsPublisher interface {
|
||||
Publish(context.Context, string, []byte, ...NATSPublishOption) error
|
||||
}
|
||||
|
||||
func NewNATSSink(cfg NATSConfig) (*NATSSink, error) {
|
||||
if cfg.URL == "" {
|
||||
return nil, errors.New("nats url is required")
|
||||
}
|
||||
name := cfg.Name
|
||||
if name == "" {
|
||||
name = "lingniu-vehicle-gateway"
|
||||
}
|
||||
conn, err := nats.Connect(cfg.URL, nats.Name(name), nats.Timeout(5*time.Second))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
js, err := conn.JetStream()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
sink := newNATSSinkWithPublisher(natsJetStreamPublisher{js: js}, cfg)
|
||||
sink.conn = conn
|
||||
return sink, nil
|
||||
}
|
||||
|
||||
func newNATSSinkWithPublisher(publisher natsPublisher, cfg NATSConfig) *NATSSink {
|
||||
rawSubjects := map[envelope.Protocol]string{
|
||||
envelope.ProtocolGB32960: topics.RawGB32960,
|
||||
envelope.ProtocolJT808: topics.RawJT808,
|
||||
envelope.ProtocolYutongMQTT: topics.RawYutongMQTT,
|
||||
}
|
||||
for protocol, subject := range cfg.RawSubjects {
|
||||
if subject != "" {
|
||||
rawSubjects[protocol] = subject
|
||||
}
|
||||
}
|
||||
fieldsSubjects := map[envelope.Protocol]string{
|
||||
envelope.ProtocolGB32960: topics.FieldsGB32960,
|
||||
envelope.ProtocolJT808: topics.FieldsJT808,
|
||||
envelope.ProtocolYutongMQTT: topics.FieldsYutongMQTT,
|
||||
}
|
||||
for protocol, subject := range cfg.FieldsSubjects {
|
||||
if subject != "" {
|
||||
fieldsSubjects[protocol] = subject
|
||||
}
|
||||
}
|
||||
unifiedSubject := cfg.UnifiedSubject
|
||||
if unifiedSubject == "" {
|
||||
unifiedSubject = topics.Unified
|
||||
}
|
||||
return &NATSSink{
|
||||
publisher: publisher,
|
||||
rawSubjects: rawSubjects,
|
||||
fieldsSubjects: fieldsSubjects,
|
||||
unifiedSubject: unifiedSubject,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NATSSink) PublishRaw(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
subject, ok := s.rawSubjects[env.Protocol]
|
||||
if !ok || subject == "" {
|
||||
return fmt.Errorf("raw subject not configured for protocol %s", env.Protocol)
|
||||
}
|
||||
return s.publish(ctx, subject, env)
|
||||
}
|
||||
|
||||
func (s *NATSSink) PublishUnified(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
if s.unifiedSubject == "" {
|
||||
return errors.New("unified subject is empty")
|
||||
}
|
||||
return s.publish(ctx, s.unifiedSubject, env)
|
||||
}
|
||||
|
||||
func (s *NATSSink) PublishFields(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
subject, ok := s.fieldsSubjects[env.Protocol]
|
||||
if !ok || subject == "" {
|
||||
return fmt.Errorf("fields subject not configured for protocol %s", env.Protocol)
|
||||
}
|
||||
return s.publish(ctx, subject, env)
|
||||
}
|
||||
|
||||
func (s *NATSSink) Close() error {
|
||||
if s == nil || s.conn == nil {
|
||||
return nil
|
||||
}
|
||||
_ = s.conn.Drain()
|
||||
s.conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *NATSSink) publish(ctx context.Context, subject string, env envelope.FrameEnvelope) error {
|
||||
payload, err := env.MarshalJSONBytes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.publisher.Publish(ctx, subject, payload, nats.MsgId(env.StableEventID()))
|
||||
}
|
||||
|
||||
type natsJetStreamPublisher struct {
|
||||
js nats.JetStreamContext
|
||||
}
|
||||
|
||||
func (p natsJetStreamPublisher) Publish(ctx context.Context, subject string, data []byte, opts ...NATSPublishOption) error {
|
||||
_, err := p.js.Publish(subject, data, append(opts, nats.Context(ctx))...)
|
||||
return err
|
||||
}
|
||||
72
go/vehicle-gateway/internal/eventbus/nats_sink_test.go
Normal file
72
go/vehicle-gateway/internal/eventbus/nats_sink_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package eventbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
)
|
||||
|
||||
func TestNATSSinkRoutesRawAndUnifiedSubjects(t *testing.T) {
|
||||
publisher := &recordingNATSPublisher{}
|
||||
sink := newNATSSinkWithPublisher(publisher, NATSConfig{
|
||||
RawSubjects: map[envelope.Protocol]string{
|
||||
envelope.ProtocolJT808: "vehicle.raw.jt808.v1",
|
||||
},
|
||||
UnifiedSubject: "vehicle.event.unified.v1",
|
||||
})
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
|
||||
|
||||
if err := sink.PublishRaw(context.Background(), env); err != nil {
|
||||
t.Fatalf("PublishRaw() error = %v", err)
|
||||
}
|
||||
if err := sink.PublishUnified(context.Background(), env); err != nil {
|
||||
t.Fatalf("PublishUnified() error = %v", err)
|
||||
}
|
||||
|
||||
if got, want := publisher.messages[0].subject, "vehicle.raw.jt808.v1"; got != want {
|
||||
t.Fatalf("raw subject = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := publisher.messages[1].subject, "vehicle.event.unified.v1"; got != want {
|
||||
t.Fatalf("unified subject = %q, want %q", got, want)
|
||||
}
|
||||
var decoded envelope.FrameEnvelope
|
||||
if err := json.Unmarshal(publisher.messages[0].data, &decoded); err != nil {
|
||||
t.Fatalf("raw payload is not envelope json: %v", err)
|
||||
}
|
||||
if decoded.Phone != "13307795425" {
|
||||
t.Fatalf("decoded phone = %q", decoded.Phone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNATSSinkDefaultsToGoRawSubjects(t *testing.T) {
|
||||
publisher := &recordingNATSPublisher{}
|
||||
sink := newNATSSinkWithPublisher(publisher, NATSConfig{})
|
||||
|
||||
err := sink.PublishRaw(context.Background(), envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
Phone: "13307795425",
|
||||
ReceivedAtMS: 1782918600000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PublishRaw() error = %v", err)
|
||||
}
|
||||
if got, want := publisher.messages[0].subject, "vehicle.raw.go.jt808.v1"; got != want {
|
||||
t.Fatalf("subject = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingNATSPublisher struct {
|
||||
messages []recordedNATSMessage
|
||||
}
|
||||
|
||||
type recordedNATSMessage struct {
|
||||
subject string
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (p *recordingNATSPublisher) Publish(_ context.Context, subject string, data []byte, _ ...NATSPublishOption) error {
|
||||
p.messages = append(p.messages, recordedNATSMessage{subject: subject, data: append([]byte(nil), data...)})
|
||||
return nil
|
||||
}
|
||||
@@ -40,6 +40,39 @@ func (s *RetryingSink) PublishUnified(ctx context.Context, env envelope.FrameEnv
|
||||
})
|
||||
}
|
||||
|
||||
func (s *RetryingSink) PublishFields(ctx context.Context, env envelope.FrameEnvelope) error {
|
||||
return s.withRetry(ctx, func(attemptCtx context.Context) error {
|
||||
return s.delegate.PublishFields(attemptCtx, env)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *RetryingSink) PublishRecords(ctx context.Context, records []durableRecord) error {
|
||||
if publisher, ok := s.delegate.(recordPublishingSink); ok {
|
||||
return s.withRetry(ctx, func(attemptCtx context.Context) error {
|
||||
return publisher.PublishRecords(attemptCtx, records)
|
||||
})
|
||||
}
|
||||
for _, record := range records {
|
||||
switch record.Kind {
|
||||
case "raw":
|
||||
if err := s.PublishRaw(ctx, record.Envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
case "unified":
|
||||
if err := s.PublishUnified(ctx, record.Envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
case "fields":
|
||||
if err := s.PublishFields(ctx, record.Envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return errUnknownRecordKind(record.Kind)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RetryingSink) Close() error {
|
||||
return s.delegate.Close()
|
||||
}
|
||||
|
||||
@@ -75,6 +75,10 @@ func (s *flakySink) PublishUnified(context.Context, envelope.FrameEnvelope) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *flakySink) PublishFields(context.Context, envelope.FrameEnvelope) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *flakySink) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -17,7 +16,9 @@ import (
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/eventbus"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/yutongmqtt"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime"
|
||||
)
|
||||
|
||||
type MQTTClientConfig struct {
|
||||
@@ -38,6 +39,8 @@ type MQTTClientConfig struct {
|
||||
Sink eventbus.Sink
|
||||
Resolver identity.Resolver
|
||||
Logger *slog.Logger
|
||||
Metrics *metrics.Registry
|
||||
PublishUnified bool
|
||||
}
|
||||
|
||||
type MQTTClient struct {
|
||||
@@ -176,6 +179,15 @@ func (c *MQTTClient) buildTLSConfig() (*tls.Config, error) {
|
||||
}
|
||||
|
||||
func (c *MQTTClient) handleMessage(ctx context.Context, topic string, payload []byte) {
|
||||
started := time.Now()
|
||||
frameStatus := envelope.ParseBadFrame
|
||||
defer func() {
|
||||
c.recordFrameDuration(frameStatus, time.Since(started))
|
||||
}()
|
||||
|
||||
messageCtx, cancelMessage := context.WithTimeout(context.WithoutCancel(ctx), frameOperationTimeout)
|
||||
defer cancelMessage()
|
||||
|
||||
receivedAtMS := time.Now().UnixMilli()
|
||||
env, err := yutongmqtt.ParseMessage(c.cfg.EndpointName, topic, payload, receivedAtMS)
|
||||
if err != nil {
|
||||
@@ -187,13 +199,13 @@ func (c *MQTTClient) handleMessage(ctx context.Context, topic string, payload []
|
||||
EventTimeMS: receivedAtMS,
|
||||
ReceivedAtMS: receivedAtMS,
|
||||
RawText: string(payload),
|
||||
RawHex: strings.ToUpper(hex.EncodeToString(payload)),
|
||||
ParseStatus: envelope.ParseBadFrame,
|
||||
ParseError: err.Error(),
|
||||
}
|
||||
env.EventID = env.StableEventID()
|
||||
c.recordParseErrorMetric(err)
|
||||
} else {
|
||||
resolved, resolveErr := c.cfg.Resolver.Resolve(ctx, env)
|
||||
resolved, resolveErr := c.cfg.Resolver.Resolve(messageCtx, env)
|
||||
if resolveErr != nil {
|
||||
c.cfg.Logger.Warn("mqtt identity resolve failed", "topic", topic, "event_id", env.StableEventID(), "error", resolveErr)
|
||||
if env.Parsed == nil {
|
||||
@@ -201,18 +213,98 @@ func (c *MQTTClient) handleMessage(ctx context.Context, topic string, payload []
|
||||
}
|
||||
env.Parsed["identity"] = map[string]any{"resolved": false, "error": resolveErr.Error()}
|
||||
env.ParseStatus = envelope.ParsePartial
|
||||
c.recordIdentityMetric("error")
|
||||
} else {
|
||||
env = resolved
|
||||
annotateIdentityUnresolved(&env)
|
||||
c.recordIdentityMetric(identityStatus(env))
|
||||
}
|
||||
}
|
||||
if err := c.cfg.Sink.PublishRaw(ctx, env); err != nil {
|
||||
frameStatus = env.ParseStatus
|
||||
c.recordFrameMetric(env.ParseStatus)
|
||||
if env.ParseStatus != envelope.ParseBadFrame {
|
||||
realtime.EnsureParsedFields(&env)
|
||||
}
|
||||
if err := c.cfg.Sink.PublishRaw(messageCtx, env); err != nil {
|
||||
c.recordPublishMetric("raw", "error")
|
||||
c.cfg.Logger.Error("publish mqtt raw failed", "topic", topic, "event_id", env.StableEventID(), "error", err)
|
||||
return
|
||||
}
|
||||
c.recordPublishMetric("raw", "ok")
|
||||
if env.ParseStatus == envelope.ParseBadFrame {
|
||||
return
|
||||
}
|
||||
if err := c.cfg.Sink.PublishUnified(ctx, env); err != nil {
|
||||
if fieldsEnv, ok := realtime.BuildFieldsEnvelope(env); ok {
|
||||
if err := c.cfg.Sink.PublishFields(messageCtx, fieldsEnv); err != nil {
|
||||
c.recordPublishMetric("fields", "error")
|
||||
c.cfg.Logger.Error("publish mqtt fields failed", "topic", topic, "event_id", env.StableEventID(), "error", err)
|
||||
return
|
||||
}
|
||||
c.recordPublishMetric("fields", "ok")
|
||||
}
|
||||
if c.cfg.PublishUnified {
|
||||
if err := c.cfg.Sink.PublishUnified(messageCtx, env); err != nil {
|
||||
c.recordPublishMetric("unified", "error")
|
||||
c.cfg.Logger.Error("publish mqtt unified failed", "topic", topic, "event_id", env.StableEventID(), "error", err)
|
||||
return
|
||||
}
|
||||
c.recordPublishMetric("unified", "ok")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MQTTClient) recordFrameMetric(status envelope.ParseStatus) {
|
||||
if c.cfg.Metrics == nil {
|
||||
return
|
||||
}
|
||||
c.cfg.Metrics.IncCounter("vehicle_gateway_frames_total", metrics.Labels{
|
||||
"protocol": string(envelope.ProtocolYutongMQTT),
|
||||
"status": string(status),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *MQTTClient) recordFrameDuration(status envelope.ParseStatus, elapsed time.Duration) {
|
||||
if c.cfg.Metrics == nil {
|
||||
return
|
||||
}
|
||||
elapsedMS := float64(elapsed.Milliseconds())
|
||||
labels := metrics.Labels{
|
||||
"protocol": string(envelope.ProtocolYutongMQTT),
|
||||
"status": string(status),
|
||||
}
|
||||
c.cfg.Metrics.SetGauge("vehicle_gateway_frame_duration_ms", metrics.Labels{
|
||||
"protocol": string(envelope.ProtocolYutongMQTT),
|
||||
"status": string(status),
|
||||
}, elapsedMS)
|
||||
c.cfg.Metrics.ObserveHistogram("vehicle_gateway_frame_duration_ms_histogram", labels, gatewayFrameDurationBucketsMS, elapsedMS)
|
||||
}
|
||||
|
||||
func (c *MQTTClient) recordPublishMetric(kind string, status string) {
|
||||
if c.cfg.Metrics == nil {
|
||||
return
|
||||
}
|
||||
c.cfg.Metrics.IncCounter("vehicle_gateway_publish_total", metrics.Labels{
|
||||
"protocol": string(envelope.ProtocolYutongMQTT),
|
||||
"kind": kind,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *MQTTClient) recordParseErrorMetric(err error) {
|
||||
if c.cfg.Metrics == nil {
|
||||
return
|
||||
}
|
||||
c.cfg.Metrics.IncCounter("vehicle_gateway_parse_errors_total", metrics.Labels{
|
||||
"protocol": string(envelope.ProtocolYutongMQTT),
|
||||
"reason": classifyError(err),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *MQTTClient) recordIdentityMetric(status string) {
|
||||
if c.cfg.Metrics == nil {
|
||||
return
|
||||
}
|
||||
c.cfg.Metrics.IncCounter("vehicle_gateway_identity_total", metrics.Labels{
|
||||
"protocol": string(envelope.ProtocolYutongMQTT),
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,13 +11,15 @@ import (
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
)
|
||||
|
||||
func TestMQTTClientHandleMessagePublishesRawAndUnified(t *testing.T) {
|
||||
func TestMQTTClientHandleMessagePublishesOnlyRawByDefault(t *testing.T) {
|
||||
sink := &recordingSink{}
|
||||
client, err := NewMQTTClient(MQTTClientConfig{
|
||||
EndpointName: "endpoint-a",
|
||||
@@ -37,16 +39,63 @@ func TestMQTTClientHandleMessagePublishesRawAndUnified(t *testing.T) {
|
||||
"data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7}
|
||||
}`))
|
||||
|
||||
if len(sink.raw) != 1 || len(sink.unified) != 1 {
|
||||
if len(sink.raw) != 1 || len(sink.unified) != 0 {
|
||||
t.Fatalf("raw=%d unified=%d", len(sink.raw), len(sink.unified))
|
||||
}
|
||||
if sink.raw[0].Protocol != envelope.ProtocolYutongMQTT || sink.raw[0].VIN != "LTEST000000000001" {
|
||||
t.Fatalf("unexpected raw envelope: %#v", sink.raw[0])
|
||||
}
|
||||
if sink.raw[0].RawText == "" {
|
||||
t.Fatal("mqtt raw envelope should keep text payload")
|
||||
}
|
||||
if sink.raw[0].RawHex != "" {
|
||||
t.Fatalf("mqtt raw envelope should not duplicate text payload as hex: %q", sink.raw[0].RawHex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTClientRecordsMessageMetrics(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
client, err := NewMQTTClient(MQTTClientConfig{
|
||||
EndpointName: "endpoint-a",
|
||||
Broker: "tcp://127.0.0.1:1883",
|
||||
ClientID: "test-client",
|
||||
Topics: []string{"/ytforward/shln/+"},
|
||||
Sink: &recordingSink{},
|
||||
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
|
||||
Metrics: registry,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMQTTClient() error = %v", err)
|
||||
}
|
||||
|
||||
client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{
|
||||
"device":"LTEST000000000001",
|
||||
"time":"20260413100000",
|
||||
"data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7}
|
||||
}`))
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_gateway_frames_total{protocol="YUTONG_MQTT",status="OK"} 1`,
|
||||
`vehicle_gateway_identity_total{protocol="YUTONG_MQTT",status="resolved"} 1`,
|
||||
`vehicle_gateway_publish_total{kind="raw",protocol="YUTONG_MQTT",status="ok"} 1`,
|
||||
`vehicle_gateway_frame_duration_ms{protocol="YUTONG_MQTT",status="OK"}`,
|
||||
`vehicle_gateway_frame_duration_ms_histogram_bucket{le="+Inf",protocol="YUTONG_MQTT",status="OK"} 1`,
|
||||
`vehicle_gateway_frame_duration_ms_histogram_count{protocol="YUTONG_MQTT",status="OK"} 1`,
|
||||
`vehicle_gateway_frame_duration_ms_histogram_sum{protocol="YUTONG_MQTT",status="OK"}`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("metrics missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, `kind="unified"`) {
|
||||
t.Fatalf("unified publish metric should not be recorded by default:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTClientHandleBadPayloadPublishesOnlyRaw(t *testing.T) {
|
||||
sink := &recordingSink{}
|
||||
registry := metrics.NewRegistry()
|
||||
client, err := NewMQTTClient(MQTTClientConfig{
|
||||
EndpointName: "endpoint-a",
|
||||
Broker: "tcp://127.0.0.1:1883",
|
||||
@@ -54,6 +103,7 @@ func TestMQTTClientHandleBadPayloadPublishesOnlyRaw(t *testing.T) {
|
||||
Topics: []string{"/ytforward/shln/+"},
|
||||
Sink: sink,
|
||||
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
|
||||
Metrics: registry,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMQTTClient() error = %v", err)
|
||||
@@ -67,6 +117,80 @@ func TestMQTTClientHandleBadPayloadPublishesOnlyRaw(t *testing.T) {
|
||||
if sink.raw[0].ParseStatus != envelope.ParseBadFrame {
|
||||
t.Fatalf("parse status = %q", sink.raw[0].ParseStatus)
|
||||
}
|
||||
if sink.raw[0].RawText == "" {
|
||||
t.Fatal("bad mqtt raw envelope should keep text payload")
|
||||
}
|
||||
if sink.raw[0].RawHex != "" {
|
||||
t.Fatalf("bad mqtt raw envelope should not duplicate text payload as hex: %q", sink.raw[0].RawHex)
|
||||
}
|
||||
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_parse_errors_total{protocol="YUTONG_MQTT",reason="json"} 1`) {
|
||||
t.Fatalf("parse error metric missing:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTClientUsesUncancelledMessageContextForReceivedMessage(t *testing.T) {
|
||||
parent, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
resolver := &contextCheckingResolver{}
|
||||
sink := &contextCheckingSink{}
|
||||
client, err := NewMQTTClient(MQTTClientConfig{
|
||||
EndpointName: "endpoint-a",
|
||||
Broker: "tcp://127.0.0.1:1883",
|
||||
ClientID: "test-client",
|
||||
Topics: []string{"/ytforward/shln/+"},
|
||||
Sink: sink,
|
||||
Resolver: resolver,
|
||||
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMQTTClient() error = %v", err)
|
||||
}
|
||||
|
||||
client.handleMessage(parent, "/ytforward/shln/dev1", []byte(`{
|
||||
"device":"LTEST000000000001",
|
||||
"time":"20260413100000",
|
||||
"data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7}
|
||||
}`))
|
||||
|
||||
if resolver.ctxErr != nil {
|
||||
t.Fatalf("resolver saw cancelled context: %v", resolver.ctxErr)
|
||||
}
|
||||
if sink.rawCtxErr != nil {
|
||||
t.Fatalf("raw publish saw cancelled context: %v", sink.rawCtxErr)
|
||||
}
|
||||
if sink.unifiedCtxErr != nil {
|
||||
t.Fatalf("unified publish saw cancelled context: %v", sink.unifiedCtxErr)
|
||||
}
|
||||
if sink.rawCount != 1 || sink.unifiedCount != 0 {
|
||||
t.Fatalf("raw=%d unified=%d", sink.rawCount, sink.unifiedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTClientPublishesUnifiedWhenExplicitlyEnabled(t *testing.T) {
|
||||
sink := &recordingSink{}
|
||||
client, err := NewMQTTClient(MQTTClientConfig{
|
||||
EndpointName: "endpoint-a",
|
||||
Broker: "tcp://127.0.0.1:1883",
|
||||
ClientID: "test-client",
|
||||
Topics: []string{"/ytforward/shln/+"},
|
||||
PublishUnified: true,
|
||||
Sink: sink,
|
||||
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewMQTTClient() error = %v", err)
|
||||
}
|
||||
|
||||
client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{
|
||||
"device":"LTEST000000000001",
|
||||
"time":"20260413100000",
|
||||
"data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7}
|
||||
}`))
|
||||
|
||||
if len(sink.raw) != 1 || len(sink.unified) != 1 {
|
||||
t.Fatalf("raw=%d unified=%d", len(sink.raw), len(sink.unified))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTClientBuildOptionsLoadsTLSCertificates(t *testing.T) {
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/eventbus"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime"
|
||||
)
|
||||
|
||||
type FrameExtractor func([]byte) (frames [][]byte, remainder []byte, err error)
|
||||
@@ -31,14 +33,20 @@ type TCPProtocol struct {
|
||||
Respond FrameResponder
|
||||
}
|
||||
|
||||
type connectionState struct {
|
||||
platformName string
|
||||
}
|
||||
|
||||
type TCPServer struct {
|
||||
protocol TCPProtocol
|
||||
sink eventbus.Sink
|
||||
resolver identity.Resolver
|
||||
logger *slog.Logger
|
||||
metrics *metrics.Registry
|
||||
readBufferSize int
|
||||
idleTimeout time.Duration
|
||||
maxConnections int
|
||||
publishUnified bool
|
||||
}
|
||||
|
||||
type TCPServerConfig struct {
|
||||
@@ -46,11 +54,17 @@ type TCPServerConfig struct {
|
||||
Sink eventbus.Sink
|
||||
Resolver identity.Resolver
|
||||
Logger *slog.Logger
|
||||
Metrics *metrics.Registry
|
||||
ReadBufferSize int
|
||||
IdleTimeout time.Duration
|
||||
MaxConnections int
|
||||
PublishUnified bool
|
||||
}
|
||||
|
||||
const frameOperationTimeout = 30 * time.Second
|
||||
|
||||
var gatewayFrameDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
|
||||
|
||||
func NewTCPServer(cfg TCPServerConfig) (*TCPServer, error) {
|
||||
if cfg.Protocol.Protocol == "" {
|
||||
return nil, errors.New("protocol is required")
|
||||
@@ -87,9 +101,11 @@ func NewTCPServer(cfg TCPServerConfig) (*TCPServer, error) {
|
||||
sink: cfg.Sink,
|
||||
resolver: cfg.Resolver,
|
||||
logger: cfg.Logger,
|
||||
metrics: cfg.Metrics,
|
||||
readBufferSize: cfg.ReadBufferSize,
|
||||
idleTimeout: cfg.IdleTimeout,
|
||||
maxConnections: cfg.MaxConnections,
|
||||
publishUnified: cfg.PublishUnified,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -129,6 +145,8 @@ func (s *TCPServer) ListenAndServe(ctx context.Context) error {
|
||||
s.handleConnection(ctx, conn)
|
||||
}()
|
||||
default:
|
||||
s.recordConnectionRejection("max_connections")
|
||||
s.recordConnectionClose("max_connections")
|
||||
s.logger.Warn("tcp connection rejected: max connections reached", "protocol", s.protocol.Protocol, "remote", conn.RemoteAddr().String())
|
||||
_ = conn.Close()
|
||||
}
|
||||
@@ -139,11 +157,14 @@ func (s *TCPServer) handleConnection(ctx context.Context, conn net.Conn) {
|
||||
defer conn.Close()
|
||||
source := conn.RemoteAddr().String()
|
||||
log := s.logger.With("protocol", s.protocol.Protocol, "remote", source)
|
||||
s.recordConnectionMetric(1)
|
||||
defer s.recordConnectionMetric(-1)
|
||||
log.Info("tcp connection opened")
|
||||
defer log.Info("tcp connection closed")
|
||||
|
||||
readBuffer := make([]byte, s.readBufferSize)
|
||||
var pending []byte
|
||||
state := &connectionState{}
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(s.idleTimeout))
|
||||
n, err := conn.Read(readBuffer)
|
||||
@@ -152,32 +173,46 @@ func (s *TCPServer) handleConnection(ctx context.Context, conn net.Conn) {
|
||||
frames, remainder, extractErr := s.protocol.Extract(pending)
|
||||
if extractErr != nil {
|
||||
log.Warn("frame extraction failed", "error", extractErr)
|
||||
s.recordConnectionClose("extract_error")
|
||||
return
|
||||
}
|
||||
pending = remainder
|
||||
for _, frame := range frames {
|
||||
s.handleFrame(ctx, conn, frame, source)
|
||||
s.handleFrame(ctx, conn, frame, source, state)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
s.recordConnectionClose("eof")
|
||||
return
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
log.Warn("tcp connection idle timeout")
|
||||
s.recordConnectionClose("read_timeout")
|
||||
return
|
||||
}
|
||||
log.Warn("tcp read failed", "error", err)
|
||||
s.recordConnectionClose("read_error")
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
s.recordConnectionClose("context_cancelled")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TCPServer) handleFrame(ctx context.Context, conn net.Conn, raw []byte, source string) {
|
||||
func (s *TCPServer) handleFrame(ctx context.Context, conn net.Conn, raw []byte, source string, state *connectionState) {
|
||||
started := time.Now()
|
||||
frameStatus := envelope.ParseBadFrame
|
||||
defer func() {
|
||||
s.recordFrameDuration(frameStatus, time.Since(started))
|
||||
}()
|
||||
|
||||
frameCtx, cancelFrame := context.WithTimeout(context.WithoutCancel(ctx), frameOperationTimeout)
|
||||
defer cancelFrame()
|
||||
|
||||
receivedAtMS := time.Now().UnixMilli()
|
||||
env, err := s.protocol.Parse(raw, receivedAtMS, source)
|
||||
if err != nil {
|
||||
@@ -191,8 +226,9 @@ func (s *TCPServer) handleFrame(ctx context.Context, conn net.Conn, raw []byte,
|
||||
ParseError: err.Error(),
|
||||
}
|
||||
env.EventID = env.StableEventID()
|
||||
s.recordParseErrorMetric(err)
|
||||
} else {
|
||||
resolved, resolveErr := s.resolver.Resolve(ctx, env)
|
||||
resolved, resolveErr := s.resolver.Resolve(frameCtx, env)
|
||||
if resolveErr != nil {
|
||||
s.logger.Warn("identity resolve failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", resolveErr)
|
||||
if env.Parsed == nil {
|
||||
@@ -200,22 +236,45 @@ func (s *TCPServer) handleFrame(ctx context.Context, conn net.Conn, raw []byte,
|
||||
}
|
||||
env.Parsed["identity"] = map[string]any{"resolved": false, "error": resolveErr.Error()}
|
||||
env.ParseStatus = envelope.ParsePartial
|
||||
s.recordIdentityMetric("error")
|
||||
} else {
|
||||
env = resolved
|
||||
annotateIdentityUnresolved(&env)
|
||||
s.recordIdentityMetric(identityStatus(env))
|
||||
}
|
||||
enrichConnectionPlatform(&env, state)
|
||||
}
|
||||
frameStatus = env.ParseStatus
|
||||
s.recordFrameMetric(env.ParseStatus)
|
||||
if env.ParseStatus != envelope.ParseBadFrame {
|
||||
realtime.EnsureParsedFields(&env)
|
||||
}
|
||||
|
||||
if err := s.sink.PublishRaw(ctx, env); err != nil {
|
||||
if err := s.sink.PublishRaw(frameCtx, env); err != nil {
|
||||
s.recordPublishMetric("raw", "error")
|
||||
s.logger.Error("publish raw failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
|
||||
return
|
||||
}
|
||||
s.recordPublishMetric("raw", "ok")
|
||||
if env.ParseStatus == envelope.ParseBadFrame {
|
||||
return
|
||||
}
|
||||
if err := s.sink.PublishUnified(ctx, env); err != nil {
|
||||
if fieldsEnv, ok := realtime.BuildFieldsEnvelope(env); ok {
|
||||
if err := s.sink.PublishFields(frameCtx, fieldsEnv); err != nil {
|
||||
s.recordPublishMetric("fields", "error")
|
||||
s.logger.Error("publish fields failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
|
||||
return
|
||||
}
|
||||
s.recordPublishMetric("fields", "ok")
|
||||
}
|
||||
if s.publishUnified {
|
||||
if err := s.sink.PublishUnified(frameCtx, env); err != nil {
|
||||
s.recordPublishMetric("unified", "error")
|
||||
s.logger.Error("publish unified failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
|
||||
return
|
||||
}
|
||||
s.recordPublishMetric("unified", "ok")
|
||||
}
|
||||
if s.protocol.Respond == nil {
|
||||
return
|
||||
}
|
||||
@@ -233,6 +292,160 @@ func (s *TCPServer) handleFrame(ctx context.Context, conn net.Conn, raw []byte,
|
||||
}
|
||||
}
|
||||
|
||||
func enrichConnectionPlatform(env *envelope.FrameEnvelope, state *connectionState) {
|
||||
if env == nil || state == nil || env.ParseStatus == envelope.ParseBadFrame {
|
||||
return
|
||||
}
|
||||
if env.Fields == nil {
|
||||
env.Fields = map[string]any{}
|
||||
}
|
||||
if current := strings.TrimSpace(fmt.Sprint(env.Fields["platform_account"])); current != "" && current != "<nil>" {
|
||||
state.platformName = current
|
||||
}
|
||||
if state.platformName == "" {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(fmt.Sprint(env.Fields["platform_account"])) == "" || fmt.Sprint(env.Fields["platform_account"]) == "<nil>" {
|
||||
env.Fields["platform_account"] = state.platformName
|
||||
}
|
||||
if env.Parsed == nil {
|
||||
env.Parsed = map[string]any{}
|
||||
}
|
||||
if _, ok := env.Parsed["platform_name"]; !ok {
|
||||
env.Parsed["platform_name"] = state.platformName
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TCPServer) recordFrameMetric(status envelope.ParseStatus) {
|
||||
if s.metrics == nil {
|
||||
return
|
||||
}
|
||||
s.metrics.IncCounter("vehicle_gateway_frames_total", metrics.Labels{
|
||||
"protocol": string(s.protocol.Protocol),
|
||||
"status": string(status),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *TCPServer) recordFrameDuration(status envelope.ParseStatus, elapsed time.Duration) {
|
||||
if s.metrics == nil {
|
||||
return
|
||||
}
|
||||
elapsedMS := float64(elapsed.Milliseconds())
|
||||
labels := metrics.Labels{
|
||||
"protocol": string(s.protocol.Protocol),
|
||||
"status": string(status),
|
||||
}
|
||||
s.metrics.SetGauge("vehicle_gateway_frame_duration_ms", metrics.Labels{
|
||||
"protocol": string(s.protocol.Protocol),
|
||||
"status": string(status),
|
||||
}, elapsedMS)
|
||||
s.metrics.ObserveHistogram("vehicle_gateway_frame_duration_ms_histogram", labels, gatewayFrameDurationBucketsMS, elapsedMS)
|
||||
}
|
||||
|
||||
func (s *TCPServer) recordPublishMetric(kind string, status string) {
|
||||
if s.metrics == nil {
|
||||
return
|
||||
}
|
||||
s.metrics.IncCounter("vehicle_gateway_publish_total", metrics.Labels{
|
||||
"protocol": string(s.protocol.Protocol),
|
||||
"kind": kind,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *TCPServer) recordParseErrorMetric(err error) {
|
||||
if s.metrics == nil {
|
||||
return
|
||||
}
|
||||
s.metrics.IncCounter("vehicle_gateway_parse_errors_total", metrics.Labels{
|
||||
"protocol": string(s.protocol.Protocol),
|
||||
"reason": classifyError(err),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *TCPServer) recordIdentityMetric(status string) {
|
||||
if s.metrics == nil {
|
||||
return
|
||||
}
|
||||
s.metrics.IncCounter("vehicle_gateway_identity_total", metrics.Labels{
|
||||
"protocol": string(s.protocol.Protocol),
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *TCPServer) recordConnectionMetric(delta float64) {
|
||||
if s.metrics == nil {
|
||||
return
|
||||
}
|
||||
s.metrics.AddGauge("vehicle_gateway_active_connections", metrics.Labels{
|
||||
"protocol": string(s.protocol.Protocol),
|
||||
}, delta)
|
||||
}
|
||||
|
||||
func (s *TCPServer) recordConnectionRejection(reason string) {
|
||||
if s.metrics == nil {
|
||||
return
|
||||
}
|
||||
s.metrics.IncCounter("vehicle_gateway_connection_rejections_total", metrics.Labels{
|
||||
"protocol": string(s.protocol.Protocol),
|
||||
"reason": reason,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *TCPServer) recordConnectionClose(reason string) {
|
||||
if s.metrics == nil {
|
||||
return
|
||||
}
|
||||
s.metrics.IncCounter("vehicle_gateway_connection_closes_total", metrics.Labels{
|
||||
"protocol": string(s.protocol.Protocol),
|
||||
"reason": reason,
|
||||
})
|
||||
}
|
||||
|
||||
func identityStatus(env envelope.FrameEnvelope) string {
|
||||
if strings.TrimSpace(env.VIN) != "" {
|
||||
return "resolved"
|
||||
}
|
||||
if env.ParseStatus == envelope.ParsePartial {
|
||||
return "error"
|
||||
}
|
||||
return "unresolved"
|
||||
}
|
||||
|
||||
func annotateIdentityUnresolved(env *envelope.FrameEnvelope) {
|
||||
if env == nil || env.ParseStatus == envelope.ParseBadFrame || strings.TrimSpace(env.VIN) != "" {
|
||||
return
|
||||
}
|
||||
if env.Parsed == nil {
|
||||
env.Parsed = map[string]any{}
|
||||
}
|
||||
if _, exists := env.Parsed["identity"]; exists {
|
||||
return
|
||||
}
|
||||
env.Parsed["identity"] = map[string]any{
|
||||
"resolved": false,
|
||||
"reason": "no_binding",
|
||||
}
|
||||
}
|
||||
|
||||
func classifyError(err error) string {
|
||||
text := strings.ToLower(strings.TrimSpace(fmt.Sprint(err)))
|
||||
switch {
|
||||
case text == "":
|
||||
return "unknown"
|
||||
case strings.Contains(text, "bcc") || strings.Contains(text, "checksum"):
|
||||
return "checksum"
|
||||
case strings.Contains(text, "short") || strings.Contains(text, "truncated") || strings.Contains(text, "length"):
|
||||
return "length"
|
||||
case strings.Contains(text, "json"):
|
||||
return "json"
|
||||
case strings.Contains(text, "start"):
|
||||
return "start_symbol"
|
||||
default:
|
||||
return "parse"
|
||||
}
|
||||
}
|
||||
|
||||
func (p TCPProtocol) String() string {
|
||||
return fmt.Sprintf("%s@%s", p.Protocol, p.Addr)
|
||||
}
|
||||
|
||||
@@ -6,15 +6,17 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/gb32960"
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/jt808"
|
||||
)
|
||||
|
||||
func TestTCPServerPublishesGoodFrameToRawAndUnified(t *testing.T) {
|
||||
func TestTCPServerPublishesGoodFrameOnlyToRawByDefault(t *testing.T) {
|
||||
frame, err := hex.DecodeString("7E020000320133077954250001000000000048000301D2C4C707376139000A00E6004F26063016235701040001900C2504000000000202000030011F31010F867E")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -34,10 +36,10 @@ func TestTCPServerPublishesGoodFrameToRawAndUnified(t *testing.T) {
|
||||
_ = client.Close()
|
||||
<-done
|
||||
|
||||
if len(sink.raw) != 1 || len(sink.unified) != 1 {
|
||||
if len(sink.raw) != 1 || len(sink.unified) != 0 {
|
||||
t.Fatalf("raw=%d unified=%d", len(sink.raw), len(sink.unified))
|
||||
}
|
||||
if sink.raw[0].Phone != "013307795425" {
|
||||
if sink.raw[0].Phone != "13307795425" {
|
||||
t.Fatalf("phone = %q", sink.raw[0].Phone)
|
||||
}
|
||||
if sink.raw[0].Fields[envelope.FieldTotalMileageKM] != 10241.2 {
|
||||
@@ -45,16 +47,159 @@ func TestTCPServerPublishesGoodFrameToRawAndUnified(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPServerRecordsFrameMetrics(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
server, err := NewTCPServer(TCPServerConfig{
|
||||
Protocol: TCPProtocol{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
Addr: ":0",
|
||||
Extract: jt808.ExtractFrames,
|
||||
Parse: func(_ []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) {
|
||||
return envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
MessageID: "0x0200",
|
||||
Phone: "13307795425",
|
||||
SourceEndpoint: sourceEndpoint,
|
||||
ReceivedAtMS: receivedAtMS,
|
||||
EventTimeMS: receivedAtMS,
|
||||
ParseStatus: envelope.ParseOK,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
Sink: &recordingSink{},
|
||||
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
|
||||
Metrics: registry,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTCPServer() error = %v", err)
|
||||
}
|
||||
|
||||
server.handleFrame(context.Background(), nil, []byte{0x01}, "127.0.0.1:808", &connectionState{})
|
||||
|
||||
text := registry.Render()
|
||||
for _, want := range []string{
|
||||
`vehicle_gateway_frames_total{protocol="JT808",status="OK"} 1`,
|
||||
`vehicle_gateway_identity_total{protocol="JT808",status="unresolved"} 1`,
|
||||
`vehicle_gateway_publish_total{kind="raw",protocol="JT808",status="ok"} 1`,
|
||||
`vehicle_gateway_frame_duration_ms{protocol="JT808",status="OK"}`,
|
||||
`vehicle_gateway_frame_duration_ms_histogram_bucket{le="+Inf",protocol="JT808",status="OK"} 1`,
|
||||
`vehicle_gateway_frame_duration_ms_histogram_count{protocol="JT808",status="OK"} 1`,
|
||||
`vehicle_gateway_frame_duration_ms_histogram_sum{protocol="JT808",status="OK"}`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("metrics missing %s:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, `kind="unified"`) {
|
||||
t.Fatalf("unified publish metric should not be recorded by default:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPServerRecordsActiveConnectionGauge(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
server, err := NewTCPServer(TCPServerConfig{
|
||||
Protocol: TCPProtocol{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
Addr: ":0",
|
||||
Extract: jt808.ExtractFrames,
|
||||
Parse: jt808.ParseFrame,
|
||||
},
|
||||
Sink: &recordingSink{},
|
||||
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
|
||||
Metrics: registry,
|
||||
IdleTimeout: time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTCPServer() error = %v", err)
|
||||
}
|
||||
client, srv := net.Pipe()
|
||||
opened := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
close(opened)
|
||||
server.handleConnection(context.Background(), srv)
|
||||
close(done)
|
||||
}()
|
||||
<-opened
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if strings.Contains(registry.Render(), `vehicle_gateway_active_connections{protocol="JT808"} 1`) {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_active_connections{protocol="JT808"} 1`) {
|
||||
t.Fatalf("active connection gauge missing while open:\n%s", text)
|
||||
}
|
||||
|
||||
_ = client.Close()
|
||||
<-done
|
||||
|
||||
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_active_connections{protocol="JT808"} 0`) {
|
||||
t.Fatalf("active connection gauge should return to zero:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPServerRecordsConnectionRejectionMetric(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
server, err := NewTCPServer(TCPServerConfig{
|
||||
Protocol: TCPProtocol{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
Addr: ":0",
|
||||
Extract: jt808.ExtractFrames,
|
||||
Parse: jt808.ParseFrame,
|
||||
},
|
||||
Sink: &recordingSink{},
|
||||
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
|
||||
Metrics: registry,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTCPServer() error = %v", err)
|
||||
}
|
||||
|
||||
server.recordConnectionRejection("max_connections")
|
||||
|
||||
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_connection_rejections_total{protocol="JT808",reason="max_connections"} 1`) {
|
||||
t.Fatalf("connection rejection metric missing:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPServerRecordsConnectionCloseReasonMetric(t *testing.T) {
|
||||
registry := metrics.NewRegistry()
|
||||
server, err := NewTCPServer(TCPServerConfig{
|
||||
Protocol: TCPProtocol{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
Addr: ":0",
|
||||
Extract: jt808.ExtractFrames,
|
||||
Parse: jt808.ParseFrame,
|
||||
},
|
||||
Sink: &recordingSink{},
|
||||
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
|
||||
Metrics: registry,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTCPServer() error = %v", err)
|
||||
}
|
||||
|
||||
server.recordConnectionClose("read_timeout")
|
||||
|
||||
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_connection_closes_total{protocol="JT808",reason="read_timeout"} 1`) {
|
||||
t.Fatalf("connection close metric missing:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPServerPublishesBadFrameOnlyToRaw(t *testing.T) {
|
||||
good := buildGBFrame(0x02, 0xfe, "LNBSCB3D4R1234567", nil)
|
||||
good[len(good)-1] ^= 0xff
|
||||
sink := &recordingSink{}
|
||||
registry := metrics.NewRegistry()
|
||||
server := newTestServer(t, TCPProtocol{
|
||||
Protocol: envelope.ProtocolGB32960,
|
||||
Addr: ":0",
|
||||
Extract: gb32960.ExtractFrames,
|
||||
Parse: gb32960.ParseFrame,
|
||||
}, sink)
|
||||
server.metrics = registry
|
||||
|
||||
client, done := runPipe(t, server)
|
||||
if _, err := client.Write(good); err != nil {
|
||||
@@ -72,6 +217,41 @@ func TestTCPServerPublishesBadFrameOnlyToRaw(t *testing.T) {
|
||||
if sink.raw[0].ParseError == "" {
|
||||
t.Fatal("parse error should be recorded")
|
||||
}
|
||||
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_parse_errors_total{protocol="GB32960",reason="checksum"} 1`) {
|
||||
t.Fatalf("parse error metric missing:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPServerAnnotatesUnresolvedIdentity(t *testing.T) {
|
||||
sink := &recordingSink{}
|
||||
server := newTestServer(t, TCPProtocol{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
Addr: ":0",
|
||||
Extract: func(raw []byte) ([][]byte, []byte, error) {
|
||||
return [][]byte{raw}, nil, nil
|
||||
},
|
||||
Parse: func(_ []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) {
|
||||
return envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
MessageID: "0x0200",
|
||||
Phone: "13307795425",
|
||||
SourceEndpoint: sourceEndpoint,
|
||||
ReceivedAtMS: receivedAtMS,
|
||||
EventTimeMS: receivedAtMS,
|
||||
ParseStatus: envelope.ParseOK,
|
||||
}, nil
|
||||
},
|
||||
}, sink)
|
||||
|
||||
server.handleFrame(context.Background(), nil, []byte{0x01}, "127.0.0.1:808", &connectionState{})
|
||||
|
||||
if len(sink.raw) != 1 {
|
||||
t.Fatalf("raw count = %d", len(sink.raw))
|
||||
}
|
||||
identity, ok := sink.raw[0].Parsed["identity"].(map[string]any)
|
||||
if !ok || identity["resolved"] != false || identity["reason"] != "no_binding" {
|
||||
t.Fatalf("identity metadata = %#v", sink.raw[0].Parsed["identity"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPServerWritesProtocolResponseAfterPublish(t *testing.T) {
|
||||
@@ -83,8 +263,8 @@ func TestTCPServerWritesProtocolResponseAfterPublish(t *testing.T) {
|
||||
Extract: gb32960.ExtractFrames,
|
||||
Parse: gb32960.ParseFrame,
|
||||
Respond: func(_ []byte, env envelope.FrameEnvelope) ([]byte, bool, error) {
|
||||
if len(sink.unified) != 1 || sink.unified[0].EventID != env.EventID {
|
||||
t.Fatalf("response built before publish: raw=%d unified=%d", len(sink.raw), len(sink.unified))
|
||||
if len(sink.raw) != 1 || sink.raw[0].EventID != env.EventID {
|
||||
t.Fatalf("response built before raw publish: raw=%d unified=%d", len(sink.raw), len(sink.unified))
|
||||
}
|
||||
return []byte("ACK"), true, nil
|
||||
},
|
||||
@@ -105,6 +285,120 @@ func TestTCPServerWritesProtocolResponseAfterPublish(t *testing.T) {
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestTCPServerUsesUncancelledFrameContextForAlreadyReadFrame(t *testing.T) {
|
||||
parent, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
resolver := &contextCheckingResolver{}
|
||||
sink := &contextCheckingSink{}
|
||||
server, err := NewTCPServer(TCPServerConfig{
|
||||
Protocol: TCPProtocol{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
Addr: ":0",
|
||||
Extract: jt808.ExtractFrames,
|
||||
Parse: func(_ []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) {
|
||||
return envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
MessageID: "0x0200",
|
||||
Phone: "13307795425",
|
||||
SourceEndpoint: sourceEndpoint,
|
||||
ReceivedAtMS: receivedAtMS,
|
||||
EventTimeMS: receivedAtMS,
|
||||
ParseStatus: envelope.ParseOK,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
Sink: sink,
|
||||
Resolver: resolver,
|
||||
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTCPServer() error = %v", err)
|
||||
}
|
||||
|
||||
server.handleFrame(parent, nil, []byte{0x01}, "127.0.0.1:808", &connectionState{})
|
||||
|
||||
if resolver.ctxErr != nil {
|
||||
t.Fatalf("resolver saw cancelled context: %v", resolver.ctxErr)
|
||||
}
|
||||
if sink.rawCtxErr != nil {
|
||||
t.Fatalf("raw publish saw cancelled context: %v", sink.rawCtxErr)
|
||||
}
|
||||
if sink.unifiedCtxErr != nil {
|
||||
t.Fatalf("unified publish saw cancelled context: %v", sink.unifiedCtxErr)
|
||||
}
|
||||
if sink.rawCount != 1 || sink.unifiedCount != 0 {
|
||||
t.Fatalf("raw=%d unified=%d", sink.rawCount, sink.unifiedCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTCPServerPublishesUnifiedWhenExplicitlyEnabled(t *testing.T) {
|
||||
sink := &recordingSink{}
|
||||
server := newTestServer(t, TCPProtocol{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
Addr: ":0",
|
||||
Extract: jt808.ExtractFrames,
|
||||
Parse: func(_ []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) {
|
||||
return envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
MessageID: "0x0200",
|
||||
Phone: "13307795425",
|
||||
SourceEndpoint: sourceEndpoint,
|
||||
ReceivedAtMS: receivedAtMS,
|
||||
EventTimeMS: receivedAtMS,
|
||||
ParseStatus: envelope.ParseOK,
|
||||
}, nil
|
||||
},
|
||||
}, sink)
|
||||
server.publishUnified = true
|
||||
|
||||
server.handleFrame(context.Background(), nil, []byte{0x01}, "127.0.0.1:808", &connectionState{})
|
||||
|
||||
if len(sink.raw) != 1 || len(sink.unified) != 1 {
|
||||
t.Fatalf("raw=%d unified=%d", len(sink.raw), len(sink.unified))
|
||||
}
|
||||
}
|
||||
|
||||
type contextCheckingResolver struct {
|
||||
ctxErr error
|
||||
}
|
||||
|
||||
func (r *contextCheckingResolver) Resolve(ctx context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
|
||||
r.ctxErr = ctx.Err()
|
||||
return env, r.ctxErr
|
||||
}
|
||||
|
||||
type contextCheckingSink struct {
|
||||
rawCtxErr error
|
||||
unifiedCtxErr error
|
||||
fieldsCtxErr error
|
||||
rawCount int
|
||||
unifiedCount int
|
||||
fieldsCount int
|
||||
}
|
||||
|
||||
func (s *contextCheckingSink) PublishRaw(ctx context.Context, _ envelope.FrameEnvelope) error {
|
||||
s.rawCtxErr = ctx.Err()
|
||||
s.rawCount++
|
||||
return s.rawCtxErr
|
||||
}
|
||||
|
||||
func (s *contextCheckingSink) PublishUnified(ctx context.Context, _ envelope.FrameEnvelope) error {
|
||||
s.unifiedCtxErr = ctx.Err()
|
||||
s.unifiedCount++
|
||||
return s.unifiedCtxErr
|
||||
}
|
||||
|
||||
func (s *contextCheckingSink) PublishFields(ctx context.Context, _ envelope.FrameEnvelope) error {
|
||||
s.fieldsCtxErr = ctx.Err()
|
||||
s.fieldsCount++
|
||||
return s.fieldsCtxErr
|
||||
}
|
||||
|
||||
func (s *contextCheckingSink) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, protocol TCPProtocol, sink *recordingSink) *TCPServer {
|
||||
t.Helper()
|
||||
server, err := NewTCPServer(TCPServerConfig{
|
||||
@@ -135,6 +429,7 @@ func runPipe(t *testing.T, server *TCPServer) (net.Conn, <-chan struct{}) {
|
||||
type recordingSink struct {
|
||||
raw []envelope.FrameEnvelope
|
||||
unified []envelope.FrameEnvelope
|
||||
fields []envelope.FrameEnvelope
|
||||
}
|
||||
|
||||
func (s *recordingSink) PublishRaw(_ context.Context, env envelope.FrameEnvelope) error {
|
||||
@@ -147,6 +442,11 @@ func (s *recordingSink) PublishUnified(_ context.Context, env envelope.FrameEnve
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingSink) PublishFields(_ context.Context, env envelope.FrameEnvelope) error {
|
||||
s.fields = append(s.fields, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingSink) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
137
go/vehicle-gateway/internal/health/health.go
Normal file
137
go/vehicle-gateway/internal/health/health.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
|
||||
)
|
||||
|
||||
type Check struct {
|
||||
Name string
|
||||
Check func(context.Context) error
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
service string
|
||||
checks []Check
|
||||
}
|
||||
|
||||
type Logger interface {
|
||||
Info(string, ...any)
|
||||
Error(string, ...any)
|
||||
}
|
||||
|
||||
func NewHandler(service string, checks []Check) *Handler {
|
||||
service = strings.TrimSpace(service)
|
||||
if service == "" {
|
||||
service = "vehicle-service"
|
||||
}
|
||||
return &Handler{service: service, checks: checks}
|
||||
}
|
||||
|
||||
func NewMux(service string, checks []Check, registry *metrics.Registry) *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
handler := NewHandler(service, checks)
|
||||
mux.Handle("/healthz", handler)
|
||||
mux.Handle("/readyz", handler)
|
||||
if registry != nil {
|
||||
mux.Handle("/metrics", metrics.NewHandler(registry))
|
||||
}
|
||||
return mux
|
||||
}
|
||||
|
||||
func NewServer(addr string, service string, checks []Check, registry *metrics.Registry) *http.Server {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
return nil
|
||||
}
|
||||
return &http.Server{
|
||||
Addr: addr,
|
||||
Handler: NewMux(service, checks, registry),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func Start(ctx context.Context, logger Logger, server *http.Server) {
|
||||
if server == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
go func() {
|
||||
if logger != nil {
|
||||
logger.Info("health server started", "addr", server.Addr)
|
||||
}
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) && logger != nil {
|
||||
logger.Error("health server failed", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeHealth(w, http.StatusMethodNotAllowed, map[string]any{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
path := strings.Trim(r.URL.Path, "/")
|
||||
if path != "healthz" && path != "readyz" {
|
||||
writeHealth(w, http.StatusNotFound, map[string]any{"error": "route not found"})
|
||||
return
|
||||
}
|
||||
if path == "healthz" {
|
||||
writeHealth(w, http.StatusOK, map[string]any{
|
||||
"service": h.service,
|
||||
"status": "ok",
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
h.writeReadiness(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) writeReadiness(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
status := "ok"
|
||||
code := http.StatusOK
|
||||
checks := make(map[string]any, len(h.checks))
|
||||
for _, check := range h.checks {
|
||||
name := strings.TrimSpace(check.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if check.Check == nil {
|
||||
checks[name] = map[string]any{"status": "ok"}
|
||||
continue
|
||||
}
|
||||
if err := check.Check(ctx); err != nil {
|
||||
status = "degraded"
|
||||
code = http.StatusServiceUnavailable
|
||||
checks[name] = map[string]any{"status": "error", "error": err.Error()}
|
||||
continue
|
||||
}
|
||||
checks[name] = map[string]any{"status": "ok"}
|
||||
}
|
||||
writeHealth(w, code, map[string]any{
|
||||
"service": h.service,
|
||||
"status": status,
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
"checks": checks,
|
||||
})
|
||||
}
|
||||
|
||||
func writeHealth(w http.ResponseWriter, status int, payload map[string]any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user