mirror of
https://github.com/BenCodez/VotingPlugin.git
synced 2026-11-04 09:02:00 -05:00
Compare commits
822
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46a6e47a80 | ||
|
|
36bf96559e | ||
|
|
63c3c9aecf | ||
|
|
8acf825eaa | ||
|
|
e40bf57093 | ||
|
|
0c512eb8c1 | ||
|
|
de9852a974 | ||
|
|
ad0c58d945 | ||
|
|
dea8c3a8ba | ||
|
|
f79e4c5d36 | ||
|
|
ca591ed39c | ||
|
|
ccfe5f1cf1 | ||
|
|
2b59b83123 | ||
|
|
1fbc1a1939 | ||
|
|
c6a099fdb1 | ||
|
|
03bbdb4c93 | ||
|
|
8b1a03afa7 | ||
|
|
8048aa3995 | ||
|
|
74e5a490d8 | ||
|
|
4a2dae0e47 | ||
|
|
1ce14cc0ec | ||
|
|
e2f326de2a | ||
|
|
4cf01f556e | ||
|
|
11fc55cc24 | ||
|
|
4724d57e5d | ||
|
|
a9e9c282f5 | ||
|
|
9a91be8565 | ||
|
|
2dc99a0b97 | ||
|
|
c7f5b786bb | ||
|
|
306854486b | ||
|
|
07b96c4885 | ||
|
|
05b17aad11 | ||
|
|
2236dea0ff | ||
|
|
a2d88cb2cf | ||
|
|
0518038d91 | ||
|
|
5fc7d5a67b | ||
|
|
b02228e743 | ||
|
|
1240be93e4 | ||
|
|
63179e56ff | ||
|
|
ed0b7584e4 | ||
|
|
6bae1599ff | ||
|
|
9ef8566677 | ||
|
|
aa9d49cc72 | ||
|
|
e34232319c | ||
|
|
dadbc5423f | ||
|
|
bdd8320c8d | ||
|
|
0c26a216dc | ||
|
|
f5b8c669a3 | ||
|
|
22ba18f481 | ||
|
|
65f1ee3fb4 | ||
|
|
ca43cd2d33 | ||
|
|
67194213f9 | ||
|
|
612f53f48d | ||
|
|
ee0110d665 | ||
|
|
6aa5acb99d | ||
|
|
1cda20842d | ||
|
|
c3d7d11310 | ||
|
|
3b1994c899 | ||
|
|
ce7d2f747c | ||
|
|
fe9ab829ef | ||
|
|
1a19feebbb | ||
|
|
3b69f39f90 | ||
|
|
a39d2cf77f | ||
|
|
21204495bd | ||
|
|
b510322a09 | ||
|
|
7821ccaaff | ||
|
|
229c096086 | ||
|
|
b5c759570a | ||
|
|
e323bc1722 | ||
|
|
d8891d5adc | ||
|
|
2c9e29ba88 | ||
|
|
54c346cdf8 | ||
|
|
6838a081ad | ||
|
|
0ae23e748c | ||
|
|
3e03f3e236 | ||
|
|
c05a02d129 | ||
|
|
aaa3cc720c | ||
|
|
b7fb1d80d5 | ||
|
|
dda9388e29 | ||
|
|
be66e9ea33 | ||
|
|
e06211a6ec | ||
|
|
5d4cb06cce | ||
|
|
371f109a99 | ||
|
|
734f4b5952 | ||
|
|
a1f780f9eb | ||
|
|
103445a4dc | ||
|
|
e8af1e2287 | ||
|
|
7321ea5da4 | ||
|
|
3cd46e6d92 | ||
|
|
ebd15756d5 | ||
|
|
e6d0c30e93 | ||
|
|
ad652ebdb3 | ||
|
|
cb34f055ed | ||
|
|
832772f382 | ||
|
|
fda4b09909 | ||
|
|
6cbc180d7b | ||
|
|
51e2396f0d | ||
|
|
6a5c124ec3 | ||
|
|
54cffb5f95 | ||
|
|
a1905322ae | ||
|
|
fa39ec9b7c | ||
|
|
0be5deedfe | ||
|
|
9865b50938 | ||
|
|
65498873f7 | ||
|
|
4615be0eaa | ||
|
|
c2446c5151 | ||
|
|
e0c48ef8f7 | ||
|
|
84658b2651 | ||
|
|
5f64dafab0 | ||
|
|
2889d07f0c | ||
|
|
65f3e1de46 | ||
|
|
736bb10687 | ||
|
|
22c630b10c | ||
|
|
ff4f5196d0 | ||
|
|
a927215431 | ||
|
|
8e27570c7a | ||
|
|
efd1c93433 | ||
|
|
834bcb84a5 | ||
|
|
4348ab8536 | ||
|
|
7e766d4d15 | ||
|
|
ed9970bcfa | ||
|
|
4b5b32661e | ||
|
|
32362acfab | ||
|
|
7ca6de5659 | ||
|
|
8b2db047b6 | ||
|
|
3f920f7e7d | ||
|
|
35d0d5694d | ||
|
|
07ee9367d8 | ||
|
|
63626db18a | ||
|
|
f1d7b0012f | ||
|
|
0406ff7b10 | ||
|
|
93df54109d | ||
|
|
ff152f8ee8 | ||
|
|
376c2a9c3c | ||
|
|
1cf5a6de4d | ||
|
|
2f9746b035 | ||
|
|
164138e8d5 | ||
|
|
e8afe00d74 | ||
|
|
5271f58ede | ||
|
|
6ca699758f | ||
|
|
fff806d031 | ||
|
|
8ce33156d8 | ||
|
|
860f530ede | ||
|
|
c47522cee6 | ||
|
|
792836c3be | ||
|
|
a4f46212c4 | ||
|
|
4c66833bdb | ||
|
|
e8092c0334 | ||
|
|
bae0462fe5 | ||
|
|
d87d6a2654 | ||
|
|
752ef3e4d5 | ||
|
|
89e60da857 | ||
|
|
e67bfd908a | ||
|
|
5e86b79db4 | ||
|
|
1c01b14ad6 | ||
|
|
d3c69313a3 | ||
|
|
7794e5c701 | ||
|
|
00d499d51d | ||
|
|
692ea55cfa | ||
|
|
5bfc4a8e47 | ||
|
|
051c906a69 | ||
|
|
d7126fd5f3 | ||
|
|
679ecd6b72 | ||
|
|
426197f0c7 | ||
|
|
81ae47f619 | ||
|
|
33109b1998 | ||
|
|
aace407031 | ||
|
|
9c8a939786 | ||
|
|
7985674249 | ||
|
|
dd5d22d520 | ||
|
|
0a4b786a4f | ||
|
|
ae2c2af7be | ||
|
|
3806fcd996 | ||
|
|
97850e30cf | ||
|
|
45e3e75352 | ||
|
|
d6005b61fc | ||
|
|
3bea8cabea | ||
|
|
3cdf42d9ea | ||
|
|
e2c2a430aa | ||
|
|
bfc75f6a7d | ||
|
|
864d2c708d | ||
|
|
75ad5f02a8 | ||
|
|
00c90c4cd9 | ||
|
|
b5518dbbf3 | ||
|
|
5fcd8fa8e7 | ||
|
|
d14925bf36 | ||
|
|
7335f88f86 | ||
|
|
8b65aa8803 | ||
|
|
d37dd98842 | ||
|
|
d22a611202 | ||
|
|
4dc1be1ac8 | ||
|
|
a38ed709e5 | ||
|
|
52e13e58ab | ||
|
|
d08e630964 | ||
|
|
cbe7172a7c | ||
|
|
4cc6e734b0 | ||
|
|
e24321db85 | ||
|
|
43009ca556 | ||
|
|
1e90a9f035 | ||
|
|
ab0bbe9108 | ||
|
|
8c232ebc3a | ||
|
|
f37e93f0d3 | ||
|
|
07454ac591 | ||
|
|
76c22fece3 | ||
|
|
c313053b3f | ||
|
|
7a183ccc44 | ||
|
|
d4144e04a3 | ||
|
|
bcc44aad91 | ||
|
|
be9466acf4 | ||
|
|
a4cc0e7947 | ||
|
|
0ef28454bd | ||
|
|
37ec4014ed | ||
|
|
51e40354c0 | ||
|
|
6f23c992fa | ||
|
|
0d8e4bb576 | ||
|
|
16f5b7e5fb | ||
|
|
749bd12405 | ||
|
|
9c69bbff7b | ||
|
|
5e7a9455e0 | ||
|
|
ea7ee766f9 | ||
|
|
431541abb1 | ||
|
|
93b83109fe | ||
|
|
621c90f7f1 | ||
|
|
ccfa346d5d | ||
|
|
8f0ad32141 | ||
|
|
94f869a1e4 | ||
|
|
b754035366 | ||
|
|
2a5cdeb351 | ||
|
|
ea201e6503 | ||
|
|
fccba32e15 | ||
|
|
819e2a22fb | ||
|
|
296b42bd4b | ||
|
|
11e1fd27c1 | ||
|
|
7130b85247 | ||
|
|
4efd2c28d0 | ||
|
|
6963ab9d84 | ||
|
|
e41fee0415 | ||
|
|
a815aeb4ff | ||
|
|
f8420a35c0 | ||
|
|
d153181106 | ||
|
|
0620eabe9a | ||
|
|
294c698fec | ||
|
|
582436d470 | ||
|
|
90f44b30b1 | ||
|
|
6449df35ed | ||
|
|
d39aa973cd | ||
|
|
1028d54841 | ||
|
|
f8ca055a68 | ||
|
|
b39de254cf | ||
|
|
8610ecca51 | ||
|
|
b6d2aeaa50 | ||
|
|
9cb0ead53a | ||
|
|
5c9b877595 | ||
|
|
c59d088f41 | ||
|
|
612290eb92 | ||
|
|
e8d9d10368 | ||
|
|
be3bbffcd0 | ||
|
|
7daa22ecb4 | ||
|
|
307c41f08a | ||
|
|
51d3bae929 | ||
|
|
b70709b9dd | ||
|
|
1e428d5d51 | ||
|
|
b6c11e2d04 | ||
|
|
b224ad08c0 | ||
|
|
770eec4a33 | ||
|
|
f504c7a279 | ||
|
|
a6a265a5e3 | ||
|
|
8099d16f0e | ||
|
|
0e09397a64 | ||
|
|
2384b40ffd | ||
|
|
bd31d90b02 | ||
|
|
8fbe8fd988 | ||
|
|
ec06b30a84 | ||
|
|
181cb3f30f | ||
|
|
921dd56e01 | ||
|
|
f5068bda9b | ||
|
|
41609444fe | ||
|
|
cc862f74c5 | ||
|
|
8e5abea3b3 | ||
|
|
92206476b9 | ||
|
|
5cdc4fae2f | ||
|
|
74b4841cd9 | ||
|
|
f6b76f8d92 | ||
|
|
325675ad19 | ||
|
|
f26e8e8ce1 | ||
|
|
a7dcec11d3 | ||
|
|
227e19884b | ||
|
|
1afd9b9b3d | ||
|
|
d95bb733cc | ||
|
|
da0c740091 | ||
|
|
7be4c1c630 | ||
|
|
d68cf6b42e | ||
|
|
7f1ed88574 | ||
|
|
3657787c76 | ||
|
|
ff2204b565 | ||
|
|
ec819df74f | ||
|
|
27dc7be78a | ||
|
|
b737cb03fe | ||
|
|
e333bd4d5c | ||
|
|
4683b3c951 | ||
|
|
f76f9b8a5a | ||
|
|
296eb3e154 | ||
|
|
f1177e4b14 | ||
|
|
b4ac57a828 | ||
|
|
222ab749a3 | ||
|
|
1cf9b7cba3 | ||
|
|
8fa44a5211 | ||
|
|
cb4fc50662 | ||
|
|
fb5d8a0fcd | ||
|
|
39f2af47e1 | ||
|
|
c86d803057 | ||
|
|
d010719b0f | ||
|
|
60aa4d53ab | ||
|
|
f2d8860b97 | ||
|
|
ffb2773766 | ||
|
|
f79e392001 | ||
|
|
0000118d6c | ||
|
|
bbe12cc4ff | ||
|
|
deb6433113 | ||
|
|
6566281c15 | ||
|
|
a3298b6029 | ||
|
|
1f988459cb | ||
|
|
27d41a0984 | ||
|
|
893fda2a9f | ||
|
|
6d55b77e41 | ||
|
|
208609515c | ||
|
|
24ecff6a52 | ||
|
|
3942f04c5d | ||
|
|
f9a6aab5e8 | ||
|
|
7cff82ffc2 | ||
|
|
574fabbb4f | ||
|
|
280d8186b4 | ||
|
|
f76884bc80 | ||
|
|
723ab02ce0 | ||
|
|
39e5a641a4 | ||
|
|
3b078dab3e | ||
|
|
8b3a5adbb4 | ||
|
|
25bb1b4256 | ||
|
|
4be8c974c4 | ||
|
|
e6091c4be5 | ||
|
|
a6e4a08122 | ||
|
|
7a08f9b1ee | ||
|
|
f6c275a264 | ||
|
|
7ff0b0c2bc | ||
|
|
7fbf3360a9 | ||
|
|
a15b2bce1b | ||
|
|
94c08cce32 | ||
|
|
18db8cfcac | ||
|
|
74fc0006fb | ||
|
|
ffcbafdc03 | ||
|
|
6d7eed5cce | ||
|
|
b56ad17144 | ||
|
|
96f6c64112 | ||
|
|
f3aa70c9ab | ||
|
|
f036fca34e | ||
|
|
63a4196ee8 | ||
|
|
817d9fee66 | ||
|
|
fc123b7bcf | ||
|
|
81a12f5cdd | ||
|
|
3f7427ad35 | ||
|
|
fe812a8044 | ||
|
|
ccc1052272 | ||
|
|
fed067f200 | ||
|
|
6a0195cd49 | ||
|
|
6ebbdfb3ee | ||
|
|
41da204190 | ||
|
|
d12d0f3584 | ||
|
|
839f63b0b4 | ||
|
|
e86a88279c | ||
|
|
c760b8b6fa | ||
|
|
388099c67d | ||
|
|
cdc99d8cea | ||
|
|
77017a931b | ||
|
|
07f8038543 | ||
|
|
687e141cbf | ||
|
|
6f69d71f0d | ||
|
|
ffbad36675 | ||
|
|
850e1978d3 | ||
|
|
2b2cd46c08 | ||
|
|
6c707902aa | ||
|
|
ce6c8512b9 | ||
|
|
e8560ca6ee | ||
|
|
c3fd10825f | ||
|
|
9f7840fe11 | ||
|
|
b8981c964d | ||
|
|
942abae8d3 | ||
|
|
e8f3ae0356 | ||
|
|
693d78ecdb | ||
|
|
3cdaf10f6d | ||
|
|
d585a950ea | ||
|
|
fced137fcd | ||
|
|
97861b2b63 | ||
|
|
7bf6a50fb2 | ||
|
|
8890e60d2e | ||
|
|
a72a09387b | ||
|
|
778c6155fa | ||
|
|
cbd364be46 | ||
|
|
2297f756ca | ||
|
|
63f4ba586f | ||
|
|
be3953356f | ||
|
|
fe0c5c7d65 | ||
|
|
b015ae58a7 | ||
|
|
8250222763 | ||
|
|
bb10a56cac | ||
|
|
816b72d435 | ||
|
|
4a589b31d8 | ||
|
|
b45e19a387 | ||
|
|
7211babd0b | ||
|
|
ac94134549 | ||
|
|
b7408b9564 | ||
|
|
86a3563ac0 | ||
|
|
ba7255bf8a | ||
|
|
38adff36b6 | ||
|
|
e5d4e10101 | ||
|
|
50d991fbf6 | ||
|
|
1830fc83cf | ||
|
|
33b8a1b67a | ||
|
|
a5f7bbaa72 | ||
|
|
a86278a662 | ||
|
|
0c00711670 | ||
|
|
485cdeaa53 | ||
|
|
69679b459d | ||
|
|
64edda43da | ||
|
|
efbc9b82f2 | ||
|
|
dbf4a8d8a5 | ||
|
|
4a137fd40f | ||
|
|
69064fedd3 | ||
|
|
89b3d1d2a2 | ||
|
|
f875970876 | ||
|
|
672b979de2 | ||
|
|
43474e669b | ||
|
|
0344105adb | ||
|
|
810e7b0b51 | ||
|
|
0474c8b7ef | ||
|
|
84cb2c3e47 | ||
|
|
4c1821f935 | ||
|
|
98637a2926 | ||
|
|
5626aa7a55 | ||
|
|
1a31b88e01 | ||
|
|
6e4bf529d6 | ||
|
|
0f5aff7568 | ||
|
|
a03ee6643a | ||
|
|
2d047c76a6 | ||
|
|
0a509b5fb8 | ||
|
|
25586baad9 | ||
|
|
5ee108f752 | ||
|
|
dcd9a8ecda | ||
|
|
f4342bf1bd | ||
|
|
8c8d118095 | ||
|
|
535eff5b88 | ||
|
|
9eb69ff601 | ||
|
|
1d778fa897 | ||
|
|
0b90ca3e9b | ||
|
|
699817daee | ||
|
|
ab0c755adc | ||
|
|
6e459bbdad | ||
|
|
d44c17dcda | ||
|
|
37fcf39908 | ||
|
|
ad98b727bb | ||
|
|
b524ba2385 | ||
|
|
d7149b2af7 | ||
|
|
053c5d4c93 | ||
|
|
5ed37bd159 | ||
|
|
31bb5efafd | ||
|
|
290f058a63 | ||
|
|
62c12392af | ||
|
|
49cbb6b0c1 | ||
|
|
2e79d81e0f | ||
|
|
986a3c13c3 | ||
|
|
ef0aba21ba | ||
|
|
5e3c303c0a | ||
|
|
5040bc6571 | ||
|
|
35886d94bc | ||
|
|
8b83238f58 | ||
|
|
bcf40b0655 | ||
|
|
1980befd80 | ||
|
|
564312902d | ||
|
|
6402e23786 | ||
|
|
438490adfd | ||
|
|
afaf703288 | ||
|
|
298754ce56 | ||
|
|
f3cdb3f956 | ||
|
|
38600063a7 | ||
|
|
4c4f20c58c | ||
|
|
9bd6da2178 | ||
|
|
71fa9a2aad | ||
|
|
c18b8c3dee | ||
|
|
f1303280e1 | ||
|
|
ede8b7386f | ||
|
|
bfe27a4ce2 | ||
|
|
3c08f9f928 | ||
|
|
37b610aad3 | ||
|
|
a16c3402a2 | ||
|
|
57d7f10160 | ||
|
|
53612df2bd | ||
|
|
8e6e84d5a9 | ||
|
|
55b5466b77 | ||
|
|
558763ac0a | ||
|
|
ad8b80b7e4 | ||
|
|
edea5960c0 | ||
|
|
4402cf91ff | ||
|
|
f2091768a2 | ||
|
|
5ced4bd758 | ||
|
|
7e645c6888 | ||
|
|
0930a85f5a | ||
|
|
265a1d709b | ||
|
|
28e45e91da | ||
|
|
f7b4ab6422 | ||
|
|
2e935855c1 | ||
|
|
5f629660f4 | ||
|
|
460887368d | ||
|
|
f95f2a34de | ||
|
|
88e785870f | ||
|
|
4c99045949 | ||
|
|
fda281652d | ||
|
|
03251519a4 | ||
|
|
e89e60bd06 | ||
|
|
bc9dac6303 | ||
|
|
43fbf0b8a0 | ||
|
|
d3d46fd932 | ||
|
|
1c592fcf38 | ||
|
|
0c5b725b1a | ||
|
|
9bd840f8bc | ||
|
|
dd119ed7cf | ||
|
|
ef2be5253c | ||
|
|
5766dbf83c | ||
|
|
fa14482c86 | ||
|
|
6e8cab0ee5 | ||
|
|
d5811f2ab3 | ||
|
|
8498622ffc | ||
|
|
765f2e7e0e | ||
|
|
c650501f04 | ||
|
|
01adaf394e | ||
|
|
2376b64d7a | ||
|
|
0998dac18a | ||
|
|
dc3875f509 | ||
|
|
f3cd9eaaeb | ||
|
|
b12bf791b1 | ||
|
|
5e4a20e144 | ||
|
|
4fd3b4396c | ||
|
|
0ce98c9a09 | ||
|
|
2856e343f3 | ||
|
|
22ff678d5e | ||
|
|
607e3a1ab4 | ||
|
|
9c3a8deee1 | ||
|
|
973837bda9 | ||
|
|
292a9b82a6 | ||
|
|
18699caa5c | ||
|
|
99deff1fbc | ||
|
|
11f20c39f5 | ||
|
|
4d1b7e226a | ||
|
|
70641af27a | ||
|
|
a2b1381240 | ||
|
|
632058fe3a | ||
|
|
f938c9f888 | ||
|
|
ad2c992279 | ||
|
|
bca3ec4d4c | ||
|
|
187db97bf3 | ||
|
|
3369cb847f | ||
|
|
25b9b870c0 | ||
|
|
5f7d9c721c | ||
|
|
607129f31a | ||
|
|
f623288b5b | ||
|
|
d89bdf5183 | ||
|
|
c3be440769 | ||
|
|
157ddde85c | ||
|
|
dec7cd1604 | ||
|
|
b3cac6f857 | ||
|
|
288dff89a7 | ||
|
|
6e4bebf81a | ||
|
|
83470a225a | ||
|
|
ec0574ea73 | ||
|
|
2e5e7d4a36 | ||
|
|
5bae2bd656 | ||
|
|
265d211b35 | ||
|
|
72d5183de1 | ||
|
|
8818f29998 | ||
|
|
15eea849c3 | ||
|
|
85e8cdf86e | ||
|
|
713e07c731 | ||
|
|
f192886726 | ||
|
|
0f0ed27118 | ||
|
|
2e49dd6669 | ||
|
|
3ac1341b0c | ||
|
|
3304911e24 | ||
|
|
a1fafd47a5 | ||
|
|
9d7a6c5123 | ||
|
|
3adf47c4db | ||
|
|
17080c041b | ||
|
|
b1664b2d57 | ||
|
|
60fb262112 | ||
|
|
4949ac9620 | ||
|
|
044bef3429 | ||
|
|
d29b10784f | ||
|
|
03dd3fbdb8 | ||
|
|
de49ab1c13 | ||
|
|
24a8dca8aa | ||
|
|
b38e5105b5 | ||
|
|
5f25641f2e | ||
|
|
3acae78914 | ||
|
|
0ee7950c98 | ||
|
|
64697cb4e5 | ||
|
|
0a0a7eb1e7 | ||
|
|
283c2b2c4f | ||
|
|
a2ad2e4518 | ||
|
|
e231f62ee3 | ||
|
|
32f7c1b766 | ||
|
|
74ead43129 | ||
|
|
20081a6d29 | ||
|
|
76a65de3de | ||
|
|
91108dda95 | ||
|
|
64a983b34a | ||
|
|
a4f44eaf82 | ||
|
|
36a5979c9e | ||
|
|
12d328c9fc | ||
|
|
8bc2e1c960 | ||
|
|
a29828bedc | ||
|
|
0647d07b2e | ||
|
|
17e16d01ba | ||
|
|
b5d049041d | ||
|
|
de0d3f742e | ||
|
|
48185a74a6 | ||
|
|
19ed58be4e | ||
|
|
f4d39a76b1 | ||
|
|
429e624d94 | ||
|
|
3047c9faa2 | ||
|
|
e57adc8b10 | ||
|
|
6bed0f0857 | ||
|
|
d0c185c476 | ||
|
|
ea53c5c42e | ||
|
|
83305bfd87 | ||
|
|
7c745354f3 | ||
|
|
2396efc0ce | ||
|
|
555cabe391 | ||
|
|
8086691cb6 | ||
|
|
aad0d0bb54 | ||
|
|
e2213e20f6 | ||
|
|
3c6ae5adde | ||
|
|
fa9f1a582f | ||
|
|
98e8c797f9 | ||
|
|
c3924c5590 | ||
|
|
ff3665842e | ||
|
|
b7ab91fc8b | ||
|
|
dc0a0ccdc4 | ||
|
|
325eb3bf6b | ||
|
|
58df2c8738 | ||
|
|
dbcdad9d1d | ||
|
|
4122d62b16 | ||
|
|
f5e6b06f77 | ||
|
|
ecd70f386d | ||
|
|
ae66bc7957 | ||
|
|
8f75535b4d | ||
|
|
68976d5f25 | ||
|
|
0da1c46084 | ||
|
|
a404248d82 | ||
|
|
d8fbbed94e | ||
|
|
cbe604683d | ||
|
|
adcfa279d8 | ||
|
|
3e5c168005 | ||
|
|
7ed6948462 | ||
|
|
3dec4333b5 | ||
|
|
222b38a97c | ||
|
|
0da81abc83 | ||
|
|
1ff9a31c64 | ||
|
|
a6894fa28a | ||
|
|
b117c36b08 | ||
|
|
18ff986162 | ||
|
|
bd16c5e073 | ||
|
|
96c850408b | ||
|
|
59b5e75c9d | ||
|
|
058e8e7aa9 | ||
|
|
bf8de86a77 | ||
|
|
367f941b29 | ||
|
|
38ae6f676c | ||
|
|
3c049f4a51 | ||
|
|
f74a228a2e | ||
|
|
e8e931632b | ||
|
|
5d29a7cee2 | ||
|
|
c94842ee72 | ||
|
|
ba2c091f69 | ||
|
|
6472e664dd | ||
|
|
13a4ec6b9c | ||
|
|
66b8a2be09 | ||
|
|
f1337fa6d4 | ||
|
|
c45f3ea0b2 | ||
|
|
21ac3f9352 | ||
|
|
3a3b0f6256 | ||
|
|
874d62fa0a | ||
|
|
c687cbfe27 | ||
|
|
87de933fab | ||
|
|
6b87b9fe02 | ||
|
|
b35abf4004 | ||
|
|
e7fb2416e2 | ||
|
|
35f3ac2a35 | ||
|
|
f3141f2d4f | ||
|
|
cb733347a2 | ||
|
|
a58168c848 | ||
|
|
1bfde2417d | ||
|
|
a2e011278e | ||
|
|
6fe7db7908 | ||
|
|
ce7443955b | ||
|
|
158d4030fb | ||
|
|
a52a1ec771 | ||
|
|
e1aa435a3f | ||
|
|
bdd471bd79 | ||
|
|
f77273094e | ||
|
|
a0ee827e63 | ||
|
|
c3ff999112 | ||
|
|
034e39aae5 | ||
|
|
4928b10f0f | ||
|
|
17a5bd581f | ||
|
|
e3e577402b | ||
|
|
58c5d0d02f | ||
|
|
0a4913e207 | ||
|
|
59e05f19e4 | ||
|
|
8d07e5a8d6 | ||
|
|
d3fc00ea1e | ||
|
|
1d091e847d | ||
|
|
4249d8cb6a | ||
|
|
1310ed877d | ||
|
|
bc36371e25 | ||
|
|
2c2ee8476d | ||
|
|
4d3f404857 | ||
|
|
a013543696 | ||
|
|
520aa142e7 | ||
|
|
9e3432fda1 | ||
|
|
95fe0931f9 | ||
|
|
dc25f953f7 | ||
|
|
a25f259424 | ||
|
|
ca387d7267 | ||
|
|
e70425ba0a | ||
|
|
307a9fe626 | ||
|
|
95471fc998 | ||
|
|
05315db092 | ||
|
|
3892634650 | ||
|
|
c5ddb3c541 | ||
|
|
c71599cb40 | ||
|
|
7c715cf95c | ||
|
|
e07be21403 | ||
|
|
f519c3bd29 | ||
|
|
38ae0d6fd4 | ||
|
|
f08dd1c3a3 | ||
|
|
37543d22af | ||
|
|
219fb35774 | ||
|
|
ca4471d917 | ||
|
|
cc1db03ea3 | ||
|
|
804271e24a | ||
|
|
c984404e68 | ||
|
|
866757e65e | ||
|
|
1f206eab62 | ||
|
|
b8b695eaf9 | ||
|
|
ebfbc55036 | ||
|
|
3e17e38919 | ||
|
|
2d43f6f0be | ||
|
|
b088eaf0ba | ||
|
|
6a5cd23d90 | ||
|
|
e6ba72cf59 | ||
|
|
66d75189d4 | ||
|
|
b14d024476 | ||
|
|
69a0b99f0c | ||
|
|
63277bcd88 | ||
|
|
dd3a7701e9 | ||
|
|
9c176fdb2c | ||
|
|
a88e705926 | ||
|
|
c219d805ba | ||
|
|
5024abe440 | ||
|
|
e335aec9ad | ||
|
|
a199326714 | ||
|
|
b0ec02325b | ||
|
|
fccee28efe | ||
|
|
f394d1b2fa | ||
|
|
7bfe60d4bc | ||
|
|
70fe42f378 | ||
|
|
1d44c4b73f | ||
|
|
f86e298da3 | ||
|
|
625eaf6b21 | ||
|
|
fe689ca81c | ||
|
|
c817fec06d | ||
|
|
fd1b2b125f | ||
|
|
4b12ebf96b | ||
|
|
6b69462cc0 | ||
|
|
48841b80d8 | ||
|
|
b6d0f774bd | ||
|
|
bfea6f1b63 | ||
|
|
fa1296ea07 | ||
|
|
e23a9691d0 | ||
|
|
7495519d61 | ||
|
|
f8113897b2 | ||
|
|
01dea25564 | ||
|
|
7fec7976a4 | ||
|
|
c10236a620 | ||
|
|
59b08052fe | ||
|
|
9b9232b110 | ||
|
|
57517d7593 | ||
|
|
c8b0116381 | ||
|
|
425664be7c | ||
|
|
67e9d1262a | ||
|
|
b7503b9eb8 | ||
|
|
f81aa90ba6 | ||
|
|
0e9cf5ac05 | ||
|
|
dd1ef83f2c | ||
|
|
5949e113bc | ||
|
|
0a938cf3ad | ||
|
|
b13ce28b45 | ||
|
|
9eae8b5a35 | ||
|
|
2a9fcc6d84 | ||
|
|
6216a04cc3 | ||
|
|
71f5ca4d6d | ||
|
|
13fd2b7b09 | ||
|
|
f104e61d83 | ||
|
|
ac6a947ae5 | ||
|
|
dbf416f19b | ||
|
|
5b666dc501 | ||
|
|
63e8f9ddd9 | ||
|
|
aa0f8d8ba5 | ||
|
|
57226856ab | ||
|
|
38a37ddb1b | ||
|
|
e9bbe03612 | ||
|
|
1d30c94201 | ||
|
|
d27636bdcf | ||
|
|
1fd3be90b5 | ||
|
|
567531eeef |
No files matched your search
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"owner": "mex-agent",
|
||||
"skill": "mex-inbox",
|
||||
"packageVersion": "0.8.2",
|
||||
"files": {
|
||||
"SKILL.md": "39cf595ac48203bf49ea2e273ea1c1c25e7ebbf4616c68596cd8e1c08eaf2738",
|
||||
"agents/openai.yaml": "12a2827fe257c303d926122e4bb590910abd660e6451550a1db68a48037b4402",
|
||||
"references/cli-workflows.md": "6c91905cc69d8ea47080630ee1551bf1a61ec33605fff01463d4a2ad71f497c6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: mex-inbox
|
||||
description: Draft and review contributions to existing MEX project knowledge. Use when the user asks to capture a discussion or decision in project knowledge, propose an addition or correction for team review, or explicitly invokes /mex-inbox or $mex-inbox. Also supports existing Spec proposals. Do not activate for brainstorming alone, routine GROW upkeep, email inboxes, session logs, or handoffs.
|
||||
---
|
||||
|
||||
# MEX Inbox
|
||||
|
||||
Turn an explicit request to retain project knowledge into a focused contribution for review. Inbox proposals are review artifacts; accepted knowledge belongs in the existing Wiki Markdown. Ordinary GROW upkeep can continue directly.
|
||||
|
||||
## Keep the scope honest
|
||||
|
||||
- Create one `knowledge.create` or `knowledge.update` change per draft, for an existing kind: `architecture`, `component`, `convention`, `decision`, `pattern`, or `guide`.
|
||||
- Existing Spec workflows also support `spec.create` and `spec.update` for `spec`, `requirement`, `constraint`, and `acceptance_criterion`. Choose these only for actual Spec-family intent.
|
||||
- Capture durable conclusions, their rationale, and useful evidence. Do not dump the conversation or present unresolved ideas as agreed facts.
|
||||
- Do not route session logs, Relays, or routine GROW edits through Inbox merely because they contain context.
|
||||
|
||||
## Prepare a draft
|
||||
|
||||
1. Distill what future agents or teammates need to know from the user's request and discussion.
|
||||
2. Search existing knowledge first. Prefer correcting or extending the relevant record or section when it already covers the subject; create a new entry when the claim has no suitable home.
|
||||
3. Read the exact target and current revisions before drafting a correction. Preserve unrelated knowledge when replacing its body. Never guess an ID or revision.
|
||||
4. Preserve useful files, code, commits, entities, and external links as evidence only when actually available. Never invent provenance, authors, dates, events, or history.
|
||||
5. Build one request against the action-scoped runtime contract.
|
||||
6. Run the exact preview flow and summarize the human effect before technical details.
|
||||
7. When the user already asked to create, save, or draft it, apply that exact successful checkout-local draft preview without asking again.
|
||||
8. Return `/inbox?view=drafts&draft=<id>` and state that the draft is checkout-local, nothing was published or shared, and review or publication happens in Hub.
|
||||
|
||||
Read [references/cli-workflows.md](references/cli-workflows.md) before executing any Inbox mutation. Load only the operation being performed.
|
||||
|
||||
## Apply the approval policy
|
||||
|
||||
- Proceed with read-only list, show, and target resolution.
|
||||
- Treat an explicit create/save/draft request as authorization only for the local draft preview and exact apply.
|
||||
- Preview a local draft deletion, explain that the checkout-local draft will be removed, and obtain fresh confirmation before applying.
|
||||
- For publish, approve, reject, withdraw, mark stale, or repair: create a semantic preview, explain the local-versus-Git-tracked effect, and wait for fresh explicit confirmation. Apply the exact preview unchanged only after confirmation.
|
||||
- Keep Git commit, push, pull, and staging separate from every MEX authorization.
|
||||
- Never approve or publish merely because this skill activated.
|
||||
|
||||
## Report effects precisely
|
||||
|
||||
- Lead with what the proposal means to a person. Do not expose envelopes, hashes, revision machinery, or raw diffs unless diagnosis requires them.
|
||||
- After a local draft write, say exactly what changed and that it remains checkout-only.
|
||||
- After a canonical write, say which Git-tracked MEX artifacts were written to the working tree and that commit/push is still required to share them. Never claim that MEX committed or pushed.
|
||||
- When MEX context materially helps your work, mention MEX and the relevant finding naturally in your explanation. Tie the mention to what it helped you understand, decide, or verify. Avoid fixed phrases, standalone acknowledgements, repeated mentions, or narrating routine context loading. This replaces older MEX instructions requiring a fixed acknowledgement or context-loading narration.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "MEX Inbox"
|
||||
short_description: "Propose additions to project knowledge"
|
||||
default_prompt: "Use $mex-inbox to capture what we decided as a contribution to this project's knowledge."
|
||||
@@ -0,0 +1,86 @@
|
||||
# Inbox CLI workflows
|
||||
|
||||
Use JSON mode for deterministic agent work. Keep request and preview files temporary and avoid displaying them unless troubleshooting.
|
||||
|
||||
## Resolve only the needed contract
|
||||
|
||||
Run:
|
||||
|
||||
```text
|
||||
mex inbox contract --action <command-id> --json
|
||||
```
|
||||
|
||||
Use one of these command IDs:
|
||||
|
||||
- `inbox.draft.save`
|
||||
- `inbox.draft.delete`
|
||||
- `inbox.publish`
|
||||
- `inbox.proposal.approve`
|
||||
- `inbox.proposal.reject`
|
||||
- `inbox.proposal.withdraw`
|
||||
- `inbox.proposal.mark-stale`
|
||||
- `inbox.proposal.repair`
|
||||
|
||||
Treat this bounded action result as the exact runtime source for the request shape, available examples, constraints, preview command, and apply command. Do not dump `mex capabilities --json` or the full Inbox contract during ordinary execution. Write request and preview JSON only to ordinary regular files inside the checkout or an approved temporary directory; do not use symlinks.
|
||||
|
||||
## Find existing knowledge and resolve a correction
|
||||
|
||||
1. Use `mex wiki query <subject> --limit 10 --json` or `mex wiki list --type <kind> --limit 25 --json` to find related records before creating another.
|
||||
2. Read the best candidate with `mex inbox target <entity-id> --json`. This bounded read returns `target`, `version`, `sourcePath`, and the existing body from the current Wiki index, without initializing or repairing it.
|
||||
3. For `knowledge.update`, copy `target` and use `version.contentHash` as the target revision and `version.semanticRevision` as the semantic revision. Select a section entity when only that section should change. The patch body replaces the target's body; preserve its relevant existing content. A target can fit the read response but exceed the 16 KiB update-body limit; choose a smaller section or a title/summary-only patch when appropriate, never truncate existing knowledge to fit.
|
||||
4. If no suitable record exists, use `knowledge.create` with the appropriate existing kind. MEX chooses a path in `context/` or `patterns/`; do not invent an Inbox knowledge category or supply an arbitrary destination path.
|
||||
5. If several targets remain plausible after reading them, ask which one the user intends. If a read reports a stale or unavailable index, address its explicit maintenance requirement before relying on it; never fabricate revisions or silently refresh an index as part of a read.
|
||||
|
||||
For create-time topics, find them with Wiki reads, then resolve each with `mex inbox target <topic-id> --json` for current revisions. Topics can be dependencies but are not Inbox correction targets. Omit topics when none are needed; normal knowledge creates have no relation editor.
|
||||
|
||||
## Resolve a legacy Spec update
|
||||
|
||||
1. Use `mex spec list --json` to identify candidates.
|
||||
2. Use `mex spec show <entity-id> --json` for the exact candidate.
|
||||
3. Match the requested durable claim to one exact entity and kind.
|
||||
4. Copy `version.contentHash` as the entity revision and `version.semanticRevision` as the semantic revision exactly as the selected contract requires. For nested requirements, constraints, or acceptance criteria, take both from the enclosing `mex spec show` projection.
|
||||
5. Stop and ask for target clarification when multiple candidates remain plausible.
|
||||
|
||||
Do not invent target IDs, relation endpoints, topic IDs, or revisions. For a create request with relations or topics, resolve every referenced entity and its current revisions first.
|
||||
|
||||
## Save a checkout-local draft
|
||||
|
||||
1. Resolve `inbox.draft.save`.
|
||||
2. Create a unique operation ID and a request containing one `knowledge.create` or `knowledge.update` draft (or a Spec change for actual Spec-family intent).
|
||||
3. For a new draft, provide no unrelated expectations. For an existing draft update, read it with `mex inbox draft show <draft-id> --json` and use its exact current local revision.
|
||||
4. Preview with `mex inbox draft save <request-file> --json` and capture the complete successful JSON wrapper unchanged. Require `ok: true`, `mode: "preview"`, and `data.preview.valid: true`.
|
||||
5. Summarize the proposed local effect. If the user asked to create/save/draft, apply with `mex inbox draft save --apply <preview-envelope> --json` without another confirmation.
|
||||
6. Read the returned draft ID and respond with `/inbox?view=drafts&draft=<id>`.
|
||||
|
||||
The apply writes only checkout-local draft state. It does not create a canonical proposal, Activity record, commit, push, or notification. Apply before the preview expires; if anything changes or the preview becomes stale, preview again instead of reconstructing it.
|
||||
|
||||
## Delete a local draft
|
||||
|
||||
1. Read the exact draft and current local revision.
|
||||
2. Resolve and preview `inbox.draft.delete`.
|
||||
3. Explain that the checkout-local draft will be deleted and wait for fresh confirmation.
|
||||
4. Apply the captured preview unchanged with `mex inbox draft delete --apply <preview-envelope> --json`.
|
||||
|
||||
## Publish a draft
|
||||
|
||||
1. Read the exact draft and resolve `inbox.publish`.
|
||||
2. Build the request with the exact local draft revision and preview using `mex inbox publish <request-file> --json`.
|
||||
3. Explain that applying writes a canonical proposal and Activity artifact into the working tree while leaving Git commit/push separate.
|
||||
4. Wait for fresh explicit confirmation.
|
||||
5. Apply the exact preview with `mex inbox publish --apply <preview-envelope> --json`.
|
||||
6. Return `/inbox?view=review&proposal=<proposal-id>`.
|
||||
|
||||
Publishing removes the exact local draft after creating the pending proposal. It does not approve the proposal or change accepted knowledge. The proposal is Markdown in the working tree; teammates receive it through Git.
|
||||
|
||||
## Review canonical proposals
|
||||
|
||||
Use `mex inbox proposal list --json` and `mex inbox proposal show <proposal-id> --json` for read-only review. For approve, reject, withdraw, mark-stale, or repair:
|
||||
|
||||
1. Resolve the matching command ID.
|
||||
2. Use the proposal's exact current artifact revision and any action-specific rationale or replacement draft.
|
||||
3. Preview with the corresponding `mex inbox proposal <action> <request-file> --json` command.
|
||||
4. Explain the semantic outcome and which canonical Git-tracked MEX records would change.
|
||||
5. Wait for fresh explicit confirmation.
|
||||
6. Apply the exact preview with the same command plus `--apply <preview-envelope> --json`.
|
||||
|
||||
Approval writes the proposed knowledge change, proposal decision, Wiki ledger, and Activity records to the working tree. The proposal remains review history. Reject and withdraw make a terminal proposal decision without changing knowledge. Mark stale changes a pending proposal to stale only when MEX proves dependency drift. Repair replaces stale intent, clears prior review, and returns the proposal to pending without changing knowledge. These canonical transitions write Activity records; none commits, pushes, pulls, stages, or notifies teammates.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"owner": "mex-agent",
|
||||
"skill": "mex-relay",
|
||||
"packageVersion": "0.8.2",
|
||||
"files": {
|
||||
"SKILL.md": "186d1d19c4b513f4b98ee27bf0259744d1ef79ffc758676484596cfe498aa502",
|
||||
"agents/openai.yaml": "9c2e9c2e34d60ec0f4d07089b07d9e49a6c8210c2a3e277593c15a2635f03076",
|
||||
"references/cli-workflows.md": "56b6e74129cdc6ddb5121480b03e01fd2f1f65b7b8c0b7899552a4a217d48cf5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: mex-relay
|
||||
description: Prepare and manage durable MEX team handoffs. Use when the user asks to hand work to a person or team, prepare an end-of-session handoff, save what the next engineer needs, create a MEX Relay, take or close a Relay, or explicitly invokes /mex-relay or $mex-relay. Treat Relay as a memory and context baton, not chat, notifications, task assignment, issue tracking, or a Jira replacement; do not activate for ordinary status messages that are not durable handoffs.
|
||||
---
|
||||
|
||||
# MEX Relay
|
||||
|
||||
Prepare durable team handoffs that another engineer can continue from. Never represent a Relay as a sent message or notification.
|
||||
|
||||
## Prepare a Relay draft
|
||||
|
||||
1. Infer the useful session state: a concise summary, current position, completed work, in-progress work, blockers, unresolved questions, next actions, and relevant decisions, files, code, commits, or external links.
|
||||
2. For “whoever picks this up,” choose open-to-team. It includes future active project Members; no Member lookup is needed to save this local draft. If the user names people, retain named-recipient intent and resolve exact IDs only when needed. An unresolved named draft may stay recipient-free locally; do not silently publish it to everyone.
|
||||
3. Default to a standalone Relay. Use the local-save shortcut for a new draft; keep the structured preview/apply path for exact updates and canonical actions.
|
||||
4. Include an existing relevant Workstream only as typed evidence. Never invent one or turn saving a handoff into Workstream creation.
|
||||
5. Add optional typed context references only when the referenced IDs, paths, commits, or URLs are known. Never invent provenance.
|
||||
6. Resolve the action-scoped runtime contract. For a new draft, `mex relay draft save --from <draft.json> --json` performs the exact local preview/apply internally. For an update, build and preview the structured request.
|
||||
7. An explicit create/save/draft request authorizes that local write without another confirmation. Publication remains separate.
|
||||
8. Return `/relays?view=drafts&draft=<id>` and state that the draft is checkout-local and nothing has been delivered or shared.
|
||||
|
||||
Read [references/cli-workflows.md](references/cli-workflows.md) before executing any Relay mutation. Load only the operation being performed.
|
||||
|
||||
## Apply the approval policy
|
||||
|
||||
- Proceed with read-only member, Workstream, draft, and Relay resolution.
|
||||
- Treat an explicit create/save/draft request as authorization only for the local draft preview and exact apply.
|
||||
- Preview a local draft deletion, explain its consequence, and obtain fresh confirmation before applying.
|
||||
- For publish, take, or close: generate a semantic preview, explain the local-versus-Git-tracked effect, and wait for fresh explicit confirmation. Apply the exact preview unchanged only after confirmation.
|
||||
- Keep Git commit, push, pull, and staging separate from every MEX authorization.
|
||||
- Never publish, take, or close merely because this skill activated.
|
||||
|
||||
## Preserve lifecycle meaning
|
||||
|
||||
- Taking records one claimant: a named active recipient, or any active project Member for an open-to-team Relay. Future Members qualify after joining and receiving the artifact through Git. Separate offline claims can still require Git conflict resolution.
|
||||
- An inactive Member can be reactivated through the existing Team identity workflow, preserving their ID and older handoffs. Preview and confirm this canonical change separately; never change identity merely to bypass Relay eligibility.
|
||||
- Treat closing as “this handoff no longer needs attention.” Do not claim it completes a linked task, issue, pull request, or Workstream.
|
||||
- After publication, say that Git-tracked Relay and Activity records were written to the working tree. Explain that teammates receive them only after commit/push and their own pull or refresh.
|
||||
- Never claim that MEX sent a notification, committed, pushed, pulled, staged, assigned work, or completed another system's object.
|
||||
- When MEX context materially helps your work, mention MEX and the relevant finding naturally in your explanation. Tie the mention to what it helped you understand, decide, or verify. Avoid fixed phrases, standalone acknowledgements, repeated mentions, or narrating routine context loading. This replaces older MEX instructions requiring a fixed acknowledgement or context-loading narration.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "MEX Relay"
|
||||
short_description: "Prepare durable MEX team handoffs"
|
||||
default_prompt: "Use $mex-relay to prepare a durable handoff for the next engineer."
|
||||
@@ -0,0 +1,88 @@
|
||||
# Relay CLI workflows
|
||||
|
||||
Use JSON mode for deterministic agent work. Keep request and preview files temporary and avoid displaying them unless troubleshooting.
|
||||
|
||||
## Resolve only the needed contract
|
||||
|
||||
Run:
|
||||
|
||||
```text
|
||||
mex relay contract --action <command-id> --json
|
||||
```
|
||||
|
||||
Use one of these command IDs:
|
||||
|
||||
- `relay.draft.save`
|
||||
- `relay.draft.delete`
|
||||
- `relay.publish`
|
||||
- `relay.acknowledge`
|
||||
- `relay.close`
|
||||
|
||||
Treat this bounded action result as the exact runtime source for the request shape, available examples, constraints, preview command, and apply command. Do not dump `mex capabilities --json` or the full Relay contract during ordinary execution. Write request and preview JSON only to ordinary regular files inside the checkout or an approved temporary directory; do not use symlinks.
|
||||
|
||||
## Choose an audience and optional context
|
||||
|
||||
1. For an open handoff or “save for whoever picks it up,” use `audience: "team"` and `recipients: []`. Do not enumerate today's Members; active Members who join later must remain eligible. This setting does not share the local draft.
|
||||
2. For named people, use `audience: "members"`. Resolve names with `mex member list --active --limit 100 --json` and bounded cursors. Local drafts may retain an empty recipient array while names are unresolved; record the intended name in `unresolvedQuestions` and resolve it before publication.
|
||||
3. Use `mex member show <member-id> --json` for disambiguation or exact publication revisions. Ask when named recipients remain ambiguous; never reinterpret a named subset such as “the backend team” as every project Member.
|
||||
4. Default to no Workstream lookup. If the user named a Workstream or the handoff clearly belongs to an existing one, run `mex workstream list --json` and `mex workstream show <workstream-id> --json` to resolve it exactly.
|
||||
5. In Relay v3, preserve a relevant Workstream as typed entity evidence when the selected contract supports it; do not author the legacy top-level Workstream field.
|
||||
|
||||
Never fabricate recipient IDs, entity IDs, revisions, commits, code fingerprints, paths, URLs, or provenance.
|
||||
|
||||
## Save a checkout-local draft
|
||||
|
||||
1. Resolve `relay.draft.save`.
|
||||
2. Write one temporary JSON object containing the draft content. Include a concise summary, the intended audience, and only useful non-empty context sections. Prefer accurate omissions over invented completeness.
|
||||
3. For a new draft, run `mex relay draft save --from <draft.json> --operation-id <unique-id> --json`. This validates the content, obtains a signed local preview, and applies that exact preview internally. It never publishes and needs no Member resolution. Do not combine `--from` with a request file or `--apply`.
|
||||
4. Require `ok: true`, `mode: "apply"`, and `data.applied: true`; read the draft ID from `data.localChanges` and return `/relays?view=drafts&draft=<id>`. The shortcut retains the original preview under `.mex/local/relay-previews/` before applying and removes it after success. An interrupted save can resume with the same operation ID and unchanged content, or the exact `problem.recovery` command. Do not replace the receipt or create another operation to work around an interrupted/conflicting result; if recovery is refused, report it and inspect the existing draft state.
|
||||
|
||||
For example, a new open handoff may contain:
|
||||
|
||||
```json
|
||||
{
|
||||
"audience": "team",
|
||||
"recipients": [],
|
||||
"summary": "Continue the parser correction.",
|
||||
"completed": ["Reproduced the failure with the nested-import fixture."],
|
||||
"nextActions": ["Check whether the same failure affects exported aliases."]
|
||||
}
|
||||
```
|
||||
|
||||
For an existing draft update, read it with `mex relay draft show <draft-id> --json`, preserve its content, and use its exact local revision in the structured request. Preview with `mex relay draft save <request-file> --json`; require `ok: true`, `mode: "preview"`, and `data.preview.valid: true`. Apply the complete captured envelope unchanged with `mex relay draft save --apply <preview-envelope> --json`. An explicit save/update request needs no additional confirmation for this checkout-local write.
|
||||
|
||||
The apply writes only checkout-local draft state in `.mex/local/team.db`. It does not create a canonical Relay or Activity record, deliver a handoff, commit, push, or notify anyone. Apply before the preview expires; if anything changes or the preview becomes stale, preview again instead of reconstructing it.
|
||||
|
||||
## Delete a local draft
|
||||
|
||||
1. Read the exact draft and current local revision.
|
||||
2. Resolve and preview `relay.draft.delete`.
|
||||
3. Explain that the checkout-local draft will be deleted and wait for fresh confirmation.
|
||||
4. Apply the captured preview unchanged with `mex relay draft delete --apply <preview-envelope> --json`.
|
||||
|
||||
## Publish a Relay
|
||||
|
||||
1. Read the exact draft. For an open-to-team audience, no recipient Member list is needed. For named recipients, require at least one and read each current active Member; an empty named draft must be completed before publication.
|
||||
2. Resolve `relay.publish`. Team publication expects only the exact local draft revision; named publication also expects every recipient Member revision. Preview with `mex relay publish <request-file> --json`. The service verifies the current active sender in both cases.
|
||||
3. Explain that applying replaces the private local draft with canonical Git-tracked Relay and Activity records in the working tree, records the service-observed branch/HEAD/dirty repository state without copying dirty source contents, and does not deliver through a notification service or share before Git commit/push and teammate pull/refresh.
|
||||
4. Wait for fresh explicit confirmation.
|
||||
5. Apply the exact preview with `mex relay publish --apply <preview-envelope> --json`.
|
||||
6. Return `/relays?view=sent&state=open&relay=<relay-id>`.
|
||||
|
||||
## Take a Relay
|
||||
|
||||
1. Resolve the exact published Relay with `mex relay show <relay-id> --json`. A team audience allows the current active project Member; a named audience requires the current Member to be listed. An omitted audience on an older Relay means named recipients.
|
||||
2. Resolve `relay.acknowledge` and preview with `mex relay acknowledge <request-file> --json`.
|
||||
3. Explain that applying records the current Member as claimant and writes canonical Relay/Activity state in the working tree. A synchronized claim prevents another take; separate offline claims still require Git conflict resolution. There is no unclaim or reassignment action, and it does not assign or start a task elsewhere.
|
||||
4. Wait for fresh explicit confirmation, then apply the exact preview with `mex relay acknowledge --apply <preview-envelope> --json`.
|
||||
5. Return `/relays?view=mine&state=open&relay=<relay-id>`.
|
||||
|
||||
## Close a Relay
|
||||
|
||||
1. Resolve the exact acknowledged Relay and its current revision.
|
||||
2. Resolve `relay.close` and preview with `mex relay close <request-file> --json`.
|
||||
3. Explain that applying irreversibly marks only the handoff as no longer needing attention and writes canonical Relay/Activity state in the working tree.
|
||||
4. Wait for fresh explicit confirmation, then apply the exact preview with `mex relay close --apply <preview-envelope> --json`.
|
||||
5. Return `/relays?view=all&state=closed&relay=<relay-id>`.
|
||||
|
||||
Closing does not complete a linked task, issue, pull request, or Workstream. No lifecycle command stages, commits, pushes, pulls, or sends a notification.
|
||||
@@ -0,0 +1,6 @@
|
||||
# VotingPlugin currently keeps these legacy files as CRLF. Treat CR as part of the line ending
|
||||
# so git diff --check does not report their CRLF additions as trailing whitespace.
|
||||
VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java whitespace=cr-at-eol
|
||||
VotingPlugin/src/main/resources/bungeeconfig.yml whitespace=cr-at-eol
|
||||
VotingPlugin/src/main/java/com/bencodez/votingplugin/config/Config.java whitespace=cr-at-eol
|
||||
VotingPlugin/src/main/resources/Config.yml whitespace=cr-at-eol
|
||||
@@ -270,11 +270,14 @@ Coverage: <complete, static-only, partial, or stale; material limitations>
|
||||
<prioritized findings, or the applicable zero-finding result below>
|
||||
```
|
||||
|
||||
When required coverage and validation are complete with no findings, the findings
|
||||
section is exactly `No findings.` Otherwise use `Review incomplete.` with specific
|
||||
limitations, while still reporting verified findings. An explicitly scoped
|
||||
static-only review can be complete within that scope but does not pass a gate
|
||||
requiring builds. Do not add praise, scores, generic advice, or merge approval.
|
||||
Determine review completeness independently from findings and publishing readiness:
|
||||
|
||||
- Coverage describes the review scope, not finding completeness. Preserve `Coverage: partial` for an explicitly scoped, focused review (including bounded security or reliability reviews), even when every finding within that scope is verified; findings do not upgrade partial coverage to complete.
|
||||
- Complete review with findings: report the verified findings in priority order and record the review as complete. Keep `Coverage: static-only` for an explicitly scoped static-only review; otherwise mark coverage complete only when the requested scope was fully covered.
|
||||
- Complete review without findings: the findings section is exactly `No findings.`
|
||||
- Incomplete review: only when required coverage or validation is missing, unresolved, or stale, use `Review incomplete.` with the actual limitations while still reporting verified findings.
|
||||
|
||||
An explicitly scoped static-only review can be complete within that scope but does not pass a gate requiring builds. A demonstrated defect is validation evidence, not by itself a coverage gap. Do not add praise, scores, generic advice, or merge approval.
|
||||
|
||||
For a pre-publish gate, the implementation coordinator, not the reviewer, fixes
|
||||
accepted findings, reruns required checks, and obtains a fresh independent review
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
skill=${1:-"$(cd "$(dirname "$0")/.." && pwd)/SKILL.md"}
|
||||
|
||||
grep -Fq 'Coverage describes the review scope, not finding completeness.' "$skill"
|
||||
grep -Fq 'Preserve `Coverage: partial` for an explicitly scoped, focused review' "$skill"
|
||||
grep -Fq 'findings do not upgrade partial coverage' "$skill"
|
||||
if grep -Fq 'otherwise mark coverage complete.' "$skill"; then
|
||||
echo 'stale unconditional complete-coverage guidance remains' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo 'coverage contract: ok'
|
||||
+23
-18
@@ -1,10 +1,3 @@
|
||||
# This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven
|
||||
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support documentation.
|
||||
|
||||
name: Java CI with Maven
|
||||
|
||||
on:
|
||||
@@ -14,28 +7,40 @@ on:
|
||||
branches: [ "master" ]
|
||||
|
||||
permissions:
|
||||
contents: write # required for dependency submission
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
cache: maven
|
||||
|
||||
- name: Build with Maven
|
||||
run: mvn -B -f VotingPlugin/pom.xml package
|
||||
|
||||
# Only submit dependency snapshot on push (not on pull_request)
|
||||
- name: Submit Dependency Snapshot
|
||||
if: github.event_name == 'push'
|
||||
uses: advanced-security/maven-dependency-submission-action@v5
|
||||
dependency-submission:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
directory: VotingPlugin # path to pom.xml
|
||||
# optional: maven-args: "-DskipTests"
|
||||
persist-credentials: false
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
cache: maven
|
||||
- name: Submit dependency snapshot
|
||||
uses: advanced-security/maven-dependency-submission-action@b275d12641ac2d2108b2cbb7598b154ad2f2cee8 # v5.0.0
|
||||
with:
|
||||
directory: VotingPlugin
|
||||
@@ -3,22 +3,87 @@ name: Deploy Javadoc
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: github-pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
release-eligibility:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
eligible: ${{ steps.eligibility.outputs.eligible }}
|
||||
commit: ${{ steps.eligibility.outputs.commit }}
|
||||
steps:
|
||||
- name: Deploy JavaDoc 🚀
|
||||
uses: MathieuSoysal/Javadoc-publisher.yml@main
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
javadoc-branch: gh-pages
|
||||
java-version: 21
|
||||
target-folder: ''
|
||||
project: maven # or gradle
|
||||
# subdirectories: moduleA moduleB #for subdirectories support, needs to be run with custom command
|
||||
custom-command: mvn -f VotingPlugin/pom.xml deploy -P javadoc javadoc:aggregate
|
||||
javadoc-source-folder: 'VotingPlugin/target/reports/apidocs'
|
||||
ref: master
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Verify release tag is reachable from master
|
||||
id: eligibility
|
||||
env:
|
||||
PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
if [ "$PRERELEASE" = "true" ]; then
|
||||
echo "eligible=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git fetch --no-tags origin "refs/tags/${TAG_NAME}:refs/tags/release"
|
||||
tag_commit="$(git rev-parse 'refs/tags/release^{commit}')"
|
||||
if git merge-base --is-ancestor "$tag_commit" origin/master; then
|
||||
echo "eligible=true" >> "$GITHUB_OUTPUT"
|
||||
echo "commit=$tag_commit" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "eligible=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: release-eligibility
|
||||
if: needs.release-eligibility.outputs.eligible == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.release-eligibility.outputs.commit }}
|
||||
persist-credentials: false
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: temurin
|
||||
cache: maven
|
||||
- name: Build project
|
||||
run: mvn -B -f VotingPlugin/pom.xml clean package
|
||||
- name: Build Javadoc
|
||||
run: mvn -B -f VotingPlugin/pom.xml org.apache.maven.plugins:maven-javadoc-plugin:3.12.0:javadoc -Ddoclint=none
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
|
||||
with:
|
||||
path: VotingPlugin/target/reports/apidocs
|
||||
|
||||
deploy:
|
||||
needs: [release-eligibility, build]
|
||||
if: >-
|
||||
needs.release-eligibility.outputs.eligible == 'true' &&
|
||||
needs.build.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: 'Stale issue message'
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
graph.db*
|
||||
wiki.db*
|
||||
local/
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: agents
|
||||
description: Project anchor for VotingPlugin; use ROUTER.md for task-specific MEX context.
|
||||
last_updated: 2026-09-20
|
||||
---
|
||||
|
||||
# VotingPlugin
|
||||
|
||||
VotingPlugin processes player votes, totals, and rewards on Bukkit/Paper servers and routes network votes through BungeeCord/Velocity.
|
||||
|
||||
## Authority and use
|
||||
|
||||
Use current code and tests for current behavior; root `AGENTS.md` and formal repository contracts for development rules and intended contracts; reviewed MEX project knowledge for rationale and known pitfalls; historical Relays and older records for background only. If memory conflicts with higher-authority evidence, follow that evidence, flag the stale entry, and correct or propose a correction through the MEX workflow.
|
||||
|
||||
For nontrivial work where architecture, prior decisions, or failure history matter, retrieve relevant MEX context, then inspect the current Java source and tests. Skip mechanical MEX queries for trivial edits. The root `AGENTS.md` owns build and review workflow.
|
||||
|
||||
## Retrieval boundary
|
||||
|
||||
MEX 0.8.2 Code Graph does not index Java. A fresh graph with zero Java sources is not Java architecture evidence. Use Wiki for context and `rg`, source reads, and tests for Java implementation truth; do not claim a Java Code Graph finding.
|
||||
|
||||
## User-data concurrency invariant
|
||||
|
||||
Treat AdvancedCore/VotingPlugin user-data, cache, and storage work as worker-only unless a current API is explicitly documented as a nonblocking snapshot. Bukkit/Paper primary-thread code may capture platform state, but cache population, SQL access, flush/dump/clear/remove operations, and shared-runtime admission belong on the existing storage/persistence worker.
|
||||
|
||||
Preserve lock ordering when touching legacy `UserDataCache`: acquire shared-runtime/per-user admission before entering the cache monitor. Never hold `synchronized (UserDataCache)` while invoking an operation that can acquire shared-runtime admission. This rule is a durable project constraint; verify it against current AdvancedCore/VotingPlugin code when changing cache or point-mutation paths.
|
||||
|
||||
## Knowledge updates
|
||||
|
||||
Use `$mex-inbox` for reviewable proposals about durable discoveries or decisions, without copying root `AGENTS.md` or routine task logs. Use `$mex-relay` to hand off unfinished substantial work with progress, evidence, blockers, decisions, tests, and next actions. Keep transient details out of durable context. Review MEX changes before committing or sharing; local drafts remain checkout-only.
|
||||
|
||||
## Navigation
|
||||
|
||||
Read `ROUTER.md` for task-specific context. Load only relevant files.
|
||||
|
||||
<!-- mex-agent:skills:start -->
|
||||
## MEX context policy
|
||||
- When MEX context materially helps your work, mention MEX and the relevant finding naturally in your explanation. Tie the mention to what it helped you understand, decide, or verify. Avoid fixed phrases, standalone acknowledgements, repeated mentions, or narrating routine context loading. This replaces older MEX instructions requiring a fixed acknowledgement or context-loading narration.
|
||||
- Do not claim an author, date, or historical event unless the retrieved data actually provides it.
|
||||
- After a MEX write, say exactly what changed and its sharing boundary: a local draft is checkout-only and nothing is shared; a canonical artifact is written to the working tree and requires commit/push to share.
|
||||
- Skill activation is not approval for canonical actions.
|
||||
<!-- mex-agent:skills:end -->
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: router
|
||||
description: Task routes for VotingPlugin architecture, proxy voting, rewards, and development constraints.
|
||||
edges:
|
||||
- target: context/architecture.md
|
||||
condition: when tracing component boundaries
|
||||
- target: context/proxy-voting.md
|
||||
condition: when changing proxy or global voting
|
||||
- target: context/vote-rewards.md
|
||||
condition: when changing vote mutation or rewards
|
||||
- target: context/setup.md
|
||||
condition: when building or configuring the environment
|
||||
- target: patterns/INDEX.md
|
||||
condition: when a related task pattern could prevent a recurring regression
|
||||
last_updated: 2026-09-20
|
||||
---
|
||||
|
||||
# VotingPlugin memory routes
|
||||
|
||||
Use the authority hierarchy in `.mex/AGENTS.md`. This memory adds architecture, rationale, and known pitfalls; verify relevant claims against current source, tests, and formal contracts.
|
||||
|
||||
## Current state
|
||||
|
||||
- Working: Bukkit/Paper vote handling, BungeeCord/Velocity proxy routing, configurable vote sites/rewards, optional outbound Control integration. Sources: `VotingPlugin/src/main/java/com/bencodez/votingplugin/`, `README.md`.
|
||||
- Graph limitation: MEX 0.8.2 indexes no Java symbols. Use Wiki search and direct Java source inspection.
|
||||
|
||||
## Route only when relevant
|
||||
|
||||
| Task | Context |
|
||||
| --- | --- |
|
||||
| Architecture or package ownership | `context/architecture.md` |
|
||||
| Proxy/global vote handling | `context/proxy-voting.md`, then [proxy vote pattern](patterns/proxy-vote-change.md) |
|
||||
| Proxy delivery guarantees | `context/decision-mx_01M31068M0WJ4KB0ZD6C6BXHZK.md` |
|
||||
| Vote mutation, totals, or rewards | `context/vote-rewards.md`, then [vote behavior pattern](patterns/vote-behavior-change.md) |
|
||||
| Libraries and supported platforms | `context/stack.md` |
|
||||
| Build/environment | `context/setup.md` |
|
||||
| Why an unusual boundary exists | `context/decisions.md` |
|
||||
| Unusual implementation convention | `context/conventions.md` |
|
||||
|
||||
Retrieve memory only when it may materially help; inspect current Java and tests before relying on it. Flag and correct stale entries through MEX. Do not force a query or memory write for trivial edits.
|
||||
@@ -0,0 +1,9 @@
|
||||
# MEX setup for this VotingPlugin checkout
|
||||
|
||||
This is an established Java repository. Initial MEX context was populated from `README.md`, root `AGENTS.md`, `VotingPlugin/pom.xml`, configuration resources, docs, and the current production Java packages. The fresh-project questionnaire in the packaged MEX template does not apply here.
|
||||
|
||||
MEX 0.8.2 was configured with `mex setup --cli` for Codex only. Its Java Code Graph contains zero source symbols because this release does not index Java. Use `.mex/ROUTER.md` and Wiki for context, then verify implementation claims through direct Java source and tests.
|
||||
|
||||
The CLI, Wiki, and Codex instructions work in this checkout. MEX's full Hub requires the canonical `.mex/config.json` at `HEAD`; routine CLI use does not require a Hub. Do not expose a Hub for this setup.
|
||||
|
||||
Useful checks: `mex check`, `mex doctor`, `mex graph status`, `mex wiki query "proxy"`, and `mex skills sync --dry-run --tool codex`.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Keeping VotingPlugin memory current
|
||||
|
||||
MEX 0.8.2 does not index Java symbols, so an empty graph cannot prove architectural freshness. Check claims against current source, tests, root `AGENTS.md`, and formal contracts. Use `mex check` for scaffold health and `mex wiki rebuild-index` after reviewed memory changes.
|
||||
|
||||
If memory is stale, update the relevant context or propose a correction through `$mex-inbox`. Keep prior decisions only when their history matters, marking superseded decisions clearly. Do not regenerate all context from a scaffold prompt or copy root development rules into MEX.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"setupMode": "code-repo",
|
||||
"aiTools": [
|
||||
"codex"
|
||||
],
|
||||
"scaffold_id": "054c39c7-d95c-449e-bdad-6ff84f6d3ebd",
|
||||
"scaffold_name": "VotingPlugin",
|
||||
"origin": null,
|
||||
"upstream": null
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: architecture
|
||||
description: VotingPlugin's runtime boundaries and vote flow.
|
||||
triggers: [architecture, vote flow, module boundaries]
|
||||
edges:
|
||||
- target: context/proxy-voting.md
|
||||
condition: when a vote crosses proxy and backend
|
||||
- target: context/vote-rewards.md
|
||||
condition: when vote state or reward behavior changes
|
||||
grounds_to: []
|
||||
last_updated: 2026-09-20
|
||||
mex:
|
||||
id: mx_01M305TTX1QHVTAB2KR3S1VPH5
|
||||
type: architecture
|
||||
status: promoted
|
||||
revision: 1
|
||||
title: architecture
|
||||
---
|
||||
|
||||
# Architecture
|
||||
|
||||
## Vote flow
|
||||
|
||||
Votifier and proxy ingress reach Bukkit's `PlayerVoteListener`, which validates the vote, resolves the player and service site, handles timing and proxy metadata, updates the `VotingPluginUser`, delivers or queues rewards, updates totals/points/streaks, and emits downstream effects. Proxy nodes route votes to backend servers, where player rewards normally execute. The optional Control connector is a management adapter and does not process votes.
|
||||
|
||||
<!-- mex:entity
|
||||
id: mx_01M305TTVDWCN3GH24C15GADN2
|
||||
type: component
|
||||
status: promoted
|
||||
revision: 1
|
||||
-->
|
||||
## Cross-component boundaries
|
||||
|
||||
- The proxy package routes network votes; backendproxy handles backend transport and coordination. A routing change may also alter the listener's online/offline reward decision.
|
||||
- Vote-site resolution and user persistence meet in the listener. Some site lookup paths may create configuration; observational code must use non-creating resolution.
|
||||
- The vote log records selected events rather than every network hop or reward outcome. It cannot prove complete delivery.
|
||||
|
||||
## Boundaries
|
||||
|
||||
No native Fabric, Forge, or NeoForge loader is in this repository's current Maven package. Source: `README.md`, root `AGENTS.md`, `VotingPlugin/src/main/java/com/bencodez/votingplugin/`.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: conventions
|
||||
description: Established VotingPlugin engineering and verification rules.
|
||||
triggers: [conventions, code review, implementation, test]
|
||||
edges:
|
||||
- target: context/architecture.md
|
||||
condition: when finding the owner of a change
|
||||
- target: context/vote-rewards.md
|
||||
condition: when a change affects vote behavior
|
||||
grounds_to: []
|
||||
last_updated: 2026-09-20
|
||||
mex:
|
||||
id: mx_01M305TTXGZN471W11VFPJQKQH
|
||||
type: convention
|
||||
status: promoted
|
||||
revision: 2
|
||||
title: conventions
|
||||
relations:
|
||||
- type: related_to
|
||||
target: mx_01M305TTX1QHVTAB2KR3S1VPH5
|
||||
note: when finding the owner of a change
|
||||
---
|
||||
|
||||
# Conventions that need context
|
||||
|
||||
- Vote-site lookup has both observational and potentially creating paths. Read-only Control inspection must use the observational path; see root `AGENTS.md` and the vote-site manager before changing a call.
|
||||
- Control is an optional outbound management adapter. Its failure must not become a dependency of vote receipt or delivery; see `docs/control-connector.md`.
|
||||
|
||||
Build, testing, review, and security rules live in root `AGENTS.md` and `docs/control-agent-contract.md`; do not maintain a second copy here.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
mex:
|
||||
id: mx_01M31068M0WJ4KB0ZD6C6BXHZK
|
||||
type: decision
|
||||
status: promoted
|
||||
revision: 1
|
||||
title: Defer end-to-end proxy vote delivery guarantee
|
||||
sources:
|
||||
- type: document
|
||||
ref: .mex/inbox/proposal_01M31040E5ECB5F57YHXYGX618.md
|
||||
note: "The user explicitly said guaranteed delivery will be added in the future
|
||||
while discussing new PR #1619 review comments. This separates current
|
||||
local queue reliability fixes from a future protocol design."
|
||||
capturedAt: 2026-09-21T03:29:40.432Z
|
||||
metadata:
|
||||
proposalId: proposal_01M31040E5ECB5F57YHXYGX618
|
||||
author:
|
||||
kind: git
|
||||
name: BenCodez
|
||||
email: 17074231+BenCodez@users.noreply.github.com
|
||||
approvedBy:
|
||||
kind: git
|
||||
name: BenCodez
|
||||
email: 17074231+BenCodez@users.noreply.github.com
|
||||
- type: manual
|
||||
note: "User message in this session: 'New comments, will add the guaranteed
|
||||
delivery in the future, please remember that'."
|
||||
capturedAt: 2026-09-21T03:29:40.432Z
|
||||
metadata:
|
||||
evidence:
|
||||
kind: manual
|
||||
note: "User message in this session: 'New comments, will add the guaranteed
|
||||
delivery in the future, please remember that'."
|
||||
provenance:
|
||||
createdBy:
|
||||
kind: human
|
||||
id: git:139ad6f2a86f1dbf589661a50db20426
|
||||
createdAt: 2026-09-21T03:29:40.432Z
|
||||
---
|
||||
# Defer end-to-end proxy vote delivery guarantee
|
||||
|
||||
For VotingPlugin PR #1619, retain the existing bounded backend queue work but defer an end-to-end guaranteed proxy-to-backend vote delivery protocol to future work. A future design may need proxy retention, backend durable acknowledgement, retry, and restart-safe duplicate handling. Do not treat the current overflow queue or plugin-message send result as a guarantee that every vote will be delivered or each reward will execute exactly once.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: decisions
|
||||
description: Durable reasons behind important VotingPlugin boundaries.
|
||||
triggers: [decision, why, tradeoff, compatibility]
|
||||
edges:
|
||||
- target: context/architecture.md
|
||||
condition: when the decision changes ownership
|
||||
- target: context/proxy-voting.md
|
||||
condition: when a decision affects network votes
|
||||
- target: context/vote-rewards.md
|
||||
condition: when a decision affects rewards
|
||||
grounds_to: []
|
||||
last_updated: 2026-09-20
|
||||
---
|
||||
|
||||
# Decisions and constraints
|
||||
|
||||
## Control is optional
|
||||
|
||||
Control is a separate management plane. Connectors initiate outbound calls and are isolated from vote receipt, rewards, joins, and shutdown. This permits independent deployment and failure. Source: `README.md`, root `AGENTS.md`, `docs/control-connector.md`.
|
||||
|
||||
## Proxy and backend have different jobs
|
||||
|
||||
Proxy code routes network votes and selected global messaging; backend servers normally own player rewards and state. Network configuration requires consistent backend settings and unique server names. Source: `VotingPlugin/src/main/resources/Config.yml` (proxy setup guidance), `VotingPlugin/src/main/resources/bungeeconfig.yml`, `VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/`, `VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/`.
|
||||
|
||||
## Keep established reward behavior during portability work
|
||||
|
||||
Maintainer direction (2026-09-20): preserve existing function behavior while preparing for possible Fabric/NeoForge support; explain and ask before a necessary behavior change. Reward YAML changes should affect subsequent delivery through the existing configuration path. This is a compatibility preference, not a claim that native loaders or a new shared vote path exist in this checkout. Verify the current path in `VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java`.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: proxy-voting
|
||||
description: Proxy/global vote routing and state distinctions.
|
||||
triggers: [proxy, global vote, BungeeCord, Velocity, wasOnline, routing]
|
||||
edges:
|
||||
- target: context/architecture.md
|
||||
condition: when tracing ownership between nodes
|
||||
- target: context/vote-rewards.md
|
||||
condition: when proxy metadata affects reward delivery
|
||||
grounds_to: []
|
||||
last_updated: 2026-09-20
|
||||
---
|
||||
|
||||
# Proxy and global voting
|
||||
|
||||
Proxy nodes use BungeeCord or Velocity entry points under the proxy package. Backend transport and message routing live in the backendproxy package; configuration chooses plugin messaging, HTTP, Redis, socket, MySQL, or MQTT where supported. HTTP proxy-method coordination requires `config.proxy-method.v2`; the other methods use v1. Backend servers normally execute player rewards. Shared MySQL data and unique backend `ServerName` settings matter for network setups.
|
||||
|
||||
In the accepted Bukkit vote path, `event.isBungee()` (origin), `event.isForceBungee()` (Bungee-aware reward and downstream processing), `user.isOnline()` (current state), and `event.isWasOnline()` (historical state) drive different branches. `forceBungee` does not select the earlier proxy route. Do not collapse these into one flag. The listener also handles queued proxy duplicate timing, `WaitUntilVoteDelay`, vote party, broadcast, totals, and post-vote events; changing routing can affect each.
|
||||
|
||||
Sources: `VotingPlugin/src/main/resources/Config.yml` (proxy guidance), `VotingPlugin/src/main/resources/bungeeconfig.yml`, `VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java`, `VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/`, `VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/`.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: setup
|
||||
description: Local build and MEX environment for this VotingPlugin checkout.
|
||||
triggers: [setup, build, Maven, test, environment]
|
||||
edges:
|
||||
- target: context/stack.md
|
||||
condition: when a dependency or runtime version matters
|
||||
- target: context/conventions.md
|
||||
condition: when preparing validation
|
||||
grounds_to: []
|
||||
last_updated: 2026-09-20
|
||||
mex:
|
||||
id: mx_01M305TTXSX79T4GDEXNMFVB3E
|
||||
type: guide
|
||||
status: promoted
|
||||
revision: 2
|
||||
title: setup
|
||||
relations:
|
||||
- type: related_to
|
||||
target: mx_01M305TTXGZN471W11VFPJQKQH
|
||||
note: when preparing validation
|
||||
---
|
||||
|
||||
# Setup boundary
|
||||
|
||||
Root `AGENTS.md`, `VotingPlugin/pom.xml`, and `.github/workflows/maven.yml` own build instructions. MEX is local development tooling, not a plugin runtime dependency. This MEX release has no Java structural graph; inspect Java source directly. Rebuild the Wiki index after changing reviewed memory.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: stack
|
||||
description: Runtime, build, and dependency facts for VotingPlugin.
|
||||
triggers: [Java, Maven, Bukkit, Paper, Velocity, BungeeCord, dependency]
|
||||
edges:
|
||||
- target: context/setup.md
|
||||
condition: when installing or building
|
||||
- target: context/architecture.md
|
||||
condition: when a dependency affects runtime ownership
|
||||
grounds_to: []
|
||||
last_updated: 2026-09-20
|
||||
---
|
||||
|
||||
# Stack
|
||||
|
||||
- Java 21 is the Maven compile target. Backend runtime is Bukkit/Paper; proxy runtime has BungeeCord and Velocity implementations. Check current POM and descriptors for version support.
|
||||
- AdvancedCore supplies user and reward infrastructure. The listener consumes Votifier API events, which may be supplied by a compatible Votifier implementation. These are cross-component dependencies when changing vote behavior.
|
||||
- No native Fabric/Forge/NeoForge loader is present in this checkout.
|
||||
|
||||
Sources: `VotingPlugin/pom.xml`, `VotingPlugin/src/main/resources/Config.yml`, `VotingPlugin/src/main/resources/bungeeconfig.yml`, `VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/`.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: vote-rewards
|
||||
description: Vote mutation, totals, reward, and offline-delivery compatibility.
|
||||
triggers: [reward, totals, points, offline vote, vote streak, VoteParty]
|
||||
edges:
|
||||
- target: context/architecture.md
|
||||
condition: when locating the vote or reward owner
|
||||
- target: context/proxy-voting.md
|
||||
condition: when proxy or online state changes behavior
|
||||
grounds_to: []
|
||||
last_updated: 2026-09-20
|
||||
---
|
||||
|
||||
# Vote state and rewards
|
||||
|
||||
`PlayerVoteListener` is the accepted Bukkit vote path in this checkout. It resolves identity/site, applies vote-delay checks, caches the user, invokes vote party and broadcasts, then updates the last-vote time (normalizing a zero event timestamp). It delivers via `user.playerVote(...)` or queues an offline vote before updating totals, points, streaks, milestones, cooldowns, and `PlayerPostVoteEvent`.
|
||||
|
||||
Counting is conditional: `CountFakeVotes` or a real vote, plus `event.isAddTotals()`. `Config.AddTotals` and `AddTotalsOffline` govern totals, while configured points are awarded in the enclosing condition even when `Config.AddTotals` is false. Preserve those distinctions. Offline and proxy reward paths use different online facts. Reward definitions are in per-site `VotingPlugin/src/main/resources/VoteSites.yml`, `VotingPlugin/src/main/resources/SpecialRewards.yml`, and optional reward files; reward YAML edits are intended to affect live behavior.
|
||||
|
||||
Do not infer that shared processing work in another branch is present here. Verify actual persistence and reward paths before claiming exactly-once behavior.
|
||||
|
||||
Sources: `VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java`, `VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java`, `VotingPlugin/src/main/resources/Config.yml`.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
schema_version: 2
|
||||
id: "event_01M31040CS7129A1X8DZECNCZK"
|
||||
timestamp: "2026-09-21T03:28:26.521Z"
|
||||
actor: {"email":"17074231+BenCodez@users.noreply.github.com","kind":"git","name":"BenCodez"}
|
||||
action: "inbox.published"
|
||||
origin: {"kind":"workflow","operation":"inbox.publish"}
|
||||
label: "Defer end-to-end proxy vote delivery guarantee"
|
||||
subjects: [{"entity":{"id":"proposal_01M31040E5ECB5F57YHXYGX618","kind":"proposal"},"kind":"entity"}]
|
||||
repo_state: {"branch":"codex/pr1619-comments","dirty":false,"head":"37b610aad3094080b3282689ad2fc0d2bb8fa45d","observedAt":"2026-09-21T03:28:26.529Z"}
|
||||
---
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
schema_version: 2
|
||||
id: "event_01M31068JGH5JBVJR4ZDC7TBXN"
|
||||
timestamp: "2026-09-21T03:29:40.432Z"
|
||||
actor: {"email":"17074231+BenCodez@users.noreply.github.com","kind":"git","name":"BenCodez"}
|
||||
action: "inbox.approved"
|
||||
origin: {"kind":"workflow","operation":"inbox.approve"}
|
||||
label: "Defer end-to-end proxy vote delivery guarantee"
|
||||
subjects: [{"entity":{"id":"proposal_01M31040E5ECB5F57YHXYGX618","kind":"proposal"},"kind":"entity"},{"entity":{"id":"mx_01M31068M0WJ4KB0ZD6C6BXHZK","kind":"decision"},"kind":"entity"}]
|
||||
repo_state: {"branch":"codex/pr1619-comments","dirty":true,"head":"37b610aad3094080b3282689ad2fc0d2bb8fa45d","observedAt":"2026-09-21T03:29:40.440Z"}
|
||||
---
|
||||
@@ -0,0 +1,7 @@
|
||||
{"timestamp":"2026-09-21T10:51:59.003Z","kind":"decision","message":"Proxy-to-backend reward votes use a capability-negotiated durable outbox and completion acknowledgement. The guarantee is at least once; HTTP retains its existing delivery path, old backends retain legacy semantics, and exactly-once reward effects remain out of scope.","files":["VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/ReliableVoteDeliveryOutbox.java","VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java"],"cwd":".","source":"agent","status":"implemented"}
|
||||
{"timestamp":"2026-09-21T10:57:35.704Z","kind":"decision","message":"Keep the common Linux x86_64 SQLite native in the downloadable JAR; provision other SQLite targets from the pinned, SHA-256-verified sqlite-jdbc artifact with a documented offline pre-provisioning path. Use SimpleAPI JDK-only TLS identity without Bouncy Castle, enforce a 10 MiB package gate, and alert on meaningful size growth.","files":["VotingPlugin/pom.xml","VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java","docs/jar-packaging.md"],"cwd":".","source":"agent","status":"implemented"}
|
||||
{"timestamp":"2026-09-21T11:07:28.400Z","kind":"decision","message":"Reliable backend votes persist completed vote IDs for seven days before acknowledgement. Receipt persistence failure retries without repeating effects in the active process; restart deduplication covers durable completions, while a crash during non-transactional reward effects remains at-least-once.","files":["VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/DurableVoteReceiptStore.java","VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java"],"cwd":".","source":"agent","status":"implemented"}
|
||||
{"timestamp":"2026-09-21T11:41:29.374Z","kind":"decision","message":"Supersedes the seven-day receipt decision: completion receipts have no time expiry and fail closed at the bounded journal capacity, HTTP participates in the completion-acknowledged outbox, and receipt-write failures keep an in-memory post-effect fence until persistence succeeds.","files":["VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/DurableVoteReceiptStore.java","VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java","VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java"],"cwd":".","source":"agent","status":"implemented"}
|
||||
{"timestamp":"2026-09-21T12:13:09.371Z","kind":"decision","message":"Supersedes indefinite unreclaimed completion receipts: reliable vote delivery uses a durable two-phase receipt-release handshake. After the proxy durably records backend completion, it retries a release marker until the backend durably removes the receipt and acknowledges retirement; only then does the proxy remove the marker.","files":["VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/ReliableVoteDeliveryOutbox.java","VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/DurableVoteReceiptStore.java","VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java"],"cwd":".","source":"agent","status":"implemented"}
|
||||
{"timestamp":"2026-09-21T12:30:33.599Z","kind":"decision","message":"Supersedes immediate receipt removal after release: backend receipt release creates a 24-hour durable tombstone to fence deliveries already in flight, then reclaims it. Legacy downgrade delivery preserves the proxy release marker so a later capable backend can retire any receipt whose completion acknowledgement was lost.","files":["VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/DurableVoteReceiptStore.java","VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java","VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java"],"cwd":".","source":"agent","status":"implemented"}
|
||||
{"timestamp":"2026-09-21T12:46:34.284Z","kind":"decision","message":"Receipt releases persist a 24-hour tombstone even when the backend has no active receipt, fencing a late legacy retry. The bounded receipt store keeps completion headroom larger than the entire ordered lane plus separate tombstone capacity. Releases backed by an existing durable receipt may bypass a capacity-blocked vote because durable completion makes that reordering safe; unknown releases remain ordered.","files":["VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/DurableVoteReceiptStore.java","VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java","VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java"],"cwd":".","source":"agent","status":"implemented"}
|
||||
@@ -0,0 +1,18 @@
|
||||
{"v":1,"phase":"intent","opId":"mig_cabe95b226ccf7cde3af8516b56acf274014b473","type":"create-entry","entityIds":[],"createdIds":["mx_01M305TTVDWCN3GH24C15GADN2"],"actor":{"kind":"system","id":"mex-migration"},"timestamp":"2026-01-01T00:00:00.000Z","files":["context/architecture.md"],"payloadHash":"286d7621e415c1ec61509384e7551086c0b585e1dfe3de71a6dc23f8499b3dd2","revisions":[{"entityId":"mx_01M305TTVDWCN3GH24C15GADN2","before":0,"after":1}]}
|
||||
{"v":1,"phase":"complete","opId":"mig_cabe95b226ccf7cde3af8516b56acf274014b473","type":"create-entry","entityIds":[],"createdIds":["mx_01M305TTVDWCN3GH24C15GADN2"],"actor":{"kind":"system","id":"mex-migration"},"timestamp":"2026-01-01T00:00:00.000Z","files":["context/architecture.md"],"payloadHash":"286d7621e415c1ec61509384e7551086c0b585e1dfe3de71a6dc23f8499b3dd2","revisions":[{"entityId":"mx_01M305TTVDWCN3GH24C15GADN2","before":0,"after":1}]}
|
||||
{"v":1,"phase":"intent","opId":"mig_04bdfe68d2f7f9f793ced12eadc3a4027c0b5a64","type":"create-entry","entityIds":[],"createdIds":["mx_01M305TTX1QHVTAB2KR3S1VPH5"],"actor":{"kind":"system","id":"mex-migration"},"timestamp":"2026-01-01T00:00:00.000Z","files":["context/architecture.md"],"payloadHash":"60e9cb3b8cf14fc598ca64e965b4e9a2e20ef743ac37abc02e2c86cc48103ff5","revisions":[{"entityId":"mx_01M305TTX1QHVTAB2KR3S1VPH5","before":0,"after":1}]}
|
||||
{"v":1,"phase":"complete","opId":"mig_04bdfe68d2f7f9f793ced12eadc3a4027c0b5a64","type":"create-entry","entityIds":[],"createdIds":["mx_01M305TTX1QHVTAB2KR3S1VPH5"],"actor":{"kind":"system","id":"mex-migration"},"timestamp":"2026-01-01T00:00:00.000Z","files":["context/architecture.md"],"payloadHash":"60e9cb3b8cf14fc598ca64e965b4e9a2e20ef743ac37abc02e2c86cc48103ff5","revisions":[{"entityId":"mx_01M305TTX1QHVTAB2KR3S1VPH5","before":0,"after":1}]}
|
||||
{"v":1,"phase":"intent","opId":"mig_bba7e40030721976170be1e0e46673406f16555f","type":"create-entry","entityIds":[],"createdIds":["mx_01M305TTXGZN471W11VFPJQKQH"],"actor":{"kind":"system","id":"mex-migration"},"timestamp":"2026-01-01T00:00:00.000Z","files":["context/conventions.md"],"payloadHash":"dea0802d2c6208b2f103f615a1a322ba63f9a35dbddbe82d49988f8ed41f86ae","revisions":[{"entityId":"mx_01M305TTXGZN471W11VFPJQKQH","before":0,"after":1}]}
|
||||
{"v":1,"phase":"complete","opId":"mig_bba7e40030721976170be1e0e46673406f16555f","type":"create-entry","entityIds":[],"createdIds":["mx_01M305TTXGZN471W11VFPJQKQH"],"actor":{"kind":"system","id":"mex-migration"},"timestamp":"2026-01-01T00:00:00.000Z","files":["context/conventions.md"],"payloadHash":"dea0802d2c6208b2f103f615a1a322ba63f9a35dbddbe82d49988f8ed41f86ae","revisions":[{"entityId":"mx_01M305TTXGZN471W11VFPJQKQH","before":0,"after":1}]}
|
||||
{"v":1,"phase":"intent","opId":"mig_8dba03310a5302a413a7df38e88f6c2de16bc1e0","type":"create-entry","entityIds":[],"createdIds":["mx_01M305TTXSX79T4GDEXNMFVB3E"],"actor":{"kind":"system","id":"mex-migration"},"timestamp":"2026-01-01T00:00:00.000Z","files":["context/setup.md"],"payloadHash":"cf355dba2ffc569444242ee5b2fce6dd91e95d47beb012e590e6b8893c2314a4","revisions":[{"entityId":"mx_01M305TTXSX79T4GDEXNMFVB3E","before":0,"after":1}]}
|
||||
{"v":1,"phase":"complete","opId":"mig_8dba03310a5302a413a7df38e88f6c2de16bc1e0","type":"create-entry","entityIds":[],"createdIds":["mx_01M305TTXSX79T4GDEXNMFVB3E"],"actor":{"kind":"system","id":"mex-migration"},"timestamp":"2026-01-01T00:00:00.000Z","files":["context/setup.md"],"payloadHash":"cf355dba2ffc569444242ee5b2fce6dd91e95d47beb012e590e6b8893c2314a4","revisions":[{"entityId":"mx_01M305TTXSX79T4GDEXNMFVB3E","before":0,"after":1}]}
|
||||
{"v":1,"phase":"intent","opId":"mig_8e05280db06fec1f3e853ee787db16a146e8d9d3","type":"create-entry","entityIds":[],"createdIds":["mx_01M305TTY6NXWZP72T00XY9ESP"],"actor":{"kind":"system","id":"mex-migration"},"timestamp":"2026-01-01T00:00:00.000Z","files":["patterns/proxy-vote-change.md"],"payloadHash":"42006b04153f6c1f1ec3959ccf87960e26689c0b1f0e1728e1c98bd1c2c40699","revisions":[{"entityId":"mx_01M305TTY6NXWZP72T00XY9ESP","before":0,"after":1}]}
|
||||
{"v":1,"phase":"complete","opId":"mig_8e05280db06fec1f3e853ee787db16a146e8d9d3","type":"create-entry","entityIds":[],"createdIds":["mx_01M305TTY6NXWZP72T00XY9ESP"],"actor":{"kind":"system","id":"mex-migration"},"timestamp":"2026-01-01T00:00:00.000Z","files":["patterns/proxy-vote-change.md"],"payloadHash":"42006b04153f6c1f1ec3959ccf87960e26689c0b1f0e1728e1c98bd1c2c40699","revisions":[{"entityId":"mx_01M305TTY6NXWZP72T00XY9ESP","before":0,"after":1}]}
|
||||
{"v":1,"phase":"intent","opId":"mig_efc33097f7bc4550f829fd1a257d913379502a26","type":"create-entry","entityIds":[],"createdIds":["mx_01M305TTYFYNY85XFDEVFS6VXM"],"actor":{"kind":"system","id":"mex-migration"},"timestamp":"2026-01-01T00:00:00.000Z","files":["patterns/vote-behavior-change.md"],"payloadHash":"a541fa410e6a976cae8973438ee2919c83c4fb493fc51b61eaa7f0f068c04ea9","revisions":[{"entityId":"mx_01M305TTYFYNY85XFDEVFS6VXM","before":0,"after":1}]}
|
||||
{"v":1,"phase":"complete","opId":"mig_efc33097f7bc4550f829fd1a257d913379502a26","type":"create-entry","entityIds":[],"createdIds":["mx_01M305TTYFYNY85XFDEVFS6VXM"],"actor":{"kind":"system","id":"mex-migration"},"timestamp":"2026-01-01T00:00:00.000Z","files":["patterns/vote-behavior-change.md"],"payloadHash":"a541fa410e6a976cae8973438ee2919c83c4fb493fc51b61eaa7f0f068c04ea9","revisions":[{"entityId":"mx_01M305TTYFYNY85XFDEVFS6VXM","before":0,"after":1}]}
|
||||
{"v":1,"phase":"intent","opId":"mig_3055f0ee57741e4f76d11d3905d58cf2ea55677f","type":"add-relation","entityIds":["mx_01M305TTXGZN471W11VFPJQKQH"],"createdIds":[],"actor":{"kind":"system","id":"mex-migration"},"timestamp":"2026-01-01T00:00:00.000Z","files":["context/conventions.md"],"payloadHash":"798770ef1f12752d5cdb2faca4a6657b7bf31b8fc29a3e36cac7a985d231074e","revisions":[{"entityId":"mx_01M305TTXGZN471W11VFPJQKQH","before":1,"after":2}]}
|
||||
{"v":1,"phase":"complete","opId":"mig_3055f0ee57741e4f76d11d3905d58cf2ea55677f","type":"add-relation","entityIds":["mx_01M305TTXGZN471W11VFPJQKQH"],"createdIds":[],"actor":{"kind":"system","id":"mex-migration"},"timestamp":"2026-01-01T00:00:00.000Z","files":["context/conventions.md"],"payloadHash":"798770ef1f12752d5cdb2faca4a6657b7bf31b8fc29a3e36cac7a985d231074e","revisions":[{"entityId":"mx_01M305TTXGZN471W11VFPJQKQH","before":1,"after":2}]}
|
||||
{"v":1,"phase":"intent","opId":"mig_abd4b29a2420249bd155970f9fb94d516e380c85","type":"add-relation","entityIds":["mx_01M305TTXSX79T4GDEXNMFVB3E"],"createdIds":[],"actor":{"kind":"system","id":"mex-migration"},"timestamp":"2026-01-01T00:00:00.000Z","files":["context/setup.md"],"payloadHash":"270b121758752ad2995d9d210c5242c588bc042284f665f72e6012fcc3696cb9","revisions":[{"entityId":"mx_01M305TTXSX79T4GDEXNMFVB3E","before":1,"after":2}]}
|
||||
{"v":1,"phase":"complete","opId":"mig_abd4b29a2420249bd155970f9fb94d516e380c85","type":"add-relation","entityIds":["mx_01M305TTXSX79T4GDEXNMFVB3E"],"createdIds":[],"actor":{"kind":"system","id":"mex-migration"},"timestamp":"2026-01-01T00:00:00.000Z","files":["context/setup.md"],"payloadHash":"270b121758752ad2995d9d210c5242c588bc042284f665f72e6012fcc3696cb9","revisions":[{"entityId":"mx_01M305TTXSX79T4GDEXNMFVB3E","before":1,"after":2}]}
|
||||
{"v":1,"phase":"intent","opId":"inbox_spec_28f16fc8b55f00617b3be4060d5d15ec75e487295def23e21723ba6cc6dd571e","type":"create-entry","entityIds":[],"createdIds":["mx_01M31068M0WJ4KB0ZD6C6BXHZK"],"actor":{"kind":"human","id":"git:139ad6f2a86f1dbf589661a50db20426"},"timestamp":"2026-09-21T03:29:40.432Z","files":["context/decision-mx_01M31068M0WJ4KB0ZD6C6BXHZK.md"],"payloadHash":"742590c938f6a764a36367cb0b59c8654b6df1d4b7eb67b8d7eeb889a0c48875","revisions":[{"entityId":"mx_01M31068M0WJ4KB0ZD6C6BXHZK","before":0,"after":1}],"reason":"The user explicitly said guaranteed delivery will be added in the future while discussing new PR #1619 review comments. This separates current local queue reliability fixes from a future protocol design."}
|
||||
{"v":1,"phase":"complete","opId":"inbox_spec_28f16fc8b55f00617b3be4060d5d15ec75e487295def23e21723ba6cc6dd571e","type":"create-entry","entityIds":[],"createdIds":["mx_01M31068M0WJ4KB0ZD6C6BXHZK"],"actor":{"kind":"human","id":"git:139ad6f2a86f1dbf589661a50db20426"},"timestamp":"2026-09-21T03:29:40.432Z","files":["context/decision-mx_01M31068M0WJ4KB0ZD6C6BXHZK.md"],"payloadHash":"742590c938f6a764a36367cb0b59c8654b6df1d4b7eb67b8d7eeb889a0c48875","revisions":[{"entityId":"mx_01M31068M0WJ4KB0ZD6C6BXHZK","before":0,"after":1}],"reason":"The user explicitly said guaranteed delivery will be added in the future while discussing new PR #1619 review comments. This separates current local queue reliability fixes from a future protocol design."}
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
schema_version: 1
|
||||
id: "proposal_01M31040E5ECB5F57YHXYGX618"
|
||||
state: "approved"
|
||||
author: {"email":"17074231+BenCodez@users.noreply.github.com","kind":"git","name":"BenCodez"}
|
||||
rationale: "The user explicitly said guaranteed delivery will be added in the future while discussing new PR #1619 review comments. This separates current local queue reliability fixes from a future protocol design."
|
||||
evidence: [{"kind":"manual","note":"User message in this session: 'New comments, will add the guaranteed delivery in the future, please remember that'."}]
|
||||
request: {"expectedRevisions":[],"operation":{"opId":"inbox_spec_28f16fc8b55f00617b3be4060d5d15ec75e487295def23e21723ba6cc6dd571e","payload":{"change":{"body":"For VotingPlugin PR #1619, retain the existing bounded backend queue work but defer an end-to-end guaranteed proxy-to-backend vote delivery protocol to future work. A future design may need proxy retention, backend durable acknowledgement, retry, and restart-safe duplicate handling. Do not treat the current overflow queue or plugin-message send result as a guarantee that every vote will be delivered or each reward will execute exactly once.","entityKind":"decision","kind":"knowledge.create","status":"promoted","title":"Defer end-to-end proxy vote delivery guarantee"},"kind":"mex.team.inbox.knowledge-change.v1","schemaVersion":1},"type":"create-entry"}}
|
||||
target_revisions: []
|
||||
reviewer: {"email":"17074231+BenCodez@users.noreply.github.com","kind":"git","name":"BenCodez"}
|
||||
reviewed_at: "2026-09-21T03:29:40.432Z"
|
||||
---
|
||||
@@ -0,0 +1,8 @@
|
||||
# Pattern Index
|
||||
|
||||
Use these only when the task matches; routine edits need no pattern load.
|
||||
|
||||
| Pattern | Use when |
|
||||
| --- | --- |
|
||||
| [proxy-vote-change.md](proxy-vote-change.md) | Changing proxy/global vote routing or transport |
|
||||
| [vote-behavior-change.md](vote-behavior-change.md) | Changing Bukkit vote mutation, totals, points, or rewards |
|
||||
@@ -0,0 +1,5 @@
|
||||
# VotingPlugin patterns
|
||||
|
||||
Use `INDEX.md` to load a pattern only for a matching task. Patterns record project-specific traps; root `AGENTS.md` owns general development and review rules. Check each pattern against current source and tests before relying on it.
|
||||
|
||||
Add a pattern only when a repeated task has a verified, non-obvious failure mode worth preserving. Put unfinished work in a Relay and reviewable durable discoveries through Inbox.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: proxy-vote-change
|
||||
description: Trace and verify changes to proxy/global vote routing.
|
||||
triggers: [proxy routing, global vote, BungeeCord, Velocity]
|
||||
edges:
|
||||
- target: context/proxy-voting.md
|
||||
condition: when mapping proxy and backend ownership
|
||||
- target: context/vote-rewards.md
|
||||
condition: when a route changes reward delivery
|
||||
grounds_to: []
|
||||
last_updated: 2026-09-20
|
||||
mex:
|
||||
id: mx_01M305TTY6NXWZP72T00XY9ESP
|
||||
type: pattern
|
||||
status: promoted
|
||||
revision: 1
|
||||
title: proxy-vote-change
|
||||
---
|
||||
|
||||
# Proxy vote change
|
||||
|
||||
Trace the selected transport, proxy entry point, backend router, and Bukkit listener. Route selection happens upstream; `event.isForceBungee()` affects Bungee-aware reward and downstream processing in the listener. Check queued vote timestamps, server selection, and offline queues before treating a route as equivalent. See `context/proxy-voting.md` for the distinct online and origin facts; root `AGENTS.md` owns test/build workflow.
|
||||
|
||||
Source: `VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/`, `VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/`, `VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java`.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: vote-behavior-change
|
||||
description: Preserve production vote and reward behavior during changes.
|
||||
triggers: [vote listener, totals, points, offline reward, vote site, reward]
|
||||
edges:
|
||||
- target: context/vote-rewards.md
|
||||
condition: when mapping vote mutation and delivery
|
||||
- target: context/proxy-voting.md
|
||||
condition: when proxy state affects counting or rewards
|
||||
grounds_to: []
|
||||
last_updated: 2026-09-20
|
||||
mex:
|
||||
id: mx_01M305TTYFYNY85XFDEVFS6VXM
|
||||
type: pattern
|
||||
status: promoted
|
||||
revision: 1
|
||||
title: vote-behavior-change
|
||||
---
|
||||
|
||||
# Vote behavior change
|
||||
|
||||
Before moving the accepted-vote path, map side effects in order: vote party and broadcast, last-vote timestamp, reward delivery or offline queue, then totals/points and later events. A zero event timestamp is normalized for the stored last-vote time. Vote party has its own fake-vote setting. See `context/vote-rewards.md` for counting conditions; inspect sibling behavior in `VotingPluginUser`, vote sites, special rewards, and proxy routing. Root `AGENTS.md` owns test/build workflow.
|
||||
|
||||
Source: `VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java`, `VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java`, root `AGENTS.md`.
|
||||
@@ -4,6 +4,10 @@ VotingPlugin is the vote-processing data plane for Bukkit/Paper and BungeeCord/V
|
||||
integration is a management adapter, never a runtime dependency: vote receipt, routing, storage, rewards, joins, commands,
|
||||
reload, and shutdown must keep working when Control is disabled, unreachable, incompatible, or restarting.
|
||||
|
||||
## Security threat model
|
||||
|
||||
For security reviews, vulnerability triage, and security-sensitive changes, read `docs/security-threat-model.md` before classifying or fixing findings. Treat it as the repository-specific attacker/trust-boundary model; verify every conclusion against current code and tests. Do not promote compatibility, trusted-operator behavior, or generic correctness bugs into security findings unless the documented boundary is actually crossed.
|
||||
|
||||
## Build and verification
|
||||
|
||||
Requirements: JDK 21+ and Maven. The Maven project lives in the `VotingPlugin/` subdirectory.
|
||||
@@ -22,11 +26,44 @@ mvn -B -f VotingPlugin/pom.xml -Dtest=BackendControlConnectorProtocolTest,Contro
|
||||
CI runs `mvn -B -f VotingPlugin/pom.xml package`; see `.github/workflows/maven.yml`. Do not use the `dev` Maven profile in
|
||||
automation because it copies a JAR into a developer-specific server directory.
|
||||
|
||||
All project tests and test-only helpers belong under `VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/`, with package `com.bencodez.votingplugin.tests` or a meaningful subpackage. Keep paths and package declarations aligned, and update cross-test imports, resources, and build selectors when moving tests. Preserve package-phase artifact checks and test discovery. Do not widen production APIs to make tests accessible; use narrowly scoped test-only reflection for non-public seams. Platform API fixtures that must retain an external runtime class identity (such as `net.minecraft.network.chat.Component`) stay in that platform package and must not contain test cases.
|
||||
|
||||
Assign one owner to Maven validation in each worktree. Never run concurrent Maven commands in the same worktree, including focused tests alongside a clean/package build: compilation and cleanup share `target/` and can invalidate each other. Reviewers must inspect source only while that owner builds, or run validation in a separate worktree.
|
||||
|
||||
Keep the downloadable VotingPlugin JAR as small as practical. The 10 MiB size target is advisory:
|
||||
report a prominent package/build warning when exceeded, never fail a build solely for artifact size.
|
||||
Keep required-resource, linkage, and packaging-safety checks enforced. Inspect the shaded
|
||||
artifact when dependencies change, avoid duplicate embedded packages, and update
|
||||
the package-phase size and runtime checks when a necessary dependency increases
|
||||
the artifact budget.
|
||||
|
||||
## Drop-in upgrade and compatibility contract
|
||||
|
||||
Treat compatibility as a release invariant for every new feature, refactor, fix, storage change, protocol change, and dependency change. Unless the task explicitly says otherwise, a VotingPlugin upgrade must be a **drop-in JAR replacement**: an administrator replaces the existing VotingPlugin JAR, starts/reloads as normally supported, and does not need any other deployment change.
|
||||
|
||||
That default contract means:
|
||||
|
||||
- Do not require manual edits, regenerated configs, deleted keys, renamed files, data resets, one-off conversion commands/scripts, or manual database changes. New keys must have safe defaults when absent and preserve established behavior.
|
||||
- Existing YAML, vote-site definitions, rewards, user data, vote totals/streaks, cached/offline votes, logged data, and supported database state must continue to load. Required migrations must be automatic, idempotent, restart-safe, and preserve existing state.
|
||||
- Do not require a simultaneous update of AdvancedCore, SimpleAPI, Votifier/VotifierPlus, PlaceholderAPI, VotingPlugin-Control, a proxy JAR, backend JARs, or every server in a network merely to keep previously working behavior working. New cross-component behavior must be additive/capability-negotiated with safe fallback for older peers.
|
||||
- Preserve existing commands, permissions, placeholders, events, public/de-facto APIs, configuration semantics, proxy methods, message/reward behavior, and supported platform behavior unless the request explicitly authorizes a break.
|
||||
- Mixed-version proxy/backend deployments must fail safe and retain legacy behavior for features not mutually supported; do not make upgrade order a hidden requirement.
|
||||
- New optional integrations and features must default to non-disruptive behavior for existing installations and must not become mandatory runtime dependencies.
|
||||
- Packaging changes must preserve the normal downloadable artifact and startup path. Do not make administrators install extra libraries or companion JARs for an upgrade unless explicitly requested.
|
||||
- When a requested implementation cannot preserve drop-in compatibility, stop and clearly surface the required compatibility break before implementing it unless the request explicitly permits that break.
|
||||
|
||||
For compatibility-sensitive changes, add regression coverage that exercises the prior installation/state or protocol shape as well as the new behavior. Review the final diff from the perspective of an administrator upgrading only the VotingPlugin JAR on an existing installation.
|
||||
|
||||
## Architecture and file map
|
||||
|
||||
- `VotingPluginMain` is the Bukkit entry point and lifecycle owner.
|
||||
- `com.bencodez.votingplugin.core` and its subpackages are platform-independent. Classes there must not import Bukkit, Paper, Fabric, Forge, or NeoForge APIs. Keep loader-specific adapters outside this package and enforce the boundary with `CorePlatformIsolationTest`.
|
||||
- `proxy/VotingPluginProxy` and the Bungee/Velocity platform packages own proxy lifecycle and vote routing.
|
||||
- `listeners/` receives Bukkit-side vote/player events; `proxy/cache/` owns proxy pending-vote queues.
|
||||
- `commands/CommandLoader` captures `VoteCommandEnabled`, `VoteCommandName` and `LoadCommandAliases` at startup.
|
||||
Configuration reload must not change the registered player root or generated help/navigation. Preserve exact command
|
||||
ownership, admin roots/aliases and canonical `VotingPlugin.Commands.Vote.*` permissions when disabling or renaming;
|
||||
standalone player aliases retain their `vote...`/`v...` identities. See `docs/player-command-root.md`.
|
||||
- `votesites/` resolves configured service names. Be alert to the distinction between read-only resolution and paths that
|
||||
may auto-create a site.
|
||||
- `user/` owns player totals, points, streaks, last-vote values, and backend offline rewards.
|
||||
@@ -42,6 +79,16 @@ automation because it copies a JAR into a developer-specific server directory.
|
||||
- `VotingPlugin/src/main/resources/` contains the default Bukkit and proxy configuration.
|
||||
- `docs/control-connector.md` explains deployment; `docs/control-agent-contract.md` is the exact agent/client contract.
|
||||
|
||||
## Threading and user-data invariants
|
||||
|
||||
- Never call user-storage APIs from Bukkit/Paper/Folia main, tick, region, or entity server threads, directly or indirectly. This includes reads, writes, SQL, cache population/refill, flush/dump/clear/remove, and storage-backed user getters, even when a caller expects a cache hit. Capture only platform-owned input on the server thread, submit storage work to the existing persistence worker, and dispatch required platform effects back to the correct owner scheduler.
|
||||
- Preserve call order for dependent mutations of one user through the same ordered storage worker, including legacy point mutations invoked on a platform-owned thread. Test with the worker deliberately blocked so later inline work cannot overtake queued work. Never preserve synchronous behavior by waiting for SQL on the server thread.
|
||||
- Task admission and cached predictions are not committed results. Rewards, purchases, transfers, accounting-dependent decisions, and success messages must use confirmed completion; document the return semantics of legacy deferred APIs.
|
||||
- When changing a mutation, inspect the entire operation family and every overload/caller: add, set, remove, cap, transfer, bulk, recovery, and synchronous/asynchronous paths. Fixing one overload must not leave sibling paths with inconsistent ordering, thread affinity, or completion semantics.
|
||||
|
||||
- Treat AdvancedCore/VotingPlugin user-data, cache, and storage APIs as potentially blocking unless an API is explicitly documented as snapshot-only. Do not perform cache population, SQL-backed reads or writes, flush/dump/clear/remove operations, or shared-runtime admission on the Bukkit/Paper primary server thread. Capture platform-owned state there, hand user-data work to the existing persistence/storage worker, and schedule only the required Bukkit/Folia interaction back onto the platform owner.
|
||||
- Preserve the shared-user lock order: shared-runtime/per-user admission before the `UserDataCache` monitor. Never hold `synchronized (UserDataCache)` while calling APIs that can acquire shared-runtime admission, including `dump()`, `clearCache()`, `removeCache()`, cache population, or storage access. Keep cache-monitor sections short and cache-local.
|
||||
|
||||
## Runtime and security invariants
|
||||
|
||||
1. Control connectors initiate outbound HTTP(S); do not add an inbound admin port to VotingPlugin.
|
||||
@@ -76,8 +123,8 @@ automation because it copies a JAR into a developer-specific server directory.
|
||||
Keep these paths separate:
|
||||
|
||||
- discovery/presence advertises current node identity and topology;
|
||||
- configuration capabilities (`config.*.v1`) poll `/operations`, may read/preview/apply typed configuration, and journal
|
||||
results;
|
||||
- configuration capabilities (`config.*.v1` plus explicitly negotiated successors) poll `/operations`, may
|
||||
read/preview/apply typed configuration, and journal results;
|
||||
- inspection capability `data.inspect.v1` polls `/inspections`, executes only `ControlInspectionService`, and does not
|
||||
journal because a lost acknowledgement can safely repeat a read. Repeated failures back this lane off exponentially
|
||||
from one second to five minutes without changing voting or configuration availability.
|
||||
@@ -146,6 +193,117 @@ full, case-normalized ServiceSite (up to the 2048-character validator bound); tr
|
||||
and classify unmatched logged services against every configured site rather than only the displayed page. Do not accept
|
||||
raw SQL from Control or expose the database/table configuration.
|
||||
|
||||
## Proxy vote lifecycle and durability invariants
|
||||
|
||||
Treat every proxy-side Votifier event as durable work as soon as VotingPlugin accepts it. A Java stack frame, scheduled
|
||||
task, executor queue, or platform scheduler entry is **not** durable ownership.
|
||||
|
||||
- Before any fallible or cancellable scheduler/executor handoff, transfer the accepted vote into VotingPlugin-owned state.
|
||||
At every point afterward, the vote must be either: (a) actively processing against a runtime protected from teardown,
|
||||
(b) present in a bounded process-owned pending queue, or (c) durably persisted for restart recovery. There must be no
|
||||
gap where scheduler admission is the only thing preventing loss.
|
||||
- Scheduler callbacks are wakeups, not owners. Rejection, plugin disable, proxy shutdown, `shutdownNow()`, task
|
||||
cancellation, executor replacement, or a platform scheduler refusing work must leave the vote owned in pending/durable
|
||||
state with the same vote ID.
|
||||
- Preserve one stable `voteId` from Votifier acceptance through reload waiting, storage retry, durable handoff, restart,
|
||||
backend delivery, reward execution, and completion fencing. Never generate a replacement ID merely because an attempt
|
||||
was rescheduled or recovered.
|
||||
- Runtime lifecycle transitions must have explicit semantics:
|
||||
- operational: votes may enter the protected runtime;
|
||||
- reloading/replacing: accepted votes wait in owned pending state and do not consume storage-failure retry budget merely
|
||||
for waiting on lifecycle replacement;
|
||||
- terminal/unavailable: do not invoke the broken/disposed runtime; hand pending work to durable recovery where possible
|
||||
and emit an explicit diagnostic if persistence fails.
|
||||
- Publish a replacement runtime as operational only after required transport state, caches/storage, periodic tasks, and
|
||||
required Votifier listener registration are ready. Publish readiness before clearing the reload fence. Never expose a
|
||||
transient `reloading=false && runtimeOperational=false` state on an otherwise successful replacement that could cause
|
||||
a vote to be mistaken for terminal failure.
|
||||
- Protect runtime selection and teardown with the same lifecycle fence. A vote that has selected a runtime must not race
|
||||
teardown of that runtime. Conversely, teardown must not proceed while accepted process-owned votes have no durable
|
||||
recovery path.
|
||||
- Before retiring the old runtime during a full replacement, persist or otherwise durably transfer all accepted pending
|
||||
votes. If that transfer fails while the predecessor can still safely run, abort replacement and keep the predecessor.
|
||||
Final shutdown may continue only with an explicit reconciliation/error signal for any work that could not be persisted.
|
||||
- Do not remove a pending vote merely because a retry was scheduled. Remove process-owned state only after the vote is
|
||||
safely processed/completed or a durable recovery record has been confirmed. Scheduling success does not imply future
|
||||
execution; a subsequent shutdown/cancellation can still prevent the callback from running.
|
||||
- For graceful lifecycle, retry, scheduler, reload, shutdown-handoff, and restart-replay paths, preserve exactly-once
|
||||
vote effects across totals, points, streaks, VoteParty, rewards, broadcasts, backend delivery, and completion
|
||||
acknowledgement. Stable vote IDs and durable completion fences must prevent duplicates caused by those recoverable
|
||||
paths. Do not claim universal exactly-once semantics for arbitrary external side effects: if a reward/command succeeds
|
||||
and the process hard-crashes before its completion record is durably persisted, recovery may repeat that effect. Treat
|
||||
that as the documented hard-crash transaction boundary, not as an acceptable graceful-lifecycle loss/duplication path.
|
||||
- Bungee/Waterfall and Velocity must have equivalent lifecycle guarantees. A fix on one proxy platform is incomplete until
|
||||
the other platform is audited and either changed or explicitly proven safe with tests.
|
||||
- Plugin-message queues and vote queues are separate durability concerns. Reload-abort and retained-runtime paths must
|
||||
drain/retry queued messages against the still-valid runtime; do not strand them until an unrelated future reload.
|
||||
- Distinguish graceful lifecycle guarantees from unavoidable hard-crash boundaries. Do not claim graceful reload/shutdown
|
||||
safety if accepted work can still disappear or duplicate through scheduler rejection, cancellation, retry, or restart
|
||||
replay. Separately document any remaining process-crash boundary where an external side effect can occur before its
|
||||
durable completion record; that boundary must not be misrepresented as universally exactly-once.
|
||||
|
||||
For any change touching proxy vote receipt, `VoteEventBungee`, `VoteEventVelocity`, `VotingPluginBungee`,
|
||||
`VotingPluginVelocity`, `VotingPluginProxy.vote(...)`, proxy schedulers/executors, pending vote/cache state, transport
|
||||
handoff, reload, runtime replacement, or shutdown, the review must explicitly trace and test these interleavings:
|
||||
|
||||
1. vote arrives immediately before reload starts;
|
||||
2. vote arrives after the reload fence is raised but before predecessor retirement;
|
||||
3. vote is accepted while replacement is in progress;
|
||||
4. scheduler/executor rejects the wakeup after the vote is accepted;
|
||||
5. scheduler accepts the wakeup, then shutdown/cancellation occurs before it runs;
|
||||
6. replacement preparation fails while the predecessor is still safe;
|
||||
7. replacement fails after predecessor retirement;
|
||||
8. successful replacement drains pending votes exactly once;
|
||||
9. soft reload fails and a later successful soft reload restores readiness;
|
||||
10. graceful shutdown persists pending accepted votes before stopping scheduling infrastructure;
|
||||
11. restart replays durable pending votes with the original vote ID and does not replay them again after completion;
|
||||
12. multiple concurrent pending votes remain independently owned and cannot overwrite or collapse into one another.
|
||||
|
||||
Use deterministic tests with barriers, latches, fake/rejecting schedulers, or explicit lifecycle seams. Do not rely on sleeps
|
||||
or timing luck to prove race safety. A green build without these interleaving checks is not sufficient evidence for a
|
||||
proxy lifecycle/vote-durability change.
|
||||
|
||||
For transport-selection changes, additionally prove that generic reward-journal/cache IDs do not fabricate transport
|
||||
provenance. Genuine retained HTTP work must survive handoff; cached reward IDs from a non-HTTP installation must not force
|
||||
HTTP startup. Corrupt/missing retained listener state, parked HTTP queues, failed retained HTTP startup, undeletable
|
||||
retained state, and fallback to the configured transport must be covered without requiring manual cache/config cleanup.
|
||||
|
||||
## Change and PR workflow
|
||||
|
||||
Before implementing a proposed change, verify that the problem still exists on current master and identify the affected behavior, supporting evidence, and intended benefit. Check merged fixes and overlapping open PRs. If the change appears unnecessary, superseded, or counterproductive, explain the evidence and ask the user before implementation; do not spend usage implementing a questionable premise.
|
||||
|
||||
Validate reviewer suggestions and historical/MEX claims against current source and tests. Reproduce or trace the reachable failure before fixing it; a reviewer recommendation is evidence to investigate, not an instruction to follow blindly. Verify dependency/API contracts against the actual version used, including threading, return values, ownership, and lifecycle behavior; timestamps or method names alone are not proof of the contract.
|
||||
|
||||
Prefer the smallest complete root-cause fix, covering affected sibling paths without unrelated refactors or speculative abstractions. State the guarantee boundary explicitly: predicted, queued, persisted, delivered, and completed are different outcomes, and external effects are not automatically transactional or exactly once.
|
||||
|
||||
Keep changes focused and avoid unrelated formatting. Before any commit, push, PR update, review reply, or other remote change:
|
||||
|
||||
1. run relevant focused tests;
|
||||
2. run `mvn -B -f VotingPlugin/pom.xml clean package`;
|
||||
3. verify the package invocation produced a fresh downloadable JAR and that expected tests were discovered;
|
||||
4. run `git diff --cached --check` and `git diff --check`;
|
||||
5. inspect `git diff --cached` and `git diff`, then inspect the complete base-to-HEAD diff for compatibility, concurrency, persistence, lifecycle, security, packaging, and platform regressions.
|
||||
|
||||
Steps 1-3 may be skipped only for documentation/instruction-only changes that do not modify executable source, tests, build or dependency configuration, workflows, packaged resources, generated output, or runtime/deployment behavior. Record that exemption in the PR. Steps 4-5 and the review requirements below still apply.
|
||||
|
||||
For substantive work, obtain a fresh source-read-only `$code-review` of the exact intended change before the first push or PR update. The implementation agent verifies and fixes accepted findings, reruns all required checks, and obtains a new review of the updated snapshot. Any substantive repository change after a clean review—including source, tests, build or dependency configuration, workflow files, resources, contracts, documentation, or instructions—invalidates the previous clean verdict. Rerun applicable validation and obtain a fresh review of the exact intended snapshot; do not reuse an earlier verdict. Hosted PR review is confirmation, not the first full review, and merge still requires explicit authorization.
|
||||
|
||||
Before reporting review-comment status, inspect inline review threads, review bodies (including outside-diff findings), and PR conversation comments. Verify each finding against the current head; distinguish addressed/stale findings from unresolved defects. An empty unresolved-thread list alone does not establish that all comments are addressed.
|
||||
|
||||
Obtain explicit user authorization before posting GitHub comments, including review replies, status summaries, and reviewer-bot triggers. Permission to fix code, push commits, edit a PR description, or create a PR does not by itself authorize posting comments.
|
||||
|
||||
Report readiness for the exact commit that was validated and reviewed. Record the head SHA, focused/full test and artifact results, CI state, dependency publication requirements, and any applicable integration suite/artifact SHA. State exactly what tests exercised and what remains unverified; never generalize a mocked/focused result into untested production, platform, or end-to-end coverage. Keep local validation, hosted CI, and integration evidence separate; pending/blocked checks are not passes, an accepted run request is not a passing test, and old-head results do not validate changed code. Do not call a PR fully ready while required checks or known functional findings remain unresolved.
|
||||
|
||||
Do not commit server runtime data, credentials, generated JARs, dependency caches, IDE output, or unrelated formatting.
|
||||
|
||||
## Network Doctor diagnostics
|
||||
|
||||
`network-health` requires both `data.inspect.v1` and `data.network-health.v1`. It is read-only, bounded, optional, and
|
||||
compatible with unsupported peers in either deployment order. Omit unobserved facts; never infer runtime initialization
|
||||
from persisted settings or adapter presence alone. Do not return raw configuration, logs, endpoints, credentials, keys or
|
||||
certificates. Backend Votifier plus TriggerVotifierEvent is valid synthetic-event compatibility; proxy VotifierPlus socket
|
||||
forwarding alongside VotingPlugin delivery is a separate duplicate risk. See the connector/agent contract docs.
|
||||
|
||||
## Paired change and PR workflow
|
||||
|
||||
The server-side peer is `BenCodez/VotingPlugin-Control`. When changing a DTO, endpoint, capability, preset, error code, or
|
||||
@@ -160,15 +318,46 @@ limit:
|
||||
Prefer one cohesive PR per repository for a paired feature, keeping its implementation, tests, and docs together. Split
|
||||
further only when a part is independently deployable or has materially different review/rollback risk.
|
||||
|
||||
Before pushing, run the focused tests, the full Maven build, and `git diff --check`. Do not commit server runtime data,
|
||||
credentials, generated JARs, dependency caches, IDE output, or unrelated formatting.
|
||||
`config.proxy-method.v1` covers plugin messaging, Redis, MQTT, sockets, and MySQL; `config.proxy-method.v2` adds HTTP. Dispatch and validate the
|
||||
exact capability for the requested method. `config.quick-setup.v2` adds `VoteParty.Enabled`; keep legacy Vote Party
|
||||
payloads on v1, preserve the installed Enabled value when they omit it, and reject the `enabled` field unless v2 was
|
||||
accepted. The VotingPlugin connector may deploy first and
|
||||
advertise these successors without using them until Control accepts them. A newer Control deployed first must leave its
|
||||
v2-only actions unavailable on older nodes. Merge the VotingPlugin capability implementation before relying on the new
|
||||
Control behavior in production.
|
||||
|
||||
## Safe change checklist
|
||||
|
||||
- Trace whether the code runs on the connector worker, proxy thread, Bukkit primary thread, or a SQL executor.
|
||||
- Preserve queued votes across saturation, reload, runtime replacement, scheduler rejection/cancellation, shutdown, and restart; overflow handling must be bounded, durable when promised, and observable rather than silently dropping work. Scheduler admission is never proof of durable ownership.
|
||||
- Proxy-to-backend guaranteed delivery is capability negotiated and at least once. Journal a reward-bearing envelope before
|
||||
reporting transport acceptance, retain it until the matching backend completion acknowledgement is durable, persist
|
||||
completed IDs before acknowledgement for restart-safe deduplication, retire receipts only through the durable
|
||||
proxy-confirmed release handshake, retain a bounded durable tombstone for in-flight retries, and keep legacy send
|
||||
behavior for backends that do not advertise the capability.
|
||||
- Treat scheduler units explicitly. Verify whether each delay is in ticks, milliseconds, or seconds, especially across Bukkit, Folia, BungeeCord, and Velocity adapters.
|
||||
- Register listeners and lifecycle wakeups before producers can publish work; startup/reload ordering must not strand already-persisted or newly-arriving operations. For proxy Votifier paths, runtime readiness is not published until listener registration succeeds, and reload waiting must retain the vote independently of the scheduler.
|
||||
- Protocol-mode changes must not silently broaden legacy v1/RSA acceptance when token-only operation is configured or intended; cover downgrade behavior with tests.
|
||||
- Add strict type/field/range/count validation before calling plugin services.
|
||||
- Snapshot synchronized live collections before iterating; do not return mutable collections across threads.
|
||||
- Distinguish “not configured/unavailable”, “not found”, and a genuine empty result.
|
||||
- Test unknown fields, invalid bounds, disabled VoteLogging, oversized results, exact-player misses, non-creating resolution,
|
||||
reward no-side-effects, lease retry/idempotency, and redaction as applicable.
|
||||
- Preserve connector shutdown bounds and avoid blocking waits on Bukkit lifecycle paths.
|
||||
- Exercise rejected task admission, accepted-but-cancelled work, shutdown, and failed storage using deterministic fakes/latches. Preserve pending/durable ownership where the operation promises recovery; otherwise expose an explicit failure. Never silently report an unperformed mutation as successful or treat scheduling as persistence.
|
||||
|
||||
<!-- mex-agent:skills:start -->
|
||||
## MEX agent skills
|
||||
- At the start of every session, read `.mex/AGENTS.md` and `.mex/ROUTER.md` before project work; follow `ROUTER.md` to load only the relevant context.
|
||||
- Read `mex logging --json` at session start and before optional logging. Its checkout-local advisory mode is `significant` (quiet default: material decisions, risks, blockers, or durable discoveries), `checkpoints` (batch useful notes at task/session boundaries), or `manual` (no unsolicited notes). Skip routine tool calls, edits, repeated status, and empty summaries. Honor explicit user log requests in every mode; never suppress mandatory workflow Activity or recovery audit records. Report a policy read failure instead of guessing or changing the preference.
|
||||
- When earlier work may inform the task, retrieve bounded relevant notes with `mex timeline --query "subject phrase" --file src/example.ts --limit 10 --json`, using the known subject or exact recorded file path, or both. Treat matches as historical evidence, not accepted current knowledge; verify conclusions before reuse or explicit promotion with their source retained.
|
||||
- Use `$mex-inbox` for explicit contributions to project knowledge and `$mex-relay` for durable team handoffs. Invoke them automatically when intent clearly matches; ordinary GROW upkeep remains available without Inbox.
|
||||
- When MEX context materially helps your work, mention MEX and the relevant finding naturally in your explanation. Tie the mention to what it helped you understand, decide, or verify. Avoid fixed phrases, standalone acknowledgements, repeated mentions, or narrating routine context loading. This replaces older MEX instructions requiring a fixed acknowledgement or context-loading narration.
|
||||
- Do not claim an author, date, or historical event unless the retrieved data actually provides it.
|
||||
- After a MEX write, say exactly what changed and its sharing boundary: a local draft is checkout-only and nothing is shared; a canonical artifact is written to the working tree and requires commit/push to share.
|
||||
- Skill activation is not approval for canonical actions.
|
||||
<!-- mex-agent:skills:end -->
|
||||
|
||||
## Guided proxy session confirmation
|
||||
|
||||
Guided sessions never award votes or rewards. Proxy confirmation requires a bounded versioned session-fence reply matching the player, backend and random session nonce; keep the first fence for each source. New original proxy ingress assigns an opaque UUIDv8 before asynchronous handoff, with random source incarnation and monotonically increasing sequence. Preserve that canonical ID through every journal, retry, cache and multi-proxy hop; forwarding or replay must never mint new occurrence order. Do not compare proxy clocks with backend clocks or infer freshness from delivery/queue flags. New post-fence delayed/queued votes can confirm only through the existing credited pipeline; older, legacy, fake/test and unsupported-source occurrences remain unconfirmed without changing normal accounting. Unknown peers must preserve ordinary voting. No cache schema migration or new reward path.
|
||||
@@ -52,3 +52,7 @@ or disabling it; inspections gate disabled state immediately and report a newly
|
||||
```
|
||||
|
||||
`LATEST` resolves to the latest stable release.
|
||||
|
||||
## Guided voting session
|
||||
|
||||
Optional `/vote session` chat navigation tracks real accepted votes without changing existing menus or rewards. See [configuration, controls and timing limits](docs/guided-voting-session.md).
|
||||
+208
-24
@@ -4,17 +4,19 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.bencodez</groupId>
|
||||
<artifactId>votingplugin</artifactId>
|
||||
<version>7.1.2-SNAPSHOT</version>
|
||||
<version>7.2-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
<name>VotingPlugin</name>
|
||||
<properties>
|
||||
<github.global.server>github</github.global.server>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<build.number>NOTSET</build.number>
|
||||
<packaged.jar.name>${project.name}</packaged.jar.name>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<velocity.version>3.4.0</velocity.version>
|
||||
<neoforge.version>21.1.211</neoforge.version>
|
||||
</properties>
|
||||
<build>
|
||||
<resources>
|
||||
@@ -25,6 +27,7 @@
|
||||
<include>plugin.yml</include>
|
||||
<include>bungee.yml</include>
|
||||
<include>votingpluginversion.yml</include>
|
||||
<include>META-INF/neoforge.mods.toml</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
@@ -34,6 +37,7 @@
|
||||
<exclude>plugin.yml</exclude>
|
||||
<exclude>bungee.yml</exclude>
|
||||
<exclude>votingpluginversion.yml</exclude>
|
||||
<exclude>META-INF/neoforge.mods.toml</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
@@ -68,22 +72,17 @@
|
||||
</plugins>
|
||||
</pluginManagement>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.4</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
<version>3.16.0</version>
|
||||
<configuration>
|
||||
<release>21</release>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.42</version>
|
||||
<version>1.18.48</version>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>com.velocitypowered</groupId>
|
||||
@@ -177,6 +176,10 @@
|
||||
<pattern>org.yaml.snakeyaml</pattern>
|
||||
<shadedPattern>${project.groupId}.votingplugin.snakeyaml</shadedPattern>
|
||||
</relocation>
|
||||
<relocation>
|
||||
<pattern>org.checkerframework</pattern>
|
||||
<shadedPattern>${project.groupId}.votingplugin.checkerframework</shadedPattern>
|
||||
</relocation>
|
||||
</relocations>
|
||||
</configuration>
|
||||
<executions>
|
||||
@@ -192,14 +195,64 @@
|
||||
code in it (guava, etc) -->
|
||||
<excludes>
|
||||
<exclude>com.google.*:*</exclude>
|
||||
<!-- Server platforms provide SLF4J; embedding its package conflicts with NeoForge's module layer. -->
|
||||
<exclude>org.slf4j:*</exclude>
|
||||
<!-- SimpleAPI's JDK-only TLS identity does not need an external provider. -->
|
||||
<exclude>org.bouncycastle:*</exclude>
|
||||
</excludes>
|
||||
</artifactSet>
|
||||
<minimizeJar>false</minimizeJar>
|
||||
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||
<filters>
|
||||
<!-- Keep this PR buildable against both the current published
|
||||
AdvancedCore SNAPSHOT and its coordinated slimmer successor. -->
|
||||
<filter>
|
||||
<artifact>com.bencodez:advancedcore</artifact>
|
||||
<excludes>
|
||||
<exclude>org/bouncycastle/**</exclude>
|
||||
<exclude>com/bencodez/simpleapi/servercomm/http/**</exclude>
|
||||
<exclude>redis/clients/**</exclude>
|
||||
<exclude>org/eclipse/paho/**</exclude>
|
||||
<exclude>META-INF/versions/**</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>com.bencodez:simpleapi</artifact>
|
||||
<excludes>
|
||||
<exclude>org/bouncycastle/**</exclude>
|
||||
<exclude>redis/clients/**</exclude>
|
||||
<exclude>org/eclipse/paho/**</exclude>
|
||||
<exclude>META-INF/versions/**</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>org.xerial:sqlite-jdbc</artifact>
|
||||
<excludes>
|
||||
<!-- Keep the common Linux x86_64 native for offline startup. Other
|
||||
targets are fetched with a pinned digest only when SQLite is selected. -->
|
||||
<exclude>org/sqlite/native/FreeBSD/**</exclude>
|
||||
<exclude>org/sqlite/native/Linux-Musl/**</exclude>
|
||||
<exclude>org/sqlite/native/Linux/aarch64/**</exclude>
|
||||
<exclude>org/sqlite/native/Linux/arm/**</exclude>
|
||||
<exclude>org/sqlite/native/Linux/armv6/**</exclude>
|
||||
<exclude>org/sqlite/native/Linux/armv7/**</exclude>
|
||||
<exclude>org/sqlite/native/Linux/ppc64/**</exclude>
|
||||
<exclude>org/sqlite/native/Linux/riscv64/**</exclude>
|
||||
<exclude>org/sqlite/native/Linux/x86/**</exclude>
|
||||
<exclude>org/sqlite/native/Mac/**</exclude>
|
||||
<exclude>org/sqlite/native/Windows/**</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<!-- AdvancedCore and SimpleAPI also embed these classes. -->
|
||||
<exclude>org/slf4j/**</exclude>
|
||||
<exclude>org/checkerframework/**</exclude>
|
||||
<exclude>META-INF/services/org.slf4j.spi.SLF4JServiceProvider</exclude>
|
||||
<!-- Keep non-POM runtime metadata such as sqlite-jdbc's VERSION fallback. -->
|
||||
<exclude>META-INF/maven/**/pom.xml</exclude>
|
||||
<exclude>META-INF/maven/**/pom.properties</exclude>
|
||||
<exclude>META-INF/*.SF</exclude>
|
||||
<exclude>META-INF/*.DSA</exclude>
|
||||
<exclude>META-INF/*.RSA</exclude>
|
||||
@@ -215,6 +268,33 @@
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.4</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>default-test</id>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>**/PackagedArtifactTest.java</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>packaged-artifact-test</id>
|
||||
<phase>package</phase>
|
||||
<goals><goal>test</goal></goals>
|
||||
<configuration>
|
||||
<includes><include>**/PackagedArtifactTest.java</include></includes>
|
||||
<reportsDirectory>${project.build.directory}/packaged-artifact-reports</reportsDirectory>
|
||||
<systemPropertyVariables>
|
||||
<votingplugin.packagedJar>${project.build.directory}/${packaged.jar.name}.jar</votingplugin.packagedJar>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<repositories>
|
||||
@@ -225,6 +305,10 @@
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>neoforge</id>
|
||||
<url>https://maven.neoforged.net/releases</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>spigot-repo</id>
|
||||
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
|
||||
@@ -267,6 +351,52 @@
|
||||
</repository>
|
||||
</repositories>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>net.neoforged</groupId>
|
||||
<artifactId>neoforge</artifactId>
|
||||
<version>${neoforge.version}</version>
|
||||
<classifier>universal</classifier>
|
||||
<scope>provided</scope>
|
||||
<exclusions>
|
||||
<exclusion><groupId>*</groupId><artifactId>*</artifactId></exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.neoforged.fancymodloader</groupId>
|
||||
<artifactId>loader</artifactId>
|
||||
<version>4.0.42</version>
|
||||
<scope>provided</scope>
|
||||
<exclusions>
|
||||
<exclusion><groupId>*</groupId><artifactId>*</artifactId></exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.neoforged</groupId>
|
||||
<artifactId>bus</artifactId>
|
||||
<version>8.0.5</version>
|
||||
<scope>provided</scope>
|
||||
<exclusions>
|
||||
<exclusion><groupId>*</groupId><artifactId>*</artifactId></exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.neoforged</groupId>
|
||||
<artifactId>mergetool</artifactId>
|
||||
<version>2.0.7</version>
|
||||
<classifier>api</classifier>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.xerial</groupId>
|
||||
<artifactId>sqlite-jdbc</artifactId>
|
||||
<version>3.53.4.0</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.checkerframework</groupId>
|
||||
<artifactId>checker-qual</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<!-- Must precede Spigot, which embeds an older SnakeYAML API on its provided compile path. -->
|
||||
<dependency>
|
||||
<groupId>org.yaml</groupId>
|
||||
@@ -276,7 +406,7 @@
|
||||
<dependency>
|
||||
<groupId>org.spigotmc</groupId>
|
||||
<artifactId>spigot-api</artifactId>
|
||||
<version>26.2-R0.1-SNAPSHOT</version>
|
||||
<version>26.3-R0.1-SNAPSHOT</version>
|
||||
<scope>provided</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
@@ -288,7 +418,13 @@
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.42</version>
|
||||
<version>1.18.48</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.code.gson</groupId>
|
||||
<artifactId>gson</artifactId>
|
||||
<version>2.14.0</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -319,6 +455,10 @@
|
||||
<groupId>com.velocitypowered</groupId>
|
||||
<artifactId>velocity-brigadier</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>org.checkerframework</groupId>
|
||||
<artifactId>checker-qual</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -327,10 +467,20 @@
|
||||
<version>3.2.1</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.paho</groupId>
|
||||
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
|
||||
<version>1.2.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>redis.clients</groupId>
|
||||
<artifactId>jedis</artifactId>
|
||||
<version>8.0.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>me.clip</groupId>
|
||||
<artifactId>placeholderapi</artifactId>
|
||||
<version>2.12.2</version>
|
||||
<version>2.12.3</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -338,35 +488,68 @@
|
||||
<artifactId>advancedcore</artifactId>
|
||||
<version>3.8.2-SNAPSHOT</version>
|
||||
<scope>compile</scope>
|
||||
<!-- AdvancedCore already embeds its relocated Rhino implementation. -->
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.mozilla</groupId>
|
||||
<artifactId>rhino</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcpkix-jdk18on</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk18on</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcutil-jdk18on</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>org.eclipse.paho</groupId>
|
||||
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>redis.clients</groupId>
|
||||
<artifactId>jedis</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.bencodez</groupId>
|
||||
<artifactId>simpleapi</artifactId>
|
||||
<version>1.0.2-SNAPSHOT</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-engine</artifactId>
|
||||
<version>5.12.2</version>
|
||||
<version>6.1.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>5.21.0</version>
|
||||
<version>5.24.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
<version>5.12.2</version>
|
||||
<version>6.1.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<version>5.23.0</version>
|
||||
<version>5.24.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.discordsrv</groupId>
|
||||
<artifactId>discordsrv</artifactId>
|
||||
<version>1.30.4</version>
|
||||
<version>1.30.5</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@@ -399,7 +582,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<version>3.5.1</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifestEntries>
|
||||
@@ -416,6 +599,7 @@
|
||||
<id>deploy-snapshot</id>
|
||||
<properties>
|
||||
<build.profile.id>dev-snapshot</build.profile.id>
|
||||
<packaged.jar.name>${project.name}-${build.number}</packaged.jar.name>
|
||||
</properties>
|
||||
<distributionManagement>
|
||||
<snapshotRepository>
|
||||
@@ -432,7 +616,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<version>3.5.1</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifestEntries>
|
||||
@@ -466,7 +650,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-deploy-plugin</artifactId>
|
||||
<version>3.1.4</version>
|
||||
<version>3.2.0</version>
|
||||
<configuration>
|
||||
<skip>true</skip>
|
||||
</configuration>
|
||||
@@ -510,7 +694,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<version>3.5.1</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifestEntries>
|
||||
@@ -573,7 +757,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<version>3.5.1</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifestEntries>
|
||||
@@ -585,7 +769,7 @@
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-resources-plugin</artifactId>
|
||||
<version>3.4.0</version>
|
||||
<version>3.5.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>copy-resources</id>
|
||||
@@ -632,7 +816,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<version>3.5.1</version>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifestEntries>
|
||||
@@ -683,7 +867,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-deploy-plugin</artifactId>
|
||||
<version>3.1.4</version>
|
||||
<version>3.2.0</version>
|
||||
<configuration>
|
||||
<skip>true</skip>
|
||||
</configuration>
|
||||
|
||||
@@ -24,8 +24,7 @@ public class VotingPluginHooks {
|
||||
|
||||
public void backgroundUpdate(Player player) {
|
||||
VotingPluginUser user = getUserManager().getVotingPluginUser(player);
|
||||
user.offVote();
|
||||
user.checkOfflineRewards();
|
||||
user.offVoteAndThen(player, user::checkOfflineRewards);
|
||||
}
|
||||
|
||||
public VotingPluginMain getMainClass() {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+821
@@ -0,0 +1,821 @@
|
||||
package com.bencodez.votingplugin.backendproxy;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.SeekableByteChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
|
||||
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.proxy.VotingPluginWire;
|
||||
import com.bencodez.votingplugin.util.DurableFiles;
|
||||
|
||||
/**
|
||||
* Bounded durable spill for ordered backend proxy vote messages. Processing is
|
||||
* owned by the active BackendProxyHandler; this class only persists FIFO state
|
||||
* across handler replacement and process restart.
|
||||
*/
|
||||
public final class BackendOrderedVoteOverflowQueue implements AutoCloseable {
|
||||
static final int MAX_NORMAL_ENTRIES = 512;
|
||||
private static final int MAX_ENTRIES = MAX_NORMAL_ENTRIES + BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE;
|
||||
static final int MAX_FAILED_ENTRIES = 256;
|
||||
private static final long MAX_FILE_BYTES = 8L * 1024L * 1024L;
|
||||
private static final long RETRY_DELAY_MILLIS = 250L;
|
||||
private static final long CLOSE_GRACE_MILLIS = 3_000L;
|
||||
private static final String QUEUE_FILE = "BackendProxyVoteQueue.yml";
|
||||
|
||||
private final VotingPluginMain plugin;
|
||||
private final Path file;
|
||||
private final ScheduledThreadPoolExecutor worker;
|
||||
private final Object lock = new Object();
|
||||
private final Object persistenceWriteLock = new Object();
|
||||
private final ArrayDeque<PendingEnvelope> entries = new ArrayDeque<>();
|
||||
private final ArrayDeque<PendingAdmission> admissions = new ArrayDeque<>();
|
||||
private final ArrayDeque<String> failedEntries = new ArrayDeque<>();
|
||||
private final Set<String> failedDeliveryIds = new HashSet<>();
|
||||
private boolean admissionPersistenceScheduled;
|
||||
private boolean persistenceScheduled;
|
||||
private boolean persistenceDirty;
|
||||
private boolean closed;
|
||||
private final AtomicBoolean closeRequested = new AtomicBoolean();
|
||||
private boolean loadFailed;
|
||||
private PendingFailure pendingFailure;
|
||||
private PendingAcknowledgement pendingAcknowledgement;
|
||||
private volatile Thread closeThread;
|
||||
private volatile CloseState closeState = CloseState.OPEN;
|
||||
private long stateVersion;
|
||||
private long durableVersion;
|
||||
private Object wakeupOwner;
|
||||
private Runnable wakeup;
|
||||
|
||||
public BackendOrderedVoteOverflowQueue(VotingPluginMain plugin) {
|
||||
this.plugin = plugin;
|
||||
this.file = new File(plugin.getDataFolder(), QUEUE_FILE).toPath();
|
||||
this.worker = new ScheduledThreadPoolExecutor(1, runnable -> {
|
||||
Thread thread = new Thread(runnable, "VotingPlugin-BackendVote-Overflow");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
this.worker.setRemoveOnCancelPolicy(true);
|
||||
load();
|
||||
}
|
||||
|
||||
public boolean enqueue(JsonEnvelope envelope) {
|
||||
return enqueue(envelope, 0);
|
||||
}
|
||||
|
||||
boolean enqueue(JsonEnvelope envelope, int reservedPrefixEntries) {
|
||||
PendingEnvelope pending = pending(envelope);
|
||||
if (pending == null || reservedPrefixEntries < 0
|
||||
|| reservedPrefixEntries > BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE) return false;
|
||||
Runnable notify = null;
|
||||
synchronized (persistenceWriteLock) {
|
||||
synchronized (lock) {
|
||||
// Admission returns only after the overflow snapshot reaches disk.
|
||||
// Keep older in-memory work's shutdown capacity reserved as well.
|
||||
if (closeRequested.get() || closed || loadFailed || !admissions.isEmpty()
|
||||
|| entries.size() + reservedPrefixEntries >= MAX_ENTRIES) return false;
|
||||
List<String> snapshot = payloadSnapshotLocked();
|
||||
snapshot.add(pending.payload);
|
||||
try {
|
||||
writeSnapshotLocked(snapshot, failedSnapshotLocked());
|
||||
} catch (IOException failure) {
|
||||
warn("Unable to admit ordered proxy vote overflow", failure);
|
||||
return false;
|
||||
}
|
||||
entries.addLast(pending);
|
||||
durableVersion = ++stateVersion;
|
||||
if (wakeup != null) notify = wakeup;
|
||||
}
|
||||
}
|
||||
runWakeup(notify);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Reserves bounded FIFO capacity and persists it on the overflow worker. */
|
||||
boolean enqueueAsync(JsonEnvelope envelope, int reservedPrefixEntries, Consumer<Boolean> completion) {
|
||||
PendingEnvelope pending = pending(envelope);
|
||||
if (pending == null || completion == null || reservedPrefixEntries < 0
|
||||
|| reservedPrefixEntries > BackendProxyHandler.MAX_ORDERED_VOTE_QUEUE) return false;
|
||||
PendingAdmission admission = new PendingAdmission(pending, completion);
|
||||
boolean schedulePersistence;
|
||||
synchronized (lock) {
|
||||
if (closeRequested.get() || closed || loadFailed
|
||||
|| entries.size() + admissions.size() + reservedPrefixEntries >= MAX_ENTRIES) return false;
|
||||
admissions.addLast(admission);
|
||||
schedulePersistence = !admissionPersistenceScheduled;
|
||||
admissionPersistenceScheduled = true;
|
||||
}
|
||||
if (!schedulePersistence) return true;
|
||||
try {
|
||||
worker.execute(this::persistAdmissions);
|
||||
return true;
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
synchronized (lock) {
|
||||
admissions.removeLastOccurrence(admission);
|
||||
admissionPersistenceScheduled = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void persistAdmissions() {
|
||||
while (true) {
|
||||
List<PendingAdmission> batch;
|
||||
List<PendingAdmission> admitted = new ArrayList<>();
|
||||
List<String> snapshot;
|
||||
List<String> failures;
|
||||
long snapshotVersion;
|
||||
Runnable notify = null;
|
||||
synchronized (persistenceWriteLock) {
|
||||
synchronized (lock) {
|
||||
if (closed || loadFailed || admissions.isEmpty()) {
|
||||
admissionPersistenceScheduled = false;
|
||||
return;
|
||||
}
|
||||
batch = new ArrayList<>(admissions);
|
||||
snapshot = payloadSnapshotLocked();
|
||||
failures = failedSnapshotLocked();
|
||||
snapshotVersion = stateVersion;
|
||||
}
|
||||
try {
|
||||
writeSnapshotLocked(snapshot, failures);
|
||||
} catch (IOException failure) {
|
||||
warn("Unable to admit ordered proxy vote overflow", failure);
|
||||
try {
|
||||
worker.schedule(this::persistAdmissions, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
|
||||
} catch (RejectedExecutionException ignored) {
|
||||
synchronized (lock) {
|
||||
admissionPersistenceScheduled = false;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
synchronized (lock) {
|
||||
for (PendingAdmission candidate : batch) {
|
||||
if (admissions.peekFirst() != candidate) break;
|
||||
admissions.removeFirst();
|
||||
entries.addLast(candidate.pending);
|
||||
admitted.add(candidate);
|
||||
}
|
||||
durableVersion = Math.max(durableVersion, snapshotVersion);
|
||||
if (durableVersion == stateVersion && wakeup != null) notify = wakeup;
|
||||
}
|
||||
}
|
||||
for (PendingAdmission admission : admitted) admission.complete(true);
|
||||
runWakeup(notify);
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds older in-memory work ahead of already spilled newer messages. */
|
||||
public boolean prepend(List<JsonEnvelope> envelopes) {
|
||||
if (envelopes == null || envelopes.isEmpty()) return true;
|
||||
List<PendingEnvelope> pending = new ArrayList<>(envelopes.size());
|
||||
for (JsonEnvelope envelope : envelopes) {
|
||||
PendingEnvelope value = pending(envelope);
|
||||
if (value == null) return false;
|
||||
pending.add(value);
|
||||
}
|
||||
synchronized (lock) {
|
||||
if (closeRequested.get() || closed || loadFailed
|
||||
|| entries.size() + admissions.size() + pending.size() > MAX_ENTRIES) return false;
|
||||
for (int index = pending.size() - 1; index >= 0; index--) {
|
||||
entries.addFirst(pending.get(index));
|
||||
}
|
||||
stateVersion++;
|
||||
requestPersistenceLocked();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public int size() {
|
||||
synchronized (lock) {
|
||||
return entries.size() + admissions.size();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasEntries() {
|
||||
return size() != 0;
|
||||
}
|
||||
|
||||
int failedSize() {
|
||||
synchronized (lock) {
|
||||
return failedEntries.size();
|
||||
}
|
||||
}
|
||||
|
||||
boolean isQuarantineCapacityExhausted() {
|
||||
synchronized (lock) {
|
||||
return loadFailed || failedEntries.size() >= MAX_FAILED_ENTRIES;
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves an ambiguous processing failure out of the active lane in one durable snapshot. */
|
||||
private Boolean quarantine(PendingFailure request) {
|
||||
synchronized (persistenceWriteLock) {
|
||||
synchronized (lock) {
|
||||
if (closed) return null; // Final close owns the pending failure.
|
||||
if (loadFailed || pendingFailure != request
|
||||
|| (request.expected != null && entries.peekFirst() != request.expected)) return false;
|
||||
String deliveryId = reliableDeliveryId(request.failed);
|
||||
boolean alreadyQuarantined = deliveryId != null
|
||||
? failedDeliveryIds.contains(deliveryId)
|
||||
: failedEntries.contains(request.failed.payload);
|
||||
if (!alreadyQuarantined && failedEntries.size() >= MAX_FAILED_ENTRIES) return false;
|
||||
List<String> active = payloadSnapshotLocked();
|
||||
if (request.expected != null) active.remove(0);
|
||||
List<String> failures = failedSnapshotLocked();
|
||||
if (!alreadyQuarantined) failures.add(request.failed.payload);
|
||||
try {
|
||||
writeSnapshotLocked(active, failures);
|
||||
} catch (IOException failure) {
|
||||
warn("Unable to quarantine ordered proxy vote", failure);
|
||||
return false;
|
||||
}
|
||||
if (request.expected != null) entries.removeFirst();
|
||||
if (!alreadyQuarantined) {
|
||||
failedEntries.addLast(request.failed.payload);
|
||||
if (deliveryId != null) failedDeliveryIds.add(deliveryId);
|
||||
}
|
||||
durableVersion = ++stateVersion;
|
||||
request.stored = true;
|
||||
pendingFailure = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stages durable isolation without adding filesystem work to the lifecycle thread. */
|
||||
boolean quarantineForShutdown(PendingEnvelope expected, JsonEnvelope envelope) {
|
||||
PendingEnvelope failed = pending(envelope);
|
||||
PendingFailure request;
|
||||
synchronized (lock) {
|
||||
boolean alreadyQuarantined = failed != null && isAlreadyQuarantined(failed);
|
||||
if (closeRequested.get() || closed || loadFailed || failed == null || pendingFailure != null
|
||||
|| pendingAcknowledgement != null
|
||||
|| (!alreadyQuarantined && failedEntries.size() >= MAX_FAILED_ENTRIES)) return false;
|
||||
request = new PendingFailure(expected, failed, ignored -> { });
|
||||
pendingFailure = request;
|
||||
}
|
||||
try {
|
||||
worker.execute(() -> quarantine(request));
|
||||
return true;
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
synchronized (lock) {
|
||||
if (pendingFailure == request) pendingFailure = null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes failure evidence off the Bukkit owner thread before releasing the lane. */
|
||||
void quarantineAsync(PendingEnvelope expected, JsonEnvelope envelope, Consumer<Boolean> completion) {
|
||||
PendingEnvelope failed = pending(envelope);
|
||||
PendingFailure request = null;
|
||||
synchronized (lock) {
|
||||
if (!closeRequested.get() && !closed && !loadFailed && failed != null && pendingFailure == null
|
||||
&& pendingAcknowledgement == null) {
|
||||
request = new PendingFailure(expected, failed, completion);
|
||||
pendingFailure = request;
|
||||
}
|
||||
}
|
||||
if (request == null) {
|
||||
completion.accept(false);
|
||||
return;
|
||||
}
|
||||
PendingFailure queued = request;
|
||||
try {
|
||||
worker.execute(() -> {
|
||||
Boolean stored = quarantine(queued);
|
||||
if (stored == null) return;
|
||||
try {
|
||||
queued.complete(stored);
|
||||
} finally {
|
||||
if (stored) {
|
||||
synchronized (lock) {
|
||||
if (pendingFailure == queued) pendingFailure = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
queued.complete(false);
|
||||
}
|
||||
}
|
||||
|
||||
boolean hasPendingFailure(JsonEnvelope envelope) {
|
||||
synchronized (lock) {
|
||||
return pendingFailure != null && pendingFailure.failed.envelope == envelope
|
||||
&& !loadFailed;
|
||||
}
|
||||
}
|
||||
|
||||
PendingEnvelope peekDurable() {
|
||||
synchronized (lock) {
|
||||
if (closed || loadFailed || durableVersion != stateVersion) return null;
|
||||
return entries.peekFirst();
|
||||
}
|
||||
}
|
||||
|
||||
/** Persists completion off the platform thread before removing the durable head. */
|
||||
void acknowledgeAsync(PendingEnvelope expected, Consumer<Boolean> completion) {
|
||||
PendingAcknowledgement request = null;
|
||||
synchronized (lock) {
|
||||
if (!closeRequested.get() && !closed && !loadFailed && expected != null && entries.peekFirst() == expected
|
||||
&& pendingAcknowledgement == null && pendingFailure == null) {
|
||||
request = new PendingAcknowledgement(expected, completion);
|
||||
pendingAcknowledgement = request;
|
||||
}
|
||||
}
|
||||
if (request == null) {
|
||||
completion.accept(false);
|
||||
return;
|
||||
}
|
||||
PendingAcknowledgement queued = request;
|
||||
try {
|
||||
worker.execute(() -> {
|
||||
Boolean stored = acknowledge(queued);
|
||||
if (stored == null) return;
|
||||
try {
|
||||
queued.complete(stored);
|
||||
} finally {
|
||||
if (stored) {
|
||||
synchronized (lock) {
|
||||
if (pendingAcknowledgement == queued) pendingAcknowledgement = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
queued.complete(false);
|
||||
}
|
||||
}
|
||||
|
||||
private Boolean acknowledge(PendingAcknowledgement request) {
|
||||
synchronized (persistenceWriteLock) {
|
||||
synchronized (lock) {
|
||||
if (closed) return null; // Final close owns the pending acknowledgement.
|
||||
if (loadFailed || pendingAcknowledgement != request || entries.peekFirst() != request.expected) {
|
||||
return false;
|
||||
}
|
||||
List<String> active = payloadSnapshotLocked();
|
||||
active.remove(0);
|
||||
try {
|
||||
writeSnapshotLocked(active, failedSnapshotLocked());
|
||||
} catch (IOException failure) {
|
||||
warn("Unable to acknowledge ordered proxy vote overflow", failure);
|
||||
return false;
|
||||
}
|
||||
entries.removeFirst();
|
||||
durableVersion = ++stateVersion;
|
||||
request.stored = true;
|
||||
pendingAcknowledgement = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void bindWakeup(Object owner, Runnable callback) {
|
||||
Runnable notify = null;
|
||||
synchronized (lock) {
|
||||
wakeupOwner = owner;
|
||||
wakeup = callback;
|
||||
if (!closed && durableVersion == stateVersion && !entries.isEmpty()) notify = callback;
|
||||
}
|
||||
runWakeup(notify);
|
||||
}
|
||||
|
||||
void unbindWakeup(Object owner) {
|
||||
synchronized (lock) {
|
||||
if (wakeupOwner != owner) return;
|
||||
wakeupOwner = null;
|
||||
wakeup = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Retries a failed delivery without keeping the ordered lane active or spinning. */
|
||||
boolean retryLater(Object owner, Runnable callback) {
|
||||
synchronized (lock) {
|
||||
if (closed || wakeupOwner != owner) return false;
|
||||
try {
|
||||
worker.schedule(() -> {
|
||||
synchronized (lock) {
|
||||
if (closed || wakeupOwner != owner) return;
|
||||
}
|
||||
runWakeup(callback);
|
||||
}, 1L, TimeUnit.SECONDS);
|
||||
return true;
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PendingEnvelope pending(JsonEnvelope envelope) {
|
||||
if (envelope == null || !isOrderedVoteMessage(envelope)) return null;
|
||||
try {
|
||||
return new PendingEnvelope(JsonEnvelopeCodec.encode(envelope), envelope);
|
||||
} catch (RuntimeException failure) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isAlreadyQuarantined(PendingEnvelope failed) {
|
||||
String deliveryId = reliableDeliveryId(failed);
|
||||
return deliveryId != null ? failedDeliveryIds.contains(deliveryId) : failedEntries.contains(failed.payload);
|
||||
}
|
||||
|
||||
private static String reliableDeliveryId(PendingEnvelope failed) {
|
||||
JsonEnvelope envelope = failed.envelope;
|
||||
if ((!VotingPluginWire.SUB_VOTE.equals(envelope.getSubChannel())
|
||||
&& !VotingPluginWire.SUB_VOTE_ONLINE.equals(envelope.getSubChannel())
|
||||
&& !VotingPluginWire.SUB_VOTE_DELAY_REJECTED.equals(envelope.getSubChannel()))
|
||||
|| !VotingPluginWire.requestsVoteDeliveryAcknowledgement(envelope)) return null;
|
||||
try {
|
||||
return UUID.fromString(envelope.getFields().get(VotingPluginWire.K_VOTE_ID)).toString();
|
||||
} catch (RuntimeException invalidVoteId) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isOrderedVoteMessage(JsonEnvelope envelope) {
|
||||
String subChannel = envelope.getSubChannel();
|
||||
return VotingPluginWire.SUB_VOTE.equals(subChannel)
|
||||
|| VotingPluginWire.SUB_VOTE_ONLINE.equals(subChannel)
|
||||
|| VotingPluginWire.SUB_VOTE_DELAY_REJECTED.equals(subChannel)
|
||||
|| VotingPluginWire.SUB_VOTE_UPDATE.equals(subChannel)
|
||||
|| VotingPluginWire.SUB_VOTE_DELIVERY_RECEIPT_RELEASE.equals(subChannel);
|
||||
}
|
||||
|
||||
private void requestPersistenceLocked() {
|
||||
persistenceDirty = true;
|
||||
if (persistenceScheduled) return;
|
||||
persistenceScheduled = true;
|
||||
try {
|
||||
worker.execute(this::persistLoop);
|
||||
} catch (RejectedExecutionException ignored) {
|
||||
persistenceScheduled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void persistLoop() {
|
||||
while (true) {
|
||||
long snapshotVersion;
|
||||
try {
|
||||
synchronized (persistenceWriteLock) {
|
||||
List<String> snapshot;
|
||||
List<String> failures;
|
||||
synchronized (lock) {
|
||||
if (!persistenceDirty) {
|
||||
persistenceScheduled = false;
|
||||
return;
|
||||
}
|
||||
persistenceDirty = false;
|
||||
snapshot = payloadSnapshotLocked();
|
||||
failures = failedSnapshotLocked();
|
||||
snapshotVersion = stateVersion;
|
||||
}
|
||||
writeSnapshotLocked(snapshot, failures);
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
warn("Unable to persist ordered proxy vote overflow", failure);
|
||||
synchronized (lock) {
|
||||
persistenceDirty = true;
|
||||
try {
|
||||
worker.schedule(this::persistLoop, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
|
||||
} catch (RejectedExecutionException ignored) {
|
||||
persistenceScheduled = false;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
Runnable notify = null;
|
||||
synchronized (lock) {
|
||||
durableVersion = Math.max(durableVersion, snapshotVersion);
|
||||
if (!persistenceDirty) {
|
||||
persistenceScheduled = false;
|
||||
if (!closed && durableVersion == stateVersion && !entries.isEmpty()) notify = wakeup;
|
||||
}
|
||||
}
|
||||
runWakeup(notify);
|
||||
if (notify != null) return;
|
||||
}
|
||||
}
|
||||
|
||||
private void load() {
|
||||
try {
|
||||
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return;
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.loadFromString(readQueueFile());
|
||||
List<String> payloads = yaml.getStringList("Envelopes");
|
||||
List<String> failures = yaml.getStringList("FailedEnvelopes");
|
||||
if (payloads.size() > MAX_ENTRIES) {
|
||||
throw new IOException("active envelope history exceeds configured limit");
|
||||
}
|
||||
for (String failed : failures) {
|
||||
PendingEnvelope pending = null;
|
||||
try {
|
||||
JsonEnvelope envelope = JsonEnvelopeCodec.decode(failed);
|
||||
if (isOrderedVoteMessage(envelope)) pending = new PendingEnvelope(failed, envelope);
|
||||
} catch (RuntimeException ignored) {
|
||||
// Preserve legacy/manual failure evidence that is not a decodable envelope.
|
||||
}
|
||||
String deliveryId = pending == null ? null : reliableDeliveryId(pending);
|
||||
if (deliveryId != null ? !failedDeliveryIds.add(deliveryId) : failedEntries.contains(failed)) continue;
|
||||
failedEntries.addLast(failed);
|
||||
if (failedEntries.size() > MAX_FAILED_ENTRIES) {
|
||||
throw new IOException("failed envelope history exceeds configured limit");
|
||||
}
|
||||
}
|
||||
for (String payload : payloads) {
|
||||
JsonEnvelope envelope;
|
||||
try {
|
||||
envelope = JsonEnvelopeCodec.decode(payload);
|
||||
} catch (RuntimeException invalid) {
|
||||
throw new IOException("invalid ordered proxy vote envelope", invalid);
|
||||
}
|
||||
if (!isOrderedVoteMessage(envelope)) {
|
||||
throw new IOException("unsupported ordered proxy vote envelope");
|
||||
}
|
||||
entries.addLast(new PendingEnvelope(payload, envelope));
|
||||
}
|
||||
} catch (Exception failure) {
|
||||
loadFailed = true;
|
||||
entries.clear();
|
||||
failedEntries.clear();
|
||||
failedDeliveryIds.clear();
|
||||
warn("Unable to load ordered proxy vote overflow", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private String readQueueFile() throws IOException {
|
||||
Set<java.nio.file.OpenOption> options = Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS);
|
||||
try (SeekableByteChannel channel = Files.newByteChannel(file, options)) {
|
||||
ByteBuffer bytes = ByteBuffer.allocate((int) MAX_FILE_BYTES + 1);
|
||||
while (bytes.hasRemaining() && channel.read(bytes) != -1) {
|
||||
// Continue until EOF or one byte beyond the configured limit.
|
||||
}
|
||||
if (!bytes.hasRemaining()) throw new IOException("queue file exceeds size limit");
|
||||
return new String(bytes.array(), 0, bytes.position(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> payloadSnapshotLocked() {
|
||||
List<String> snapshot = new ArrayList<>(entries.size() + admissions.size());
|
||||
for (PendingEnvelope pending : entries) snapshot.add(pending.payload);
|
||||
for (PendingAdmission admission : admissions) snapshot.add(admission.pending.payload);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private List<String> failedSnapshotLocked() {
|
||||
return new ArrayList<>(failedEntries);
|
||||
}
|
||||
|
||||
private void writeSnapshotLocked(List<String> snapshot, List<String> failures) throws IOException {
|
||||
writeSnapshotLocked(snapshot, failures, false);
|
||||
}
|
||||
|
||||
private void writeSnapshotLocked(List<String> snapshot, List<String> failures, boolean finalWrite) throws IOException {
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
yaml.set("Envelopes", snapshot);
|
||||
yaml.set("FailedEnvelopes", failures);
|
||||
byte[] bytes = yaml.saveToString().getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length > MAX_FILE_BYTES) throw new IOException("queue snapshot exceeds size limit");
|
||||
Path parent = file.toAbsolutePath().normalize().getParent();
|
||||
if (parent == null) throw new IOException("queue has no parent");
|
||||
Files.createDirectories(parent);
|
||||
Path temporary = Files.createTempFile(parent, "BackendProxyVoteQueue-", ".tmp");
|
||||
try {
|
||||
Files.write(temporary, bytes, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
|
||||
DurableFiles.forceFile(temporary);
|
||||
try {
|
||||
Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException unsupported) {
|
||||
Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
if (finalWrite) closeState = CloseState.SNAPSHOT_REPLACED;
|
||||
DurableFiles.forceDirectory(parent);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private void runWakeup(Runnable callback) {
|
||||
if (callback == null) return;
|
||||
try {
|
||||
callback.run();
|
||||
} catch (RuntimeException failure) {
|
||||
if (plugin != null) plugin.debug(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void warn(String message, Exception failure) {
|
||||
if (plugin != null && plugin.getLogger() != null) {
|
||||
plugin.getLogger().warning(message + ": " + failure.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!closeRequested.compareAndSet(false, true)) return;
|
||||
closeState = CloseState.CLOSING;
|
||||
Thread finalWrite = new Thread(this::closeBlocking, "VotingPlugin-BackendVote-Close");
|
||||
finalWrite.setDaemon(true);
|
||||
closeThread = finalWrite;
|
||||
finalWrite.start();
|
||||
try {
|
||||
finalWrite.join(CLOSE_GRACE_MILLIS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
if (finalWrite.isAlive() && plugin != null && plugin.getLogger() != null) {
|
||||
if (closeState == CloseState.SNAPSHOT_REPLACED) {
|
||||
plugin.getLogger().warning("Ordered proxy vote overflow snapshot was replaced, but its durability tail "
|
||||
+ "is still running after the bounded shutdown grace");
|
||||
} else {
|
||||
plugin.getLogger().severe("Ordered proxy vote overflow snapshot was not replaced within the bounded "
|
||||
+ "shutdown grace; final persistence remains pending");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
boolean awaitClose(long timeoutMillis) throws InterruptedException {
|
||||
Thread active = closeThread;
|
||||
if (active == null) return true;
|
||||
active.join(timeoutMillis);
|
||||
return !active.isAlive();
|
||||
}
|
||||
|
||||
private void closeBlocking() {
|
||||
List<String> snapshot;
|
||||
List<String> failures;
|
||||
PendingFailure closingFailure;
|
||||
PendingAcknowledgement closingAcknowledgement;
|
||||
boolean canStoreFailure;
|
||||
boolean closingFailureAlreadyStored;
|
||||
boolean canStoreAcknowledgement;
|
||||
synchronized (lock) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
wakeupOwner = null;
|
||||
wakeup = null;
|
||||
snapshot = payloadSnapshotLocked();
|
||||
failures = failedSnapshotLocked();
|
||||
closingFailure = pendingFailure;
|
||||
closingAcknowledgement = pendingAcknowledgement;
|
||||
closingFailureAlreadyStored = closingFailure != null && isAlreadyQuarantined(closingFailure.failed);
|
||||
canStoreFailure = closingFailure != null && !closingFailure.stored
|
||||
&& (closingFailureAlreadyStored || failures.size() < MAX_FAILED_ENTRIES)
|
||||
&& (closingFailure.expected == null || entries.peekFirst() == closingFailure.expected);
|
||||
canStoreAcknowledgement = closingAcknowledgement != null && !closingAcknowledgement.stored
|
||||
&& entries.peekFirst() == closingAcknowledgement.expected;
|
||||
if (canStoreAcknowledgement) snapshot.remove(0);
|
||||
if (canStoreFailure) {
|
||||
if (closingFailure.expected != null) snapshot.remove(0);
|
||||
if (!closingFailureAlreadyStored) failures.add(closingFailure.failed.payload);
|
||||
}
|
||||
persistenceScheduled = false;
|
||||
}
|
||||
worker.shutdownNow();
|
||||
try {
|
||||
worker.awaitTermination(1, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
if (loadFailed) {
|
||||
closeState = CloseState.FAILED;
|
||||
if (closingFailure != null) closingFailure.complete(false);
|
||||
if (closingAcknowledgement != null) closingAcknowledgement.complete(false);
|
||||
return;
|
||||
}
|
||||
boolean saved = false;
|
||||
try {
|
||||
closeState = CloseState.WAITING_TO_PERSIST;
|
||||
synchronized (persistenceWriteLock) {
|
||||
writeSnapshotLocked(snapshot, failures, true);
|
||||
}
|
||||
saved = true;
|
||||
} catch (IOException failure) {
|
||||
closeState = CloseState.FAILED;
|
||||
severe("Unable to persist ordered proxy vote overflow during shutdown", failure);
|
||||
}
|
||||
if (saved && canStoreFailure) {
|
||||
synchronized (lock) {
|
||||
if (closingFailure.expected != null) entries.removeFirst();
|
||||
if (!closingFailureAlreadyStored) {
|
||||
failedEntries.addLast(closingFailure.failed.payload);
|
||||
String deliveryId = reliableDeliveryId(closingFailure.failed);
|
||||
if (deliveryId != null) failedDeliveryIds.add(deliveryId);
|
||||
}
|
||||
closingFailure.stored = true;
|
||||
}
|
||||
}
|
||||
if (saved && canStoreAcknowledgement) {
|
||||
synchronized (lock) {
|
||||
entries.removeFirst();
|
||||
closingAcknowledgement.stored = true;
|
||||
}
|
||||
}
|
||||
if (closingFailure != null) closingFailure.complete(saved && (closingFailure.stored || canStoreFailure));
|
||||
if (closingAcknowledgement != null) {
|
||||
closingAcknowledgement.complete(saved
|
||||
&& (closingAcknowledgement.stored || canStoreAcknowledgement));
|
||||
}
|
||||
if (saved) closeState = CloseState.COMPLETE;
|
||||
}
|
||||
|
||||
private void severe(String message, Exception failure) {
|
||||
if (plugin != null && plugin.getLogger() != null) {
|
||||
plugin.getLogger().log(Level.SEVERE, message, failure);
|
||||
}
|
||||
}
|
||||
|
||||
enum CloseState { OPEN, CLOSING, WAITING_TO_PERSIST, SNAPSHOT_REPLACED, COMPLETE, FAILED }
|
||||
|
||||
CloseState closeState() { return closeState; }
|
||||
|
||||
private static final class PendingAcknowledgement {
|
||||
private final PendingEnvelope expected;
|
||||
private final Consumer<Boolean> completion;
|
||||
private final AtomicBoolean completed = new AtomicBoolean();
|
||||
private boolean stored;
|
||||
|
||||
private PendingAcknowledgement(PendingEnvelope expected, Consumer<Boolean> completion) {
|
||||
this.expected = expected;
|
||||
this.completion = completion;
|
||||
}
|
||||
|
||||
private void complete(boolean success) {
|
||||
if (completed.compareAndSet(false, true)) completion.accept(success);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PendingFailure {
|
||||
private final PendingEnvelope expected;
|
||||
private final PendingEnvelope failed;
|
||||
private final Consumer<Boolean> completion;
|
||||
private final AtomicBoolean completed = new AtomicBoolean();
|
||||
private boolean stored;
|
||||
|
||||
private PendingFailure(PendingEnvelope expected, PendingEnvelope failed, Consumer<Boolean> completion) {
|
||||
this.expected = expected;
|
||||
this.failed = failed;
|
||||
this.completion = completion;
|
||||
}
|
||||
|
||||
private void complete(boolean success) {
|
||||
if (completed.compareAndSet(false, true)) completion.accept(success);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PendingAdmission {
|
||||
private final PendingEnvelope pending;
|
||||
private final Consumer<Boolean> completion;
|
||||
private final AtomicBoolean completed = new AtomicBoolean();
|
||||
|
||||
private PendingAdmission(PendingEnvelope pending, Consumer<Boolean> completion) {
|
||||
this.pending = pending;
|
||||
this.completion = completion;
|
||||
}
|
||||
|
||||
private void complete(boolean success) {
|
||||
if (completed.compareAndSet(false, true)) completion.accept(success);
|
||||
}
|
||||
}
|
||||
|
||||
static final class PendingEnvelope {
|
||||
private final String payload;
|
||||
private final JsonEnvelope envelope;
|
||||
|
||||
private PendingEnvelope(String payload, JsonEnvelope envelope) {
|
||||
this.payload = payload;
|
||||
this.envelope = envelope;
|
||||
}
|
||||
|
||||
JsonEnvelope envelope() {
|
||||
return envelope;
|
||||
}
|
||||
}
|
||||
}
|
||||
+879
-27
File diff suppressed because it is too large.
Load diff
VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/DurableVoteReceiptStore.java
Vendored
+241
@@ -0,0 +1,241 @@
|
||||
package com.bencodez.votingplugin.backendproxy.cache;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import com.bencodez.votingplugin.util.DurableFiles;
|
||||
|
||||
/** Bounded append journal for backend vote IDs completed before acknowledgement. */
|
||||
final class DurableVoteReceiptStore {
|
||||
static final int MAX_ACTIVE_RECEIPTS = 262144;
|
||||
/* Larger than the complete in-memory and durable ordered lane (256 + 512). */
|
||||
static final int COMPLETION_HEADROOM = 1024;
|
||||
/* Covers slightly more than three completed votes per second for the full 24-hour replay window. */
|
||||
static final int MAX_RELEASE_TOMBSTONES = 262144;
|
||||
static final long MAX_FILE_BYTES = 16L * 1024L * 1024L;
|
||||
private static final String HEADER = "VP-VOTE-RECEIPTS-1";
|
||||
private static final String RELEASE = "R";
|
||||
static final int MAX_RECORD_BYTES = 57;
|
||||
static final int MAX_TOTAL_RECEIPTS = (int) ((MAX_FILE_BYTES - HEADER.length() - 1L) / MAX_RECORD_BYTES);
|
||||
static final long RELEASE_TOMBSTONE_TTL_MILLIS = TimeUnit.HOURS.toMillis(24);
|
||||
private static final ConcurrentHashMap<Path, Object> FILE_LOCKS = new ConcurrentHashMap<>();
|
||||
|
||||
private final Path file;
|
||||
private final Object fileLock;
|
||||
private final int maxActiveReceipts;
|
||||
private final int completionHeadroom;
|
||||
private final int maxReleaseTombstones;
|
||||
private final int maxTotalReceipts;
|
||||
private final LinkedHashMap<UUID, Long> receipts = new LinkedHashMap<>();
|
||||
private int activeReceipts;
|
||||
private int releaseTombstones;
|
||||
private int journalRecords;
|
||||
private boolean repairRequired;
|
||||
|
||||
DurableVoteReceiptStore(Path file) throws IOException {
|
||||
this(file, MAX_ACTIVE_RECEIPTS, COMPLETION_HEADROOM, MAX_RELEASE_TOMBSTONES, MAX_TOTAL_RECEIPTS);
|
||||
}
|
||||
|
||||
DurableVoteReceiptStore(Path file, int maxActiveReceipts, int completionHeadroom,
|
||||
int maxReleaseTombstones) throws IOException {
|
||||
this(file, maxActiveReceipts, completionHeadroom, maxReleaseTombstones,
|
||||
maxActiveReceipts + completionHeadroom + maxReleaseTombstones);
|
||||
}
|
||||
|
||||
DurableVoteReceiptStore(Path file, int maxActiveReceipts, int completionHeadroom,
|
||||
int maxReleaseTombstones, int maxTotalReceipts) throws IOException {
|
||||
this.file = file.toAbsolutePath().normalize();
|
||||
this.fileLock = FILE_LOCKS.computeIfAbsent(this.file, ignored -> new Object());
|
||||
this.maxActiveReceipts = maxActiveReceipts;
|
||||
this.completionHeadroom = completionHeadroom;
|
||||
this.maxReleaseTombstones = maxReleaseTombstones;
|
||||
this.maxTotalReceipts = maxTotalReceipts;
|
||||
synchronized (fileLock) {
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
synchronized Map<UUID, Long> snapshot() {
|
||||
cleanupReleasedTombstones(System.currentTimeMillis());
|
||||
return new LinkedHashMap<>(receipts);
|
||||
}
|
||||
|
||||
synchronized boolean contains(UUID voteId) {
|
||||
cleanupReleasedTombstones(System.currentTimeMillis());
|
||||
return voteId != null && receipts.containsKey(voteId);
|
||||
}
|
||||
|
||||
/** Returns whether every admitted in-flight vote can still obtain an active receipt. */
|
||||
synchronized boolean hasCompletionCapacity(int admittedVotes) {
|
||||
if (admittedVotes < 0) return false;
|
||||
cleanupReleasedTombstones(System.currentTimeMillis());
|
||||
return activeReceipts <= maxActiveReceipts + completionHeadroom - admittedVotes
|
||||
&& receipts.size() <= maxTotalReceipts - admittedVotes;
|
||||
}
|
||||
|
||||
int maximumTrackedReceipts() {
|
||||
return maxTotalReceipts;
|
||||
}
|
||||
|
||||
synchronized long complete(UUID voteId) {
|
||||
if (voteId == null) return 0L;
|
||||
cleanupReleasedTombstones(System.currentTimeMillis());
|
||||
Long current = receipts.get(voteId);
|
||||
if (current != null) return current;
|
||||
if (activeReceipts >= maxActiveReceipts + completionHeadroom || receipts.size() >= maxTotalReceipts) return 0L;
|
||||
long expiresAt = Long.MAX_VALUE;
|
||||
String record = voteId + "\t" + expiresAt + '\n';
|
||||
synchronized (fileLock) {
|
||||
if (!prepareAppend(record) || !append(record)) return 0L;
|
||||
}
|
||||
putReceipt(voteId, expiresAt);
|
||||
journalRecords++;
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
synchronized long release(UUID voteId) {
|
||||
if (voteId == null) return 0L;
|
||||
long now = System.currentTimeMillis();
|
||||
cleanupReleasedTombstones(now);
|
||||
Long current = receipts.get(voteId);
|
||||
if (current != null && current != Long.MAX_VALUE) return current;
|
||||
if (releaseTombstones >= maxReleaseTombstones
|
||||
|| (current == null && receipts.size() >= maxTotalReceipts)) return 0L;
|
||||
long expiresAt = now + RELEASE_TOMBSTONE_TTL_MILLIS;
|
||||
String record = RELEASE + '\t' + voteId + '\t' + expiresAt + '\n';
|
||||
synchronized (fileLock) {
|
||||
if (!prepareAppend(record) || !append(record)) return 0L;
|
||||
}
|
||||
putReceipt(voteId, expiresAt);
|
||||
journalRecords++;
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
private void load() throws IOException {
|
||||
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return;
|
||||
if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Vote receipt journal is not a regular file");
|
||||
}
|
||||
if (Files.size(file) > MAX_FILE_BYTES) throw new IOException("Vote receipt journal exceeds size limit");
|
||||
String content = Files.readString(file, StandardCharsets.UTF_8);
|
||||
String[] lines = content.split("\\n", -1);
|
||||
if (lines.length == 0 || !HEADER.equals(lines[0])) throw new IOException("Unsupported vote receipt journal");
|
||||
boolean unterminatedTail = !content.endsWith("\n");
|
||||
int completeLineLimit = unterminatedTail ? lines.length - 1 : lines.length;
|
||||
for (int index = 1; index < completeLineLimit; index++) {
|
||||
if (lines[index].isBlank()) continue;
|
||||
try {
|
||||
String[] fields = lines[index].split("\\t", 3);
|
||||
if (RELEASE.equals(fields[0])) {
|
||||
if (fields.length == 2) removeReceipt(UUID.fromString(fields[1]));
|
||||
else if (fields.length == 3) {
|
||||
UUID voteId = UUID.fromString(fields[1]);
|
||||
long expiresAt = Long.parseLong(fields[2]);
|
||||
if (expiresAt > System.currentTimeMillis()) putReceipt(voteId, expiresAt);
|
||||
else removeReceipt(voteId);
|
||||
} else throw new IllegalArgumentException("Malformed receipt release");
|
||||
} else {
|
||||
if (fields.length != 2) throw new IllegalArgumentException("Malformed receipt");
|
||||
UUID voteId = UUID.fromString(fields[0]);
|
||||
long expiresAt = Long.parseLong(fields[1]);
|
||||
putReceipt(voteId, expiresAt);
|
||||
}
|
||||
} catch (RuntimeException malformed) {
|
||||
throw new IOException("Malformed vote receipt journal", malformed);
|
||||
}
|
||||
journalRecords++;
|
||||
if (activeReceipts > maxActiveReceipts + completionHeadroom
|
||||
|| releaseTombstones > maxReleaseTombstones) {
|
||||
throw new IOException("Vote receipt journal exceeds entry limit");
|
||||
}
|
||||
}
|
||||
if (unterminatedTail && !compact()) throw new IOException("Unable to repair vote receipt journal");
|
||||
}
|
||||
|
||||
private boolean prepareAppend(String record) {
|
||||
try {
|
||||
if ((repairRequired || DurableFiles.hasUnterminatedTail(file)) && !compact()) return false;
|
||||
repairRequired = false;
|
||||
long projected = (Files.exists(file) ? Files.size(file) : HEADER.length() + 1L)
|
||||
+ record.getBytes(StandardCharsets.UTF_8).length;
|
||||
boolean shouldCompact = journalRecords > receipts.size() * 2 + 256;
|
||||
if ((projected > MAX_FILE_BYTES || shouldCompact) && compact()) {
|
||||
projected = Files.size(file) + record.getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
return projected <= MAX_FILE_BYTES;
|
||||
} catch (IOException failure) {
|
||||
repairRequired = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean append(String record) {
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Path staged = file.resolveSibling(file.getFileName() + ".tmp");
|
||||
Files.writeString(staged, HEADER + '\n' + record, StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
|
||||
DurableFiles.publishStagedFile(staged, file);
|
||||
} else {
|
||||
Files.writeString(file, record, StandardCharsets.UTF_8, StandardOpenOption.APPEND,
|
||||
StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS);
|
||||
DurableFiles.forceFile(file);
|
||||
}
|
||||
return true;
|
||||
} catch (IOException failure) {
|
||||
repairRequired = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean compact() {
|
||||
try {
|
||||
StringBuilder content = new StringBuilder(HEADER).append('\n');
|
||||
for (Map.Entry<UUID, Long> receipt : receipts.entrySet()) {
|
||||
content.append(receipt.getKey()).append('\t').append(receipt.getValue()).append('\n');
|
||||
}
|
||||
Path staged = file.resolveSibling(file.getFileName() + ".tmp");
|
||||
Files.writeString(staged, content, StandardCharsets.UTF_8, StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
|
||||
DurableFiles.publishStagedFile(staged, file);
|
||||
journalRecords = receipts.size();
|
||||
repairRequired = false;
|
||||
return true;
|
||||
} catch (IOException failure) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupReleasedTombstones(long now) {
|
||||
int before = receipts.size();
|
||||
receipts.entrySet().removeIf(entry -> entry.getValue() != Long.MAX_VALUE && entry.getValue() <= now);
|
||||
releaseTombstones -= before - receipts.size();
|
||||
}
|
||||
|
||||
private void putReceipt(UUID voteId, long expiresAt) {
|
||||
Long previous = receipts.put(voteId, expiresAt);
|
||||
if (previous != null) {
|
||||
if (previous == Long.MAX_VALUE) activeReceipts--;
|
||||
else releaseTombstones--;
|
||||
}
|
||||
if (expiresAt == Long.MAX_VALUE) activeReceipts++;
|
||||
else releaseTombstones++;
|
||||
}
|
||||
|
||||
private void removeReceipt(UUID voteId) {
|
||||
Long previous = receipts.remove(voteId);
|
||||
if (previous == null) return;
|
||||
if (previous == Long.MAX_VALUE) activeReceipts--;
|
||||
else releaseTombstones--;
|
||||
}
|
||||
}
|
||||
Vendored
+151
-11
@@ -1,5 +1,7 @@
|
||||
package com.bencodez.votingplugin.backendproxy.cache;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
@@ -15,56 +17,165 @@ import lombok.Getter;
|
||||
public class ProcessedVoteCache {
|
||||
|
||||
private static final long DEFAULT_TTL_MILLIS = TimeUnit.MINUTES.toMillis(30);
|
||||
static final int DEFAULT_MAX_TRACKED_VOTES = Math.min(DurableVoteReceiptStore.MAX_TOTAL_RECEIPTS,
|
||||
DurableVoteReceiptStore.MAX_ACTIVE_RECEIPTS + DurableVoteReceiptStore.COMPLETION_HEADROOM);
|
||||
private static final int MAX_REDIS_DELIVERIES = 4096;
|
||||
public static final int MAX_LEGACY_REDIS_DELIVERY_BYTES = 256 * 1024;
|
||||
public static final int MAX_LEGACY_REDIS_TOTAL_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
@Getter
|
||||
private final ConcurrentHashMap<UUID, Long> processedVotes = new ConcurrentHashMap<>();
|
||||
private final java.util.Set<UUID> completedVotes = ConcurrentHashMap.newKeySet();
|
||||
private final java.util.Set<UUID> completedAwaitingReceipt = ConcurrentHashMap.newKeySet();
|
||||
private final java.util.Set<UUID> admittedVotes = ConcurrentHashMap.newKeySet();
|
||||
private final long ttlMillis;
|
||||
private final int maxTrackedVotes;
|
||||
private final DurableVoteReceiptStore durableReceipts;
|
||||
private final LinkedHashMap<String, Long> processedRedisDeliveries = new LinkedHashMap<>();
|
||||
private final LinkedHashMap<String, Integer> legacyRedisDeliveries = new LinkedHashMap<>();
|
||||
private long legacyRedisDeliveryBytes;
|
||||
private boolean legacyRedisHandoffOverflowed;
|
||||
private Object activeRedisSubscriber;
|
||||
private Object standbyRedisSubscriber;
|
||||
|
||||
public ProcessedVoteCache() {
|
||||
this(DEFAULT_TTL_MILLIS);
|
||||
this(DEFAULT_TTL_MILLIS, DEFAULT_MAX_TRACKED_VOTES, null);
|
||||
}
|
||||
|
||||
public ProcessedVoteCache(long ttlMillis) {
|
||||
this(ttlMillis, DEFAULT_MAX_TRACKED_VOTES, null);
|
||||
}
|
||||
|
||||
public ProcessedVoteCache(Path receiptFile) {
|
||||
this(DEFAULT_TTL_MILLIS, DEFAULT_MAX_TRACKED_VOTES, loadReceipts(receiptFile));
|
||||
}
|
||||
|
||||
ProcessedVoteCache(long ttlMillis, Path receiptFile) {
|
||||
this(ttlMillis, DEFAULT_MAX_TRACKED_VOTES, loadReceipts(receiptFile));
|
||||
}
|
||||
|
||||
ProcessedVoteCache(long ttlMillis, int maxTrackedVotes) {
|
||||
this(ttlMillis, maxTrackedVotes, null);
|
||||
}
|
||||
|
||||
ProcessedVoteCache(long ttlMillis, int maxTrackedVotes, DurableVoteReceiptStore durableReceipts) {
|
||||
if (maxTrackedVotes <= 0) throw new IllegalArgumentException("maxTrackedVotes must be positive");
|
||||
this.ttlMillis = ttlMillis;
|
||||
this.maxTrackedVotes = maxTrackedVotes;
|
||||
this.durableReceipts = durableReceipts;
|
||||
if (durableReceipts != null) {
|
||||
Map<UUID, Long> receipts = durableReceipts.snapshot();
|
||||
processedVotes.putAll(receipts);
|
||||
completedVotes.addAll(receipts.keySet());
|
||||
}
|
||||
}
|
||||
|
||||
private static DurableVoteReceiptStore loadReceipts(Path receiptFile) {
|
||||
try {
|
||||
return new DurableVoteReceiptStore(receiptFile);
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Unable to load durable backend vote receipts", failure);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean reserve(UUID voteId) {
|
||||
return reserveWithOutcome(voteId) == Reservation.RESERVED;
|
||||
}
|
||||
|
||||
/** Reserves one vote identity without evicting a still-live replay fence. */
|
||||
public synchronized Reservation reserveWithOutcome(UUID voteId) {
|
||||
if (voteId == null) {
|
||||
return true;
|
||||
return Reservation.RESERVED;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
long expiresAt = now + ttlMillis;
|
||||
|
||||
while (true) {
|
||||
if (completedAwaitingReceipt.contains(voteId)) return Reservation.DUPLICATE;
|
||||
Long currentExpiry = processedVotes.get(voteId);
|
||||
if (currentExpiry == null) {
|
||||
if (processedVotes.putIfAbsent(voteId, expiresAt) == null) {
|
||||
if (durableReceipts != null
|
||||
&& processedVotes.size() >= durableReceipts.maximumTrackedReceipts()) cleanup(now);
|
||||
if (!hasAdmissionCapacity()) {
|
||||
cleanup(now);
|
||||
return true;
|
||||
if (!hasAdmissionCapacity()) return Reservation.SATURATED;
|
||||
}
|
||||
if (processedVotes.putIfAbsent(voteId, expiresAt) == null) {
|
||||
admittedVotes.add(voteId);
|
||||
return Reservation.RESERVED;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentExpiry > now) {
|
||||
return false;
|
||||
return Reservation.DUPLICATE;
|
||||
}
|
||||
|
||||
if (processedVotes.replace(voteId, currentExpiry, expiresAt)) {
|
||||
cleanup(now);
|
||||
return true;
|
||||
if (processedVotes.remove(voteId, currentExpiry)) {
|
||||
completedVotes.remove(voteId);
|
||||
admittedVotes.remove(voteId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Releases a reservation only when no vote effects were applied. */
|
||||
public synchronized void cancelReservation(UUID voteId) {
|
||||
if (voteId == null || completedVotes.contains(voteId) || completedAwaitingReceipt.contains(voteId)) return;
|
||||
processedVotes.remove(voteId);
|
||||
admittedVotes.remove(voteId);
|
||||
}
|
||||
|
||||
public enum Reservation {
|
||||
RESERVED,
|
||||
DUPLICATE,
|
||||
SATURATED
|
||||
}
|
||||
|
||||
/** Persists successful processing before the backend emits a delivery acknowledgement. */
|
||||
public boolean complete(UUID voteId) {
|
||||
if (voteId == null) return true;
|
||||
completedAwaitingReceipt.add(voteId);
|
||||
if (durableReceipts == null) {
|
||||
completedVotes.add(voteId);
|
||||
completedAwaitingReceipt.remove(voteId);
|
||||
admittedVotes.remove(voteId);
|
||||
return true;
|
||||
}
|
||||
long expiresAt = durableReceipts.complete(voteId);
|
||||
if (expiresAt <= 0L) return false;
|
||||
processedVotes.put(voteId, expiresAt);
|
||||
completedVotes.add(voteId);
|
||||
completedAwaitingReceipt.remove(voteId);
|
||||
admittedVotes.remove(voteId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Returns whether vote effects completed, including a receipt append awaiting retry. */
|
||||
public boolean hasCompletedEffects(UUID voteId) {
|
||||
return voteId != null && (completedVotes.contains(voteId) || completedAwaitingReceipt.contains(voteId));
|
||||
}
|
||||
|
||||
/** Returns whether an acknowledgement-safe receipt is already durable. */
|
||||
public boolean hasDurableReceipt(UUID voteId) {
|
||||
return durableReceipts != null && durableReceipts.contains(voteId);
|
||||
}
|
||||
|
||||
/** Durably retires a completed receipt after the proxy confirms outbox removal. */
|
||||
public boolean releaseCompletedReceipt(UUID voteId) {
|
||||
if (voteId == null) return false;
|
||||
if (durableReceipts == null) {
|
||||
completedVotes.remove(voteId);
|
||||
processedVotes.remove(voteId);
|
||||
return true;
|
||||
}
|
||||
long expiresAt = durableReceipts.release(voteId);
|
||||
if (expiresAt <= 0L) return false;
|
||||
completedVotes.add(voteId);
|
||||
processedVotes.put(voteId, expiresAt);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Deduplicates one Redis envelope across overlapping subscribers during a validated handoff. */
|
||||
public synchronized boolean reserveRedisDelivery(String deliveryId) {
|
||||
if (deliveryId == null || !deliveryId.matches("[0-9a-fA-F-]{36}")) return true;
|
||||
@@ -82,14 +193,18 @@ public class ProcessedVoteCache {
|
||||
return true;
|
||||
}
|
||||
|
||||
public synchronized void registerRedisSubscriber(Object subscriber) {
|
||||
public synchronized boolean registerRedisSubscriber(Object subscriber) {
|
||||
if (activeRedisSubscriber == null) {
|
||||
activeRedisSubscriber = subscriber;
|
||||
return true;
|
||||
} else if (activeRedisSubscriber != subscriber) {
|
||||
standbyRedisSubscriber = subscriber;
|
||||
legacyRedisDeliveries.clear();
|
||||
legacyRedisDeliveryBytes = 0;
|
||||
legacyRedisHandoffOverflowed = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Returns true only for the active subscriber and counts its legacy delivery during overlap. */
|
||||
@@ -107,12 +222,16 @@ public class ProcessedVoteCache {
|
||||
&& legacyRedisDeliveryBytes <= MAX_LEGACY_REDIS_TOTAL_BYTES - bytes) {
|
||||
legacyRedisDeliveries.put(signature, 1);
|
||||
legacyRedisDeliveryBytes += bytes;
|
||||
}
|
||||
} else legacyRedisHandoffOverflowed = true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public synchronized boolean isLegacyRedisHandoffOverflowed() {
|
||||
return legacyRedisHandoffOverflowed;
|
||||
}
|
||||
|
||||
public synchronized void activateRedisSubscriber(Object subscriber) {
|
||||
if (standbyRedisSubscriber != subscriber) {
|
||||
throw new IllegalStateException("Redis replacement subscriber is not registered");
|
||||
@@ -121,6 +240,15 @@ public class ProcessedVoteCache {
|
||||
standbyRedisSubscriber = null;
|
||||
}
|
||||
|
||||
/** Restores the retired active listener after a promoted replacement is rolled back. */
|
||||
public synchronized void restoreRedisSubscriber(Object subscriber) {
|
||||
activeRedisSubscriber = subscriber;
|
||||
standbyRedisSubscriber = null;
|
||||
legacyRedisDeliveries.clear();
|
||||
legacyRedisDeliveryBytes = 0;
|
||||
legacyRedisHandoffOverflowed = false;
|
||||
}
|
||||
|
||||
/** Consumes one matching delivery processed by the previous active subscriber. */
|
||||
public synchronized boolean consumeLegacyRedisDelivery(String signature) {
|
||||
Integer count = legacyRedisDeliveries.get(signature);
|
||||
@@ -135,6 +263,7 @@ public class ProcessedVoteCache {
|
||||
public synchronized void finishRedisHandoff() {
|
||||
legacyRedisDeliveries.clear();
|
||||
legacyRedisDeliveryBytes = 0;
|
||||
legacyRedisHandoffOverflowed = false;
|
||||
}
|
||||
|
||||
public synchronized void unregisterRedisSubscriber(Object subscriber) {
|
||||
@@ -142,12 +271,23 @@ public class ProcessedVoteCache {
|
||||
standbyRedisSubscriber = null;
|
||||
legacyRedisDeliveries.clear();
|
||||
legacyRedisDeliveryBytes = 0;
|
||||
legacyRedisHandoffOverflowed = false;
|
||||
}
|
||||
if (activeRedisSubscriber == subscriber) activeRedisSubscriber = null;
|
||||
}
|
||||
|
||||
private void cleanup(long now) {
|
||||
processedVotes.entrySet().removeIf(entry -> entry.getValue() <= now);
|
||||
processedVotes.forEach((voteId, expiresAt) -> {
|
||||
if (expiresAt <= now && processedVotes.remove(voteId, expiresAt)) {
|
||||
completedVotes.remove(voteId);
|
||||
admittedVotes.remove(voteId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private boolean hasAdmissionCapacity() {
|
||||
if (durableReceipts == null) return processedVotes.size() < maxTrackedVotes;
|
||||
return durableReceipts.hasCompletionCapacity(admittedVotes.size() + 1);
|
||||
}
|
||||
|
||||
/** Returns an exact UTF-8 length up to the per-delivery cap, then cap + 1. */
|
||||
|
||||
+656
-50
@@ -2,11 +2,19 @@ package com.bencodez.votingplugin.backendproxy.global;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import com.bencodez.advancedcore.api.time.TimeType;
|
||||
@@ -26,49 +34,224 @@ import lombok.Getter;
|
||||
* Owns backend global-data polling and proxy-driven time-change processing.
|
||||
*/
|
||||
public class BackendGlobalDataSync {
|
||||
private static final long CLOSE_GRACE_SECONDS = 5L;
|
||||
private static final Map<VotingPluginMain, GlobalWorkAdmissions> GLOBAL_WORK_ADMISSIONS = new WeakHashMap<>();
|
||||
|
||||
private final VotingPluginMain plugin;
|
||||
private final Consumer<JsonEnvelope> sender;
|
||||
private final Object senderLock = new Object();
|
||||
private Consumer<JsonEnvelope> sender;
|
||||
private BackendGlobalDataSync completionReplacement;
|
||||
private boolean senderHandedOff;
|
||||
private final Set<TimeType> timeChangesInProgress = ConcurrentHashMap.newKeySet();
|
||||
private final GlobalWorkAdmissions globalWorkAdmissions;
|
||||
private final Object timeChangeLifecycleLock = new Object();
|
||||
private final Map<GlobalDataHandler, Integer> activeGlobalWorkByHandler = new HashMap<>();
|
||||
private final Set<GlobalDataHandler> retiredOwnedHandlers = new HashSet<>();
|
||||
private volatile boolean acceptingWork = true;
|
||||
private volatile ForceUpdateAdmission activeForceUpdate;
|
||||
|
||||
@Getter
|
||||
private GlobalDataHandler globalDataHandler;
|
||||
private boolean ownsGlobalMysql;
|
||||
@Getter
|
||||
private ScheduledExecutorService timer;
|
||||
|
||||
public BackendGlobalDataSync(VotingPluginMain plugin, Consumer<JsonEnvelope> sender) {
|
||||
this.plugin = plugin;
|
||||
this.sender = sender;
|
||||
// Runtime replacements use the same plugin instance. Share each period fence
|
||||
// and the aggregate completion state across those generations. A process
|
||||
// restart intentionally gets a fresh state so persisted flags remain eligible
|
||||
// for recovery.
|
||||
synchronized (GLOBAL_WORK_ADMISSIONS) {
|
||||
globalWorkAdmissions = GLOBAL_WORK_ADMISSIONS.computeIfAbsent(plugin,
|
||||
ignored -> new GlobalWorkAdmissions());
|
||||
}
|
||||
}
|
||||
|
||||
public void checkGlobalData() {
|
||||
if (globalDataHandler == null) {
|
||||
GlobalDataHandler pollingHandler = acquireHandlerReference();
|
||||
if (pollingHandler == null) return;
|
||||
try {
|
||||
String serverName = plugin.getBungeeSettings().getServer();
|
||||
HashMap<String, DataValue> data = pollingHandler.getExact(serverName);
|
||||
retryTimeChangeFinalization(pollingHandler, serverName);
|
||||
|
||||
if (data.containsKey("ForceUpdate")) {
|
||||
String requestId = forceUpdateRequestId(data);
|
||||
if (checkGlobalDataTimeValue(data.get("ForceUpdate"))) {
|
||||
ForceUpdateAdmission admission = admitForceUpdate(pollingHandler, requestId);
|
||||
if (admission != null) startForceUpdate(admission, serverName);
|
||||
} else {
|
||||
globalWorkAdmissions.releaseAcknowledgedForceUpdate(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
checkGlobalDataTime(TimeType.MONTH, data, pollingHandler);
|
||||
checkGlobalDataTime(TimeType.WEEK, data, pollingHandler);
|
||||
checkGlobalDataTime(TimeType.DAY, data, pollingHandler);
|
||||
} finally {
|
||||
releaseHandlerReference(pollingHandler);
|
||||
}
|
||||
}
|
||||
|
||||
private String forceUpdateRequestId(HashMap<String, DataValue> data) {
|
||||
DataValue value = data.get("ForceUpdateId");
|
||||
if (value == null) return null;
|
||||
String requestId = value.getString();
|
||||
return requestId == null || requestId.isBlank() ? null : requestId;
|
||||
}
|
||||
|
||||
private ForceUpdateAdmission admitForceUpdate(GlobalDataHandler expectedHandler, String requestId) {
|
||||
GlobalDataHandler handler;
|
||||
ForceUpdateAdmission admission = null;
|
||||
synchronized (timeChangeLifecycleLock) {
|
||||
handler = acceptingWork ? globalDataHandler : null;
|
||||
if (handler != null && handler == expectedHandler) {
|
||||
ForceUpdateGrant grant = globalWorkAdmissions.admitForceUpdate(this, requestId);
|
||||
if (grant != null) {
|
||||
activeGlobalWorkByHandler.merge(handler, 1, Integer::sum);
|
||||
admission = new ForceUpdateAdmission(handler, grant.fence(), grant.acknowledgmentOnly());
|
||||
activeForceUpdate = admission;
|
||||
}
|
||||
}
|
||||
}
|
||||
return admission;
|
||||
}
|
||||
|
||||
private void startForceUpdate(ForceUpdateAdmission admission, String serverName) {
|
||||
try {
|
||||
if (!admission.isPending()) return;
|
||||
if (admission.acknowledgmentOnly()) {
|
||||
finishForceUpdate(admission, serverName);
|
||||
return;
|
||||
}
|
||||
if (UserStorage.MYSQL.equals(plugin.getStorageType())) plugin.getMysql().clearCacheBasic();
|
||||
if (!acceptingWork || !admission.isPending()) {
|
||||
releaseForceUpdate(admission);
|
||||
return;
|
||||
}
|
||||
plugin.getBukkitScheduler().executeOrScheduleSync(plugin, () -> continueForceUpdate(admission, serverName));
|
||||
} catch (RuntimeException failure) {
|
||||
releaseForceUpdate(admission);
|
||||
plugin.debug(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void continueForceUpdate(ForceUpdateAdmission admission, String serverName) {
|
||||
if (!acceptingWork || !admission.isPending()) {
|
||||
releaseForceUpdate(admission);
|
||||
return;
|
||||
}
|
||||
HashMap<String, DataValue> data = globalDataHandler.getExact(plugin.getBungeeSettings().getServer());
|
||||
|
||||
if (data.containsKey("ForceUpdate") && checkGlobalDataTimeValue(data.get("ForceUpdate"))) {
|
||||
if (plugin.getStorageType().equals(UserStorage.MYSQL)) {
|
||||
plugin.getMysql().clearCacheBasic();
|
||||
}
|
||||
plugin.getUserManager().getDataManager().clearCache();
|
||||
plugin.setUpdate(true);
|
||||
plugin.update();
|
||||
globalDataHandler.setBoolean(plugin.getBungeeSettings().getServer(), "ForceUpdate", false);
|
||||
try {
|
||||
plugin.getUserManager().getDataManager().clearCacheAsyncCompletion().whenComplete((ignored, failure) -> {
|
||||
if (failure != null) {
|
||||
releaseForceUpdate(admission);
|
||||
plugin.debug(failure);
|
||||
return;
|
||||
}
|
||||
if (!acceptingWork || !admission.isPending()) {
|
||||
releaseForceUpdate(admission);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
plugin.getBukkitScheduler().runTaskAsynchronously(plugin,
|
||||
() -> finishForceUpdate(admission, serverName));
|
||||
} catch (RuntimeException schedulingFailure) {
|
||||
releaseForceUpdate(admission);
|
||||
plugin.debug(schedulingFailure);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException failure) {
|
||||
releaseForceUpdate(admission);
|
||||
plugin.debug(failure);
|
||||
}
|
||||
}
|
||||
|
||||
boolean forceUpdate = checkGlobalDataTime(TimeType.MONTH, data);
|
||||
forceUpdate |= checkGlobalDataTime(TimeType.WEEK, data);
|
||||
forceUpdate |= checkGlobalDataTime(TimeType.DAY, data);
|
||||
private void finishForceUpdate(ForceUpdateAdmission admission, String serverName) {
|
||||
if (!acceptingWork) {
|
||||
if (admission.acknowledgmentOnly()) retainForceUpdateAcknowledgment(admission);
|
||||
else releaseForceUpdate(admission);
|
||||
return;
|
||||
}
|
||||
if (!admission.beginExecution()) return;
|
||||
try {
|
||||
if (!admission.acknowledgmentOnly()) {
|
||||
plugin.setUpdate(true);
|
||||
plugin.update();
|
||||
globalWorkAdmissions.markForceUpdateEffectApplied(admission.fence());
|
||||
}
|
||||
acknowledgeForceUpdate(admission.handler(), serverName, admission.fence().requestId);
|
||||
} catch (RuntimeException failure) {
|
||||
plugin.debug(failure);
|
||||
if (globalWorkAdmissions.isForceUpdateEffectApplied(admission.fence())) {
|
||||
retainForceUpdateAcknowledgment(admission);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
if (!admission.isReleased()) releaseForceUpdate(admission);
|
||||
}
|
||||
}
|
||||
|
||||
if (forceUpdate) {
|
||||
HashMap<String, DataValue> dataToSet = new HashMap<>();
|
||||
dataToSet.put("FinishedProcessing", new DataValueBoolean(true));
|
||||
dataToSet.put("Processing", new DataValueBoolean(false));
|
||||
globalDataHandler.setData(plugin.getBungeeSettings().getServer(), dataToSet);
|
||||
private void acknowledgeForceUpdate(GlobalDataHandler handler, String serverName, String requestId) {
|
||||
if (requestId == null) {
|
||||
handler.setBoolean(serverName, "ForceUpdate", false);
|
||||
return;
|
||||
}
|
||||
String escapedServer = serverName.replace("'", "''");
|
||||
String escapedRequest = requestId.replace("'", "''");
|
||||
handler.getGlobalMysql().executeQuery("UPDATE %tablename% SET ForceUpdate='false' WHERE server='"
|
||||
+ escapedServer + "' AND ForceUpdateId='" + escapedRequest + "';");
|
||||
HashMap<String, DataValue> current = handler.getExact(serverName);
|
||||
if (!current.containsKey("ForceUpdateId") || !current.containsKey("ForceUpdate")) {
|
||||
throw new IllegalStateException("ForceUpdate acknowledgment could not be verified");
|
||||
}
|
||||
String currentRequest = forceUpdateRequestId(current);
|
||||
if (requestId.equals(currentRequest) && checkGlobalDataTimeValue(current.get("ForceUpdate"))) {
|
||||
throw new IllegalStateException("ForceUpdate acknowledgment was not persisted");
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseForceUpdate(ForceUpdateAdmission admission) {
|
||||
if (!admission.release()) return;
|
||||
releaseForceUpdateResources(admission);
|
||||
}
|
||||
|
||||
private void releaseForceUpdateResources(ForceUpdateAdmission admission) {
|
||||
synchronized (timeChangeLifecycleLock) {
|
||||
if (activeForceUpdate == admission) activeForceUpdate = null;
|
||||
}
|
||||
globalWorkAdmissions.releaseForceUpdate(admission.fence());
|
||||
releaseHandlerReference(admission.handler());
|
||||
}
|
||||
|
||||
private void retainForceUpdateAcknowledgment(ForceUpdateAdmission admission) {
|
||||
if (!admission.release()) return;
|
||||
retainForceUpdateAcknowledgmentResources(admission);
|
||||
}
|
||||
|
||||
private void retainForceUpdateAcknowledgmentResources(ForceUpdateAdmission admission) {
|
||||
synchronized (timeChangeLifecycleLock) {
|
||||
if (activeForceUpdate == admission) activeForceUpdate = null;
|
||||
}
|
||||
globalWorkAdmissions.retryForceUpdateAcknowledgment(admission.fence());
|
||||
releaseHandlerReference(admission.handler());
|
||||
}
|
||||
|
||||
private GlobalDataHandler acquireHandlerReference() {
|
||||
synchronized (timeChangeLifecycleLock) {
|
||||
GlobalDataHandler handler = acceptingWork ? globalDataHandler : null;
|
||||
if (handler != null) activeGlobalWorkByHandler.merge(handler, 1, Integer::sum);
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean checkGlobalDataTime(TimeType type, HashMap<String, DataValue> data) {
|
||||
return checkGlobalDataTime(type, data, globalDataHandler);
|
||||
}
|
||||
|
||||
private boolean checkGlobalDataTime(TimeType type, HashMap<String, DataValue> data,
|
||||
GlobalDataHandler expectedHandler) {
|
||||
if (!data.containsKey(type.toString()) || !checkGlobalDataTimeValue(data.get(type.toString()))) {
|
||||
return false;
|
||||
}
|
||||
@@ -77,21 +260,189 @@ public class BackendGlobalDataSync {
|
||||
plugin.debug("LastUpdated: " + lastUpdated);
|
||||
if (LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant().toEpochMilli() - lastUpdated > 1000 * 60 * 60 * 2) {
|
||||
plugin.getLogger().warning("Ignoring bungee time change since it was more than 2 hours ago");
|
||||
globalDataHandler.setBoolean(plugin.getBungeeSettings().getServer(), type.toString(), false);
|
||||
if (!isCurrentHandler(expectedHandler)) return false;
|
||||
expectedHandler.setBoolean(plugin.getBungeeSettings().getServer(), type.toString(), false);
|
||||
return false;
|
||||
}
|
||||
|
||||
globalDataHandler.setBoolean(plugin.getBungeeSettings().getServer(), "Processing", true);
|
||||
String serverName = plugin.getBungeeSettings().getServer();
|
||||
GlobalDataHandler transitionHandler = admitTimeChange(type, serverName, expectedHandler);
|
||||
if (transitionHandler == null) return false;
|
||||
plugin.debug("Detected time change from bungee: " + type.toString());
|
||||
plugin.getTimeChecker().forceChanged(type, false, true, true);
|
||||
globalDataHandler.setBoolean(plugin.getBungeeSettings().getServer(), type.toString(), false);
|
||||
|
||||
JsonEnvelope.Builder builder = JsonEnvelope.builder("TimeChangeFinished").schema(VotingPluginWire.SCHEMA_VERSION);
|
||||
builder.put("server", plugin.getBungeeSettings().getServer());
|
||||
sender.accept(builder.build());
|
||||
ScheduledExecutorService transitionExecutor = plugin.getTimeChecker().getTimer();
|
||||
if (transitionExecutor == null) {
|
||||
releaseTimeChange(transitionHandler, type, serverName, false);
|
||||
plugin.debug("Unable to process proxy time change before the time checker is ready");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
transitionExecutor.execute(() -> {
|
||||
try {
|
||||
plugin.getTimeChecker().forceChanged(type, false, true, true);
|
||||
} catch (RuntimeException failure) {
|
||||
releaseTimeChange(transitionHandler, type, serverName, false);
|
||||
plugin.debug(failure);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
finishTimeChange(transitionHandler, type, serverName);
|
||||
} catch (RuntimeException failure) {
|
||||
// finishTimeChange releases the pinned handler in its finally block.
|
||||
plugin.debug(failure);
|
||||
}
|
||||
});
|
||||
} catch (RejectedExecutionException failure) {
|
||||
releaseTimeChange(transitionHandler, type, serverName, false);
|
||||
plugin.debug(failure);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean isCurrentHandler(GlobalDataHandler expectedHandler) {
|
||||
synchronized (timeChangeLifecycleLock) {
|
||||
return acceptingWork && expectedHandler != null && globalDataHandler == expectedHandler;
|
||||
}
|
||||
}
|
||||
|
||||
private GlobalDataHandler admitTimeChange(TimeType type, String serverName,
|
||||
GlobalDataHandler expectedHandler) {
|
||||
GlobalDataHandler handler;
|
||||
synchronized (timeChangeLifecycleLock) {
|
||||
handler = acceptingWork ? globalDataHandler : null;
|
||||
if (handler == null || handler != expectedHandler
|
||||
|| !globalWorkAdmissions.admitTimeChange(this, type)) return null;
|
||||
if (!timeChangesInProgress.add(type)) {
|
||||
globalWorkAdmissions.cancelTimeChangeAdmission(this, type);
|
||||
return null;
|
||||
}
|
||||
activeGlobalWorkByHandler.merge(handler, 1, Integer::sum);
|
||||
}
|
||||
try {
|
||||
synchronized (globalWorkAdmissions.persistenceLock()) {
|
||||
handler.setBoolean(serverName, "Processing", true);
|
||||
}
|
||||
return handler;
|
||||
} catch (RuntimeException failure) {
|
||||
try {
|
||||
releaseTimeChange(handler, type, serverName, false);
|
||||
} catch (RuntimeException cleanupFailure) {
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private void finishTimeChange(GlobalDataHandler handler, TimeType type, String serverName) {
|
||||
boolean completed = false;
|
||||
try {
|
||||
handler.setBoolean(serverName, type.toString(), false);
|
||||
JsonEnvelope.Builder builder = JsonEnvelope.builder("TimeChangeFinished")
|
||||
.schema(VotingPluginWire.SCHEMA_VERSION);
|
||||
builder.put("server", serverName);
|
||||
sendTimeChangeFinished(builder.build());
|
||||
completed = true;
|
||||
} finally {
|
||||
releaseTimeChange(handler, type, serverName, completed);
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseTimeChange(GlobalDataHandler handler, TimeType type, String serverName,
|
||||
boolean completed) {
|
||||
try {
|
||||
synchronized (timeChangeLifecycleLock) {
|
||||
timeChangesInProgress.remove(type);
|
||||
}
|
||||
persistTimeChangeFinalization(handler, serverName,
|
||||
globalWorkAdmissions.releaseTimeChange(this, type, completed));
|
||||
} finally {
|
||||
releaseHandlerReference(handler);
|
||||
}
|
||||
}
|
||||
|
||||
private void retryTimeChangeFinalization(GlobalDataHandler handler, String serverName) {
|
||||
try {
|
||||
persistTimeChangeFinalization(handler, serverName,
|
||||
globalWorkAdmissions.claimTimeChangeFinalization(this));
|
||||
} catch (RuntimeException failure) {
|
||||
plugin.debug(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void persistTimeChangeFinalization(GlobalDataHandler handler, String serverName,
|
||||
TimeChangeRelease release) {
|
||||
if (!release.finalizeBatch()) return;
|
||||
boolean persisted = false;
|
||||
try {
|
||||
synchronized (globalWorkAdmissions.persistenceLock()) {
|
||||
HashMap<String, DataValue> dataToSet = new HashMap<>();
|
||||
if (release.batchCompleted()) dataToSet.put("FinishedProcessing", new DataValueBoolean(true));
|
||||
dataToSet.put("Processing", new DataValueBoolean(false));
|
||||
handler.setData(serverName, dataToSet);
|
||||
persisted = true;
|
||||
}
|
||||
} finally {
|
||||
if (persisted) globalWorkAdmissions.finishTimeChangeRelease();
|
||||
else globalWorkAdmissions.retryTimeChangeFinalization();
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseHandlerReference(GlobalDataHandler handler) {
|
||||
GlobalMySQL closeAfterRelease;
|
||||
synchronized (timeChangeLifecycleLock) {
|
||||
closeAfterRelease = releaseHandlerReferenceLocked(handler);
|
||||
timeChangeLifecycleLock.notifyAll();
|
||||
}
|
||||
if (closeAfterRelease != null) closeAfterRelease.close();
|
||||
}
|
||||
|
||||
private void sendTimeChangeFinished(JsonEnvelope envelope) {
|
||||
Consumer<JsonEnvelope> completionSender;
|
||||
BackendGlobalDataSync replacement;
|
||||
synchronized (senderLock) {
|
||||
replacement = completionReplacement;
|
||||
if (replacement == null && sender == null) {
|
||||
throw new RejectedExecutionException("Backend proxy transport retired before time-change completion");
|
||||
}
|
||||
completionSender = sender;
|
||||
}
|
||||
if (replacement != null) {
|
||||
replacement.sendTimeChangeFinished(envelope);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
completionSender.accept(envelope);
|
||||
} catch (RuntimeException sendFailure) {
|
||||
synchronized (senderLock) {
|
||||
replacement = completionReplacement;
|
||||
}
|
||||
if (replacement == null) throw sendFailure;
|
||||
try {
|
||||
replacement.sendTimeChangeFinished(envelope);
|
||||
} catch (RuntimeException replacementFailure) {
|
||||
replacementFailure.addSuppressed(sendFailure);
|
||||
throw replacementFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Routes an admitted transition's final notification through the published replacement. */
|
||||
public void handoffCompletionSender(BackendGlobalDataSync replacement) {
|
||||
synchronized (senderLock) {
|
||||
completionReplacement = java.util.Objects.requireNonNull(replacement, "replacement");
|
||||
senderHandedOff = true;
|
||||
}
|
||||
}
|
||||
|
||||
private GlobalMySQL releaseHandlerReferenceLocked(GlobalDataHandler handler) {
|
||||
Integer references = activeGlobalWorkByHandler.get(handler);
|
||||
if (references == null || references <= 1) {
|
||||
activeGlobalWorkByHandler.remove(handler);
|
||||
return retiredOwnedHandlers.remove(handler) ? handler.getGlobalMysql() : null;
|
||||
}
|
||||
activeGlobalWorkByHandler.put(handler, references - 1);
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean checkGlobalDataTimeValue(DataValue data) {
|
||||
if (data.isBoolean()) {
|
||||
return data.getBoolean();
|
||||
@@ -103,30 +454,26 @@ public class BackendGlobalDataSync {
|
||||
if (!plugin.getBungeeSettings().isGloblalDataEnabled()) {
|
||||
return;
|
||||
}
|
||||
acceptingWork = false;
|
||||
|
||||
shutdownTimer();
|
||||
timer = Executors.newScheduledThreadPool(1);
|
||||
timer.scheduleWithFixedDelay(this::checkGlobalData, 60, 10, TimeUnit.SECONDS);
|
||||
timer.scheduleWithFixedDelay(() -> {
|
||||
if (globalDataHandler != null) {
|
||||
globalDataHandler.setString(plugin.getBungeeSettings().getServer(), "LastOnline",
|
||||
"" + LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant().toEpochMilli());
|
||||
}
|
||||
}, 1, 60, TimeUnit.MINUTES);
|
||||
|
||||
closeGlobalMysql();
|
||||
long retirementDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(CLOSE_GRACE_SECONDS);
|
||||
shutdownTimer(retirementDeadline);
|
||||
retireGlobalMysql(retirementDeadline);
|
||||
|
||||
GlobalDataHandler loadedHandler;
|
||||
boolean loadedHandlerOwnsMysql;
|
||||
if (plugin.getBungeeSettings().isGloblalDataUseMainMySQL()
|
||||
&& plugin.getStorageType().equals(UserStorage.MYSQL)) {
|
||||
globalDataHandler = new GlobalDataHandler(new GlobalMySQL("VotingPlugin_GlobalData", plugin.getMysql().getMysql()) {
|
||||
loadedHandler = new GlobalDataHandler(new GlobalMySQL("VotingPlugin_GlobalData", plugin.getMysql().getMysql()) {
|
||||
@Override public void debugEx(Exception e) { plugin.debug(e); }
|
||||
@Override public void debugLog(String text) { plugin.debug(text); }
|
||||
@Override public void info(String text) { plugin.getLogger().info(text); }
|
||||
@Override public void logSevere(String text) { plugin.getLogger().severe(text); }
|
||||
@Override public void warning(String text) { plugin.getLogger().warning(text); }
|
||||
});
|
||||
loadedHandlerOwnsMysql = false;
|
||||
} else {
|
||||
globalDataHandler = new GlobalDataHandler(new GlobalMySQL("VotingPlugin_GlobalData",
|
||||
loadedHandler = new GlobalDataHandler(new GlobalMySQL("VotingPlugin_GlobalData",
|
||||
new MysqlConfigSpigot(plugin.getBungeeSettings().getData().getConfigurationSection("GlobalData"))) {
|
||||
@Override public void debugEx(Exception e) { plugin.debug(e); }
|
||||
@Override public void debugLog(String text) { plugin.debug(text); }
|
||||
@@ -134,6 +481,11 @@ public class BackendGlobalDataSync {
|
||||
@Override public void logSevere(String text) { plugin.getLogger().severe(text); }
|
||||
@Override public void warning(String text) { plugin.getLogger().warning(text); }
|
||||
});
|
||||
loadedHandlerOwnsMysql = true;
|
||||
}
|
||||
synchronized (timeChangeLifecycleLock) {
|
||||
globalDataHandler = loadedHandler;
|
||||
ownsGlobalMysql = loadedHandlerOwnsMysql;
|
||||
}
|
||||
|
||||
for (Map.Entry<String, String> column : Map.of(
|
||||
@@ -144,34 +496,288 @@ public class BackendGlobalDataSync {
|
||||
"FinishedProcessing", "VARCHAR(5)",
|
||||
"Processing", "VARCHAR(5)",
|
||||
"LastUpdated", "MEDIUMTEXT",
|
||||
"ForceUpdateId", "VARCHAR(36)",
|
||||
"ForceUpdate", "VARCHAR(5)").entrySet()) {
|
||||
globalDataHandler.getGlobalMysql().alterColumnType(column.getKey(), column.getValue());
|
||||
loadedHandler.getGlobalMysql().alterColumnType(column.getKey(), column.getValue());
|
||||
}
|
||||
acceptingWork = true;
|
||||
timer = Executors.newSingleThreadScheduledExecutor(task -> {
|
||||
Thread thread = new Thread(task, "VotingPlugin-GlobalData");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
timer.scheduleWithFixedDelay(this::checkGlobalData, 60, 10, TimeUnit.SECONDS);
|
||||
timer.scheduleWithFixedDelay(this::updateLastOnline, 1, 60, TimeUnit.MINUTES);
|
||||
plugin.getTimeChecker().setProcessingEnabled(false);
|
||||
}
|
||||
|
||||
public void close() {
|
||||
shutdownTimer();
|
||||
closeGlobalMysql();
|
||||
close(CLOSE_GRACE_SECONDS, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private void closeGlobalMysql() {
|
||||
if (globalDataHandler != null) {
|
||||
globalDataHandler.getGlobalMysql().close();
|
||||
void close(long timeout, TimeUnit unit) {
|
||||
acceptingWork = false;
|
||||
ForceUpdateAdmission forceUpdate = activeForceUpdate;
|
||||
if (forceUpdate != null && forceUpdate.cancelPending()) {
|
||||
if (forceUpdate.acknowledgmentOnly()) retainForceUpdateAcknowledgmentResources(forceUpdate);
|
||||
else releaseForceUpdateResources(forceUpdate);
|
||||
}
|
||||
long timeoutNanos = Math.max(0L, unit.toNanos(timeout));
|
||||
long deadline = System.nanoTime() + timeoutNanos;
|
||||
shutdownTimer(deadline);
|
||||
retireGlobalMysql(deadline);
|
||||
synchronized (senderLock) {
|
||||
if (!senderHandedOff) sender = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void shutdownTimer() {
|
||||
private void retireGlobalMysql(long deadlineNanos) {
|
||||
GlobalMySQL closeNow = null;
|
||||
GlobalDataHandler deferred = null;
|
||||
synchronized (timeChangeLifecycleLock) {
|
||||
GlobalDataHandler previous = globalDataHandler;
|
||||
boolean closeConnection = ownsGlobalMysql;
|
||||
globalDataHandler = null;
|
||||
ownsGlobalMysql = false;
|
||||
if (previous != null && activeGlobalWorkByHandler.containsKey(previous)) {
|
||||
deferred = previous;
|
||||
if (closeConnection) retiredOwnedHandlers.add(previous);
|
||||
} else if (previous != null && closeConnection) {
|
||||
closeNow = previous.getGlobalMysql();
|
||||
}
|
||||
}
|
||||
if (closeNow != null) closeNow.close();
|
||||
if (deferred != null) awaitOrForceRetiredHandlerClose(deferred, deadlineNanos);
|
||||
BackendGlobalDataSync replacement;
|
||||
synchronized (senderLock) {
|
||||
replacement = completionReplacement;
|
||||
}
|
||||
// Failed finalization and pending acknowledgment retries may release their SQL
|
||||
// references before retirement observes active work. Transfer both fences.
|
||||
globalWorkAdmissions.transferTimeChangeOwner(this, replacement);
|
||||
globalWorkAdmissions.transferForceUpdateOwner(this, replacement);
|
||||
}
|
||||
|
||||
void updateLastOnline() {
|
||||
GlobalDataHandler pollingHandler = acquireHandlerReference();
|
||||
if (pollingHandler == null) return;
|
||||
try {
|
||||
pollingHandler.setString(plugin.getBungeeSettings().getServer(), "LastOnline",
|
||||
"" + LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant().toEpochMilli());
|
||||
} finally {
|
||||
releaseHandlerReference(pollingHandler);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean awaitOrForceRetiredHandlerClose(GlobalDataHandler handler, long deadlineNanos) {
|
||||
GlobalMySQL forceClose = null;
|
||||
boolean interrupted = false;
|
||||
boolean stillActive;
|
||||
synchronized (timeChangeLifecycleLock) {
|
||||
while (activeGlobalWorkByHandler.containsKey(handler)) {
|
||||
long remaining = deadlineNanos - System.nanoTime();
|
||||
if (remaining <= 0L) break;
|
||||
try {
|
||||
TimeUnit.NANOSECONDS.timedWait(timeChangeLifecycleLock, remaining);
|
||||
} catch (InterruptedException failure) {
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (activeGlobalWorkByHandler.containsKey(handler) && retiredOwnedHandlers.remove(handler)) {
|
||||
forceClose = handler.getGlobalMysql();
|
||||
}
|
||||
stillActive = activeGlobalWorkByHandler.containsKey(handler);
|
||||
}
|
||||
if (forceClose != null) {
|
||||
plugin.getLogger().warning(
|
||||
"Forcing an owned global-data connection closed after its time-change shutdown grace expired");
|
||||
forceClose.close();
|
||||
}
|
||||
if (interrupted) Thread.currentThread().interrupt();
|
||||
return stillActive;
|
||||
}
|
||||
|
||||
private void shutdownTimer(long deadlineNanos) {
|
||||
if (timer == null) {
|
||||
return;
|
||||
}
|
||||
timer.shutdown();
|
||||
try {
|
||||
timer.awaitTermination(5, TimeUnit.SECONDS);
|
||||
long remaining = Math.max(0L, deadlineNanos - System.nanoTime());
|
||||
timer.awaitTermination(remaining, TimeUnit.NANOSECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
timer.shutdownNow();
|
||||
timer = null;
|
||||
}
|
||||
|
||||
private record ForceUpdateAdmission(GlobalDataHandler handler, ForceUpdateFence fence,
|
||||
boolean acknowledgmentOnly, AtomicInteger phase) {
|
||||
private static final int PENDING = 0;
|
||||
private static final int EXECUTING = 1;
|
||||
private static final int RELEASED = 2;
|
||||
|
||||
private ForceUpdateAdmission(GlobalDataHandler handler, ForceUpdateFence fence,
|
||||
boolean acknowledgmentOnly) {
|
||||
this(handler, fence, acknowledgmentOnly, new AtomicInteger(PENDING));
|
||||
}
|
||||
|
||||
private boolean beginExecution() {
|
||||
return phase.compareAndSet(PENDING, EXECUTING);
|
||||
}
|
||||
|
||||
private boolean isPending() {
|
||||
return phase.get() == PENDING;
|
||||
}
|
||||
|
||||
private boolean cancelPending() {
|
||||
return phase.compareAndSet(PENDING, RELEASED);
|
||||
}
|
||||
|
||||
private boolean release() {
|
||||
return phase.getAndSet(RELEASED) != RELEASED;
|
||||
}
|
||||
|
||||
private boolean isReleased() {
|
||||
return phase.get() == RELEASED;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ForceUpdateFence {
|
||||
private final String requestId;
|
||||
private boolean effectApplied;
|
||||
private boolean acknowledgmentClaimed;
|
||||
|
||||
private ForceUpdateFence(String requestId) {
|
||||
this.requestId = requestId;
|
||||
}
|
||||
}
|
||||
|
||||
private record ForceUpdateGrant(ForceUpdateFence fence, boolean acknowledgmentOnly) {
|
||||
}
|
||||
|
||||
private static final class GlobalWorkAdmissions {
|
||||
private final Map<TimeType, BackendGlobalDataSync> activeTimeChanges = new EnumMap<>(TimeType.class);
|
||||
private final Object persistenceLock = new Object();
|
||||
private BackendGlobalDataSync timeChangeOwner;
|
||||
private BackendGlobalDataSync forceUpdateOwner;
|
||||
private ForceUpdateFence forceUpdateFence;
|
||||
private boolean timeChangeBatchFailed;
|
||||
private boolean timeChangeBatchFinalizing;
|
||||
private boolean timeChangeFinalizationClaimed;
|
||||
|
||||
private synchronized boolean admitTimeChange(BackendGlobalDataSync requester, TimeType type) {
|
||||
if (timeChangeBatchFinalizing) return false;
|
||||
if (timeChangeOwner != null && timeChangeOwner != requester) return false;
|
||||
if (activeTimeChanges.containsKey(type)) return false;
|
||||
if (activeTimeChanges.isEmpty()) timeChangeBatchFailed = false;
|
||||
activeTimeChanges.put(type, requester);
|
||||
timeChangeOwner = requester;
|
||||
return true;
|
||||
}
|
||||
|
||||
private synchronized TimeChangeRelease releaseTimeChange(BackendGlobalDataSync requester, TimeType type,
|
||||
boolean completed) {
|
||||
if (activeTimeChanges.get(type) != requester) return TimeChangeRelease.NONE;
|
||||
activeTimeChanges.remove(type);
|
||||
if (!completed) timeChangeBatchFailed = true;
|
||||
if (!activeTimeChanges.isEmpty()) return TimeChangeRelease.NONE;
|
||||
timeChangeBatchFinalizing = true;
|
||||
timeChangeFinalizationClaimed = true;
|
||||
return new TimeChangeRelease(true, !timeChangeBatchFailed);
|
||||
}
|
||||
|
||||
private synchronized void cancelTimeChangeAdmission(BackendGlobalDataSync requester, TimeType type) {
|
||||
if (activeTimeChanges.get(type) != requester) return;
|
||||
activeTimeChanges.remove(type);
|
||||
if (activeTimeChanges.isEmpty()) timeChangeOwner = null;
|
||||
}
|
||||
|
||||
private synchronized void finishTimeChangeRelease() {
|
||||
if (!timeChangeBatchFinalizing || !activeTimeChanges.isEmpty()) return;
|
||||
timeChangeBatchFinalizing = false;
|
||||
timeChangeFinalizationClaimed = false;
|
||||
timeChangeBatchFailed = false;
|
||||
timeChangeOwner = null;
|
||||
}
|
||||
|
||||
private synchronized TimeChangeRelease claimTimeChangeFinalization(BackendGlobalDataSync requester) {
|
||||
if (!timeChangeBatchFinalizing || timeChangeFinalizationClaimed
|
||||
|| (timeChangeOwner != null && timeChangeOwner != requester)) return TimeChangeRelease.NONE;
|
||||
timeChangeOwner = requester;
|
||||
timeChangeFinalizationClaimed = true;
|
||||
return new TimeChangeRelease(true, !timeChangeBatchFailed);
|
||||
}
|
||||
|
||||
private synchronized void retryTimeChangeFinalization() {
|
||||
if (timeChangeBatchFinalizing) timeChangeFinalizationClaimed = false;
|
||||
}
|
||||
|
||||
private synchronized void transferTimeChangeOwner(BackendGlobalDataSync previous,
|
||||
BackendGlobalDataSync replacement) {
|
||||
if (timeChangeOwner != previous) return;
|
||||
timeChangeOwner = replacement;
|
||||
if (timeChangeBatchFinalizing) timeChangeFinalizationClaimed = false;
|
||||
}
|
||||
|
||||
private Object persistenceLock() {
|
||||
return persistenceLock;
|
||||
}
|
||||
|
||||
private synchronized ForceUpdateGrant admitForceUpdate(BackendGlobalDataSync requester, String requestId) {
|
||||
if (forceUpdateFence == null) {
|
||||
forceUpdateFence = new ForceUpdateFence(requestId);
|
||||
forceUpdateFence.acknowledgmentClaimed = true;
|
||||
forceUpdateOwner = requester;
|
||||
return new ForceUpdateGrant(forceUpdateFence, false);
|
||||
}
|
||||
if (forceUpdateFence.effectApplied && !forceUpdateFence.acknowledgmentClaimed
|
||||
&& (forceUpdateOwner == null || forceUpdateOwner == requester)) {
|
||||
forceUpdateOwner = requester;
|
||||
forceUpdateFence.acknowledgmentClaimed = true;
|
||||
return new ForceUpdateGrant(forceUpdateFence, true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private synchronized void markForceUpdateEffectApplied(ForceUpdateFence fence) {
|
||||
if (forceUpdateFence == fence) fence.effectApplied = true;
|
||||
}
|
||||
|
||||
private synchronized boolean isForceUpdateEffectApplied(ForceUpdateFence fence) {
|
||||
return forceUpdateFence == fence && fence.effectApplied;
|
||||
}
|
||||
|
||||
private synchronized void retryForceUpdateAcknowledgment(ForceUpdateFence fence) {
|
||||
if (forceUpdateFence == fence) fence.acknowledgmentClaimed = false;
|
||||
}
|
||||
|
||||
private synchronized void releaseForceUpdate(ForceUpdateFence fence) {
|
||||
if (forceUpdateFence != fence) return;
|
||||
forceUpdateFence = null;
|
||||
forceUpdateOwner = null;
|
||||
}
|
||||
|
||||
private synchronized void releaseAcknowledgedForceUpdate(String requestId) {
|
||||
if (forceUpdateFence == null || !forceUpdateFence.effectApplied
|
||||
|| !Objects.equals(forceUpdateFence.requestId, requestId)) return;
|
||||
forceUpdateFence = null;
|
||||
forceUpdateOwner = null;
|
||||
}
|
||||
|
||||
private synchronized void transferForceUpdateOwner(BackendGlobalDataSync previous,
|
||||
BackendGlobalDataSync replacement) {
|
||||
if (forceUpdateOwner != previous) return;
|
||||
forceUpdateOwner = replacement;
|
||||
if (forceUpdateFence != null && forceUpdateFence.effectApplied) {
|
||||
forceUpdateFence.acknowledgmentClaimed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record TimeChangeRelease(boolean finalizeBatch, boolean batchCompleted) {
|
||||
private static final TimeChangeRelease NONE = new TimeChangeRelease(false, false);
|
||||
}
|
||||
}
|
||||
+567
-41
@@ -3,12 +3,22 @@ package com.bencodez.votingplugin.backendproxy.messaging;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import com.bencodez.advancedcore.api.user.AdvancedCoreUser;
|
||||
import com.bencodez.advancedcore.api.user.usercache.UserDataManager;
|
||||
|
||||
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
|
||||
import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler;
|
||||
import com.bencodez.simpleapi.servercomm.global.GlobalMessageListener;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache;
|
||||
import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache.Reservation;
|
||||
import com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync;
|
||||
import com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager;
|
||||
import com.bencodez.votingplugin.backendproxy.voteparty.BackendVotePartySync;
|
||||
@@ -23,12 +33,18 @@ import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
* Registers and handles backend-side proxy message routes.
|
||||
*/
|
||||
public class BackendProxyMessageRouter {
|
||||
public enum OrderedVoteOutcome { COMPLETE, RETRY, QUARANTINE }
|
||||
|
||||
private final VotingPluginMain plugin;
|
||||
private final BackendPresenceManager presenceManager;
|
||||
private final BackendGlobalDataSync globalDataSync;
|
||||
private final BackendVotePartySync votePartySync;
|
||||
private final ProcessedVoteCache processedVoteCache;
|
||||
private final AtomicBoolean voteReplayCacheSaturationLogged = new AtomicBoolean();
|
||||
private final Object pendingVoteUpdateLock = new Object();
|
||||
private final Set<PendingVoteUpdateHandoff> pendingVoteUpdateHandoffs = new HashSet<>();
|
||||
private boolean pendingVoteUpdateHandoffsClosing;
|
||||
private GlobalMessageHandler messages;
|
||||
|
||||
public BackendProxyMessageRouter(VotingPluginMain plugin, BackendPresenceManager presenceManager,
|
||||
BackendGlobalDataSync globalDataSync, BackendVotePartySync votePartySync,
|
||||
@@ -41,6 +57,12 @@ public class BackendProxyMessageRouter {
|
||||
}
|
||||
|
||||
public void register(GlobalMessageHandler messages, BungeeMethod method) {
|
||||
this.messages = messages;
|
||||
messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE_SESSION_FENCE_REPLY) {
|
||||
@Override public void onReceive(JsonEnvelope message) {
|
||||
plugin.getGuidedVotingSessions().proxyFence(message);
|
||||
}
|
||||
});
|
||||
messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE) {
|
||||
@Override public void onReceive(JsonEnvelope msg) { handleWireVote(msg); }
|
||||
});
|
||||
@@ -50,6 +72,9 @@ public class BackendProxyMessageRouter {
|
||||
messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE_DELAY_REJECTED) {
|
||||
@Override public void onReceive(JsonEnvelope msg) { handleWireVoteDelayRejected(msg); }
|
||||
});
|
||||
messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE_DELIVERY_RECEIPT_RELEASE) {
|
||||
@Override public void onReceive(JsonEnvelope msg) { handleVoteDeliveryReceiptRelease(messages, msg); }
|
||||
});
|
||||
messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_CONTROL_ENROLLMENT_RESULT) {
|
||||
@Override public void onReceive(JsonEnvelope msg) { plugin.handleBackendControlEnrollmentResult(msg); }
|
||||
});
|
||||
@@ -76,6 +101,10 @@ public class BackendProxyMessageRouter {
|
||||
@Override public void onReceive(JsonEnvelope msg) {
|
||||
HashMap<String, Object> out = new HashMap<>();
|
||||
out.put(VotingPluginWire.K_SERVER, nvl(plugin.getOptions().getServer()));
|
||||
out.put(VotingPluginWire.K_VOTE_DELIVERY_ACK_VERSION,
|
||||
VotingPluginWire.VOTE_DELIVERY_ACK_VERSION);
|
||||
out.put(VotingPluginWire.K_VOTE_DELAY_REJECTION_ACK_VERSION,
|
||||
VotingPluginWire.VOTE_DELAY_REJECTION_ACK_VERSION);
|
||||
String requestId = nvl(msg.getFields().get(VotingPluginWire.K_REQUEST_ID));
|
||||
if (!requestId.isEmpty()) out.put(VotingPluginWire.K_REQUEST_ID, requestId);
|
||||
sendSubChannel(messages, VotingPluginWire.SUB_STATUS_OKAY, out);
|
||||
@@ -85,7 +114,8 @@ public class BackendProxyMessageRouter {
|
||||
@Override public void onReceive(JsonEnvelope msg) {
|
||||
String server = nvl(msg.getFields().get("server"));
|
||||
if (!plugin.getOptions().getServer().equals(server)) {
|
||||
plugin.getLogger().warning("Server name doesn't match in BungeeSettings.yml, should be " + server);
|
||||
plugin.getLogger().warning("Server name doesn't match in BungeeSettings.yml, should be "
|
||||
+ ServiceSiteValidator.sanitizeForLog(server));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -100,39 +130,429 @@ public class BackendProxyMessageRouter {
|
||||
}
|
||||
|
||||
void handleVoteUpdate(JsonEnvelope msg) {
|
||||
VotingPluginWire.VoteUpdate update = VotingPluginWire.readVoteUpdate(msg);
|
||||
handleVoteUpdate(msg, () -> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the three ordered vote messages without routing back through the
|
||||
* transport-facing GlobalMessageHandler. Only transient lookup/cache failures
|
||||
* are retried. An exception after possible effects requires quarantine.
|
||||
*/
|
||||
public void handleOrderedVote(JsonEnvelope msg, Consumer<OrderedVoteOutcome> completion) {
|
||||
if (completion == null) throw new IllegalArgumentException("Ordered vote completion is required");
|
||||
String subChannel = msg.getSubChannel();
|
||||
if (VotingPluginWire.SUB_VOTE_DELIVERY_RECEIPT_RELEASE.equals(subChannel)) {
|
||||
completion.accept(handleVoteDeliveryReceiptRelease(messages, msg));
|
||||
return;
|
||||
}
|
||||
if (VotingPluginWire.SUB_VOTE_UPDATE.equals(subChannel)) {
|
||||
handleVoteUpdateWithOutcome(msg, completion);
|
||||
return;
|
||||
}
|
||||
if (VotingPluginWire.SUB_VOTE_DELAY_REJECTED.equals(subChannel)) {
|
||||
try {
|
||||
WireVoteResult result = handleWireVoteDelayRejected(msg);
|
||||
if (result != null && result.retryable()) {
|
||||
completion.accept(OrderedVoteOutcome.RETRY);
|
||||
return;
|
||||
}
|
||||
if (VotingPluginWire.requestsVoteDeliveryAcknowledgement(msg)
|
||||
&& (result == null || !result.effectsComplete())) {
|
||||
completion.accept(OrderedVoteOutcome.QUARANTINE);
|
||||
return;
|
||||
}
|
||||
UUID completedVoteId = result == null ? null : result.voteId();
|
||||
if (VotingPluginWire.requestsVoteDeliveryAcknowledgement(msg)
|
||||
&& completedVoteId != null && !processedVoteCache.complete(completedVoteId)) {
|
||||
completion.accept(OrderedVoteOutcome.RETRY);
|
||||
return;
|
||||
}
|
||||
} catch (RuntimeException | Error failure) {
|
||||
completion.accept(OrderedVoteOutcome.QUARANTINE);
|
||||
throw failure;
|
||||
}
|
||||
completion.accept(OrderedVoteOutcome.COMPLETE);
|
||||
return;
|
||||
}
|
||||
if (VotingPluginWire.SUB_VOTE.equals(subChannel) || VotingPluginWire.SUB_VOTE_ONLINE.equals(subChannel)) {
|
||||
try {
|
||||
WireVoteResult result = handleWireVote(msg);
|
||||
if (result != null && result.retryable()) {
|
||||
completion.accept(OrderedVoteOutcome.RETRY);
|
||||
return;
|
||||
}
|
||||
if (VotingPluginWire.requestsVoteDeliveryAcknowledgement(msg)
|
||||
&& (result == null || !result.effectsComplete())) {
|
||||
completion.accept(OrderedVoteOutcome.QUARANTINE);
|
||||
return;
|
||||
}
|
||||
UUID completedVoteId = result == null ? null : result.voteId();
|
||||
if (VotingPluginWire.requestsVoteDeliveryAcknowledgement(msg)
|
||||
&& completedVoteId != null && !processedVoteCache.complete(completedVoteId)) {
|
||||
completion.accept(OrderedVoteOutcome.RETRY);
|
||||
return;
|
||||
}
|
||||
} catch (RuntimeException | Error failure) {
|
||||
completion.accept(OrderedVoteOutcome.QUARANTINE);
|
||||
throw failure;
|
||||
}
|
||||
completion.accept(OrderedVoteOutcome.COMPLETE);
|
||||
return;
|
||||
}
|
||||
completion.accept(OrderedVoteOutcome.QUARANTINE);
|
||||
throw new IllegalArgumentException("Unsupported ordered proxy vote message: " + subChannel);
|
||||
}
|
||||
|
||||
/** Returns whether the envelope is a valid release targeted at this backend. */
|
||||
public boolean isValidReceiptRelease(JsonEnvelope msg) {
|
||||
return validReceiptReleaseVoteId(msg) != null;
|
||||
}
|
||||
|
||||
/** Returns whether a valid release already has an acknowledgement-safe durable receipt. */
|
||||
public boolean hasDurableReceiptForRelease(JsonEnvelope msg) {
|
||||
UUID voteId = validReceiptReleaseVoteId(msg);
|
||||
return voteId != null && processedVoteCache.hasDurableReceipt(voteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes one ordered VoteUpdate. User identity and shared cache population
|
||||
* are allowed to leave the platform thread, while offline reward/Bukkit work
|
||||
* returns to the platform scheduler before the ordered lane is released.
|
||||
*/
|
||||
public void handleVoteUpdate(JsonEnvelope msg, Runnable completion) {
|
||||
if (completion == null) throw new IllegalArgumentException("VoteUpdate completion is required");
|
||||
handleVoteUpdateWithOutcome(msg, ignored -> completion.run());
|
||||
}
|
||||
|
||||
private void handleVoteUpdateWithOutcome(JsonEnvelope msg, Consumer<OrderedVoteOutcome> completion) {
|
||||
if (completion == null) throw new IllegalArgumentException("VoteUpdate completion is required");
|
||||
AtomicBoolean completed = new AtomicBoolean();
|
||||
Consumer<OrderedVoteOutcome> complete = outcome -> {
|
||||
if (completed.compareAndSet(false, true)) completion.accept(outcome);
|
||||
};
|
||||
|
||||
VotingPluginWire.VoteUpdate update;
|
||||
try {
|
||||
update = VotingPluginWire.readVoteUpdate(msg);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
complete.accept(OrderedVoteOutcome.QUARANTINE);
|
||||
throw failure;
|
||||
}
|
||||
String playerUuid = update.uuid;
|
||||
if (playerUuid == null || playerUuid.isEmpty()) {
|
||||
complete.accept(OrderedVoteOutcome.COMPLETE);
|
||||
return;
|
||||
}
|
||||
|
||||
plugin.debug("pluginmessaging voteupdate received for " + playerUuid + ": " + update.votePartyCurrent + "/"
|
||||
+ update.votePartyRequired + " on " + update.service);
|
||||
votePartySync.update(update.votePartyCurrent, update.votePartyRequired);
|
||||
|
||||
VotingPluginUser user;
|
||||
// Track admission until execution starts; ordinary lag is not cancellation.
|
||||
PendingVoteUpdateHandoff handoff = trackVoteUpdateHandoff(complete);
|
||||
if (handoff.isClaimed()) return;
|
||||
try {
|
||||
user = plugin.getVotingPluginUserManager().getVotingPluginUser(UUID.fromString(playerUuid));
|
||||
} catch (IllegalArgumentException e) {
|
||||
plugin.getLogger().warning("Invalid UUID in VoteUpdate: " + playerUuid);
|
||||
return;
|
||||
plugin.getBukkitScheduler().runTask(plugin, () -> {
|
||||
if (handoff.begin()) beginVoteUpdateOnPlatform(update, complete);
|
||||
});
|
||||
} catch (RuntimeException | Error failure) {
|
||||
handoff.cancel();
|
||||
throw failure;
|
||||
}
|
||||
user.cache();
|
||||
user.offVote();
|
||||
}
|
||||
|
||||
/** Cancel only unstarted VoteUpdates when the ordered lane retires. */
|
||||
public void cancelPendingVoteUpdateHandoffs() {
|
||||
Set<PendingVoteUpdateHandoff> waiting;
|
||||
synchronized (pendingVoteUpdateLock) {
|
||||
pendingVoteUpdateHandoffsClosing = true;
|
||||
waiting = new HashSet<>(pendingVoteUpdateHandoffs);
|
||||
}
|
||||
// Completion can acquire the ordered-lane lock; never hold ours here.
|
||||
for (PendingVoteUpdateHandoff handoff : waiting) handoff.cancel();
|
||||
}
|
||||
|
||||
/** Resume admission when a staged replacement is rolled back. */
|
||||
public void resumeVoteUpdateHandoffs() {
|
||||
synchronized (pendingVoteUpdateLock) {
|
||||
pendingVoteUpdateHandoffsClosing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private PendingVoteUpdateHandoff trackVoteUpdateHandoff(Consumer<OrderedVoteOutcome> completion) {
|
||||
PendingVoteUpdateHandoff handoff = new PendingVoteUpdateHandoff(completion);
|
||||
boolean rejected;
|
||||
synchronized (pendingVoteUpdateLock) {
|
||||
rejected = pendingVoteUpdateHandoffsClosing;
|
||||
if (!rejected) pendingVoteUpdateHandoffs.add(handoff);
|
||||
}
|
||||
if (rejected) handoff.cancel();
|
||||
return handoff;
|
||||
}
|
||||
|
||||
private final class PendingVoteUpdateHandoff {
|
||||
private final AtomicBoolean claimed = new AtomicBoolean();
|
||||
private final Consumer<OrderedVoteOutcome> completion;
|
||||
|
||||
private PendingVoteUpdateHandoff(Consumer<OrderedVoteOutcome> completion) {
|
||||
this.completion = completion;
|
||||
}
|
||||
|
||||
private boolean isClaimed() { return claimed.get(); }
|
||||
|
||||
private boolean begin() {
|
||||
if (!claimed.compareAndSet(false, true)) return false;
|
||||
synchronized (pendingVoteUpdateLock) {
|
||||
pendingVoteUpdateHandoffs.remove(this);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void cancel() {
|
||||
if (!claimed.compareAndSet(false, true)) return;
|
||||
synchronized (pendingVoteUpdateLock) {
|
||||
pendingVoteUpdateHandoffs.remove(this);
|
||||
}
|
||||
completion.accept(OrderedVoteOutcome.RETRY);
|
||||
}
|
||||
}
|
||||
|
||||
private void beginVoteUpdateOnPlatform(VotingPluginWire.VoteUpdate update,
|
||||
Consumer<OrderedVoteOutcome> completion) {
|
||||
String playerUuid = update.uuid;
|
||||
UUID uuid;
|
||||
try {
|
||||
plugin.debug("pluginmessaging voteupdate received for "
|
||||
+ ServiceSiteValidator.sanitizeForLog(playerUuid) + ": " + update.votePartyCurrent + "/"
|
||||
+ update.votePartyRequired + " on " + ServiceSiteValidator.sanitizeForLog(update.service));
|
||||
votePartySync.update(update.votePartyCurrent, update.votePartyRequired);
|
||||
|
||||
try {
|
||||
uuid = UUID.fromString(playerUuid);
|
||||
} catch (IllegalArgumentException invalidUuid) {
|
||||
plugin.getLogger().warning("Invalid UUID in VoteUpdate: "
|
||||
+ ServiceSiteValidator.sanitizeForLog(playerUuid));
|
||||
completion.accept(OrderedVoteOutcome.COMPLETE);
|
||||
return;
|
||||
}
|
||||
} catch (RuntimeException | Error failure) {
|
||||
completion.accept(OrderedVoteOutcome.QUARANTINE);
|
||||
throw failure;
|
||||
}
|
||||
try {
|
||||
plugin.getUserManager().getUserAsync(uuid,
|
||||
resolved -> cacheVoteUpdateUser(update, resolved, completion),
|
||||
failure -> {
|
||||
try {
|
||||
plugin.getLogger().warning("Unable to resolve UUID user in VoteUpdate: " + playerUuid);
|
||||
plugin.debug(failure);
|
||||
} finally {
|
||||
completion.accept(OrderedVoteOutcome.RETRY);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException | Error failure) {
|
||||
completion.accept(OrderedVoteOutcome.RETRY);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private void cacheVoteUpdateUser(VotingPluginWire.VoteUpdate update, AdvancedCoreUser resolved,
|
||||
Consumer<OrderedVoteOutcome> completion) {
|
||||
try {
|
||||
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(resolved);
|
||||
UserDataManager dataManager = plugin.getUserManager().getDataManager();
|
||||
// Site resolution may auto-create YAML and reload reward/site registries.
|
||||
// Capture it once on the platform scheduler before entering the SQL worker.
|
||||
VoteSite voteSite = resolveVoteUpdateSite(update);
|
||||
// Capture player state on its owning thread before entering storage.
|
||||
// A retired entity must retry; only a genuinely absent player may skip offline rewards.
|
||||
// Match offVote(): offline-mode identities are looked up by name,
|
||||
// not by their stored UUID. Permission reads still use the entity owner.
|
||||
org.bukkit.entity.Player player = plugin.getOptions().isOnlineMode()
|
||||
? user.getPlayer()
|
||||
: org.bukkit.Bukkit.getPlayer(user.getPlayerName());
|
||||
if (player == null) {
|
||||
deferVoteUpdate(update, user, dataManager, voteSite, false, false, completion);
|
||||
} else {
|
||||
// A cancelled callback retries only on explicit lifecycle retirement.
|
||||
PendingVoteUpdateHandoff handoff = trackVoteUpdateHandoff(completion);
|
||||
if (handoff.isClaimed()) return;
|
||||
com.bencodez.votingplugin.util.BukkitCompletionScheduler.run(plugin, player, () -> {
|
||||
if (!handoff.begin()) return;
|
||||
try {
|
||||
boolean online = player.isOnline();
|
||||
deferVoteUpdate(update, user, dataManager, voteSite, online,
|
||||
online && player.hasPermission("VotingPlugin.TopVoter.Ignore"), completion);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
completion.accept(OrderedVoteOutcome.RETRY);
|
||||
throw failure;
|
||||
}
|
||||
}, handoff::cancel, handoff::cancel);
|
||||
}
|
||||
} catch (RuntimeException | Error failure) {
|
||||
completion.accept(OrderedVoteOutcome.RETRY);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The cache must be populated and LastVotes read/updated on the same storage
|
||||
* worker. A platform callback between those steps permits another storage
|
||||
* operation to retire the published cache and makes setTime() fail on Bukkit.
|
||||
*/
|
||||
private void deferSharedVoteUpdate(VotingPluginWire.VoteUpdate update, VotingPluginUser user,
|
||||
UserDataManager dataManager, VoteSite voteSite, boolean processOfflineVotes, boolean topVoterIgnore,
|
||||
Consumer<OrderedVoteOutcome> completion) {
|
||||
AtomicBoolean effectsMayHaveStarted = new AtomicBoolean();
|
||||
try {
|
||||
boolean deferred = dataManager.deferSharedStorageResultFromPlatform(() -> {
|
||||
user.cache();
|
||||
CompletionStage<Void> rewards = CompletableFuture.completedFuture(null);
|
||||
if (processOfflineVotes) {
|
||||
// Persistent reads and cache publication may fail before any
|
||||
// reward begins. The async user API signals only the first
|
||||
// potentially nontransactional reward/pending grant boundary.
|
||||
rewards = user.offVoteWithCapturedTopVoterIgnoreAsync(topVoterIgnore,
|
||||
() -> effectsMayHaveStarted.set(true));
|
||||
if (rewards == null) throw new IllegalStateException("Offline vote reward chain was null");
|
||||
}
|
||||
applyVoteUpdateTime(update, user, voteSite, () -> effectsMayHaveStarted.set(true));
|
||||
return rewards;
|
||||
}, rewards -> {
|
||||
if (rewards == null) {
|
||||
completion.accept(OrderedVoteOutcome.QUARANTINE);
|
||||
return;
|
||||
}
|
||||
// This stage may finish on an injection/region thread. Only the
|
||||
// ordered result and update flag return to the platform scheduler.
|
||||
rewards.whenComplete((ignored, rewardFailure) -> {
|
||||
if (rewardFailure != null) {
|
||||
plugin.getLogger().warning("Unable to finish offline rewards for UUID VoteUpdate: "
|
||||
+ ServiceSiteValidator.sanitizeForLog(update.uuid));
|
||||
plugin.debug(rewardFailure);
|
||||
// The user API marks any ambiguous pending/effect boundary.
|
||||
completion.accept(effectsMayHaveStarted.get()
|
||||
? OrderedVoteOutcome.QUARANTINE : OrderedVoteOutcome.RETRY);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
dataManager.dispatchSharedStorageNotification(() -> {
|
||||
try {
|
||||
plugin.setUpdate(true);
|
||||
completion.accept(OrderedVoteOutcome.COMPLETE);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
completion.accept(OrderedVoteOutcome.QUARANTINE);
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException | Error failure) {
|
||||
plugin.debug(failure);
|
||||
completion.accept(OrderedVoteOutcome.QUARANTINE);
|
||||
}
|
||||
});
|
||||
}, failure -> {
|
||||
try {
|
||||
plugin.getLogger().warning("Unable to apply UUID user VoteUpdate: "
|
||||
+ ServiceSiteValidator.sanitizeForLog(update.uuid));
|
||||
plugin.debug(failure);
|
||||
} finally {
|
||||
// Pre-effect read/cache failures retry; possible external
|
||||
// effects or a vote-time write require quarantine.
|
||||
completion.accept(effectsMayHaveStarted.get()
|
||||
? OrderedVoteOutcome.QUARANTINE : OrderedVoteOutcome.RETRY);
|
||||
}
|
||||
});
|
||||
if (!deferred) completion.accept(OrderedVoteOutcome.RETRY);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
completion.accept(effectsMayHaveStarted.get()
|
||||
? OrderedVoteOutcome.QUARANTINE : OrderedVoteOutcome.RETRY);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
/** May mutate VoteSites.yml when auto-create is enabled; never call on the SQL worker. */
|
||||
private VoteSite resolveVoteUpdateSite(VotingPluginWire.VoteUpdate update) {
|
||||
if (update.service != null && !update.service.isEmpty() && update.time > 0) {
|
||||
return plugin.getVoteSiteManager().getVoteSite(update.service, true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void applyVoteUpdateTime(VotingPluginWire.VoteUpdate update, VotingPluginUser user,
|
||||
VoteSite voteSite, Runnable beforeTimeWrite) {
|
||||
if (update.service != null && !update.service.isEmpty() && update.time > 0) {
|
||||
VoteSite voteSite = plugin.getVoteSiteManager().getVoteSite(update.service, true);
|
||||
if (voteSite == null) {
|
||||
plugin.getLogger().warning("Ignoring VoteUpdate last vote time for unresolved or disabled service site: "
|
||||
+ ServiceSiteValidator.sanitizeForLog(update.service));
|
||||
} else {
|
||||
// Lookups and validation may fail without any effect to replay.
|
||||
beforeTimeWrite.run();
|
||||
user.setTime(voteSite, update.time);
|
||||
}
|
||||
} else if (update.service != null && !update.service.isEmpty() && update.time <= 0
|
||||
&& plugin.getBungeeSettings().isBungeeDebug()) {
|
||||
plugin.debug("Invalid last vote time received from bungee: " + update.time);
|
||||
}
|
||||
plugin.setUpdate(true);
|
||||
}
|
||||
|
||||
private void deferVoteUpdate(VotingPluginWire.VoteUpdate update, VotingPluginUser user,
|
||||
UserDataManager dataManager, VoteSite site, boolean online, boolean topVoterIgnore,
|
||||
Consumer<OrderedVoteOutcome> completion) {
|
||||
if (dataManager.hasSharedSqlBackend()) {
|
||||
deferSharedVoteUpdate(update, user, dataManager, site, online, topVoterIgnore, completion);
|
||||
return;
|
||||
}
|
||||
AtomicBoolean effectsMayHaveStarted = new AtomicBoolean();
|
||||
try {
|
||||
dataManager.getTimer().execute(() -> {
|
||||
try {
|
||||
user.cache();
|
||||
CompletionStage<Void> replay = online
|
||||
? user.offVoteWithCapturedTopVoterIgnoreAsync(topVoterIgnore,
|
||||
() -> effectsMayHaveStarted.set(true))
|
||||
: CompletableFuture.completedFuture(null);
|
||||
if (replay == null) throw new IllegalStateException("Missing offline replay completion");
|
||||
replay.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) {
|
||||
plugin.debug(failure);
|
||||
completion.accept(effectsMayHaveStarted.get()
|
||||
? OrderedVoteOutcome.QUARANTINE : OrderedVoteOutcome.RETRY);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Even an empty replay can finish on an owner callback.
|
||||
dataManager.getTimer().execute(() -> {
|
||||
try {
|
||||
user.cache();
|
||||
applyVoteUpdateTime(update, user, site, () -> effectsMayHaveStarted.set(true));
|
||||
dataManager.dispatchSharedStorageNotification(() -> {
|
||||
try {
|
||||
plugin.setUpdate(true);
|
||||
completion.accept(OrderedVoteOutcome.COMPLETE);
|
||||
} catch (RuntimeException | Error notificationFailure) {
|
||||
plugin.debug(notificationFailure);
|
||||
completion.accept(OrderedVoteOutcome.QUARANTINE);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException | Error applyFailure) {
|
||||
plugin.debug(applyFailure);
|
||||
completion.accept(effectsMayHaveStarted.get()
|
||||
? OrderedVoteOutcome.QUARANTINE : OrderedVoteOutcome.RETRY);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException | Error rejected) {
|
||||
plugin.debug(rejected);
|
||||
completion.accept(effectsMayHaveStarted.get()
|
||||
? OrderedVoteOutcome.QUARANTINE : OrderedVoteOutcome.RETRY);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException | Error failure) {
|
||||
plugin.debug(failure);
|
||||
completion.accept(effectsMayHaveStarted.get()
|
||||
? OrderedVoteOutcome.QUARANTINE : OrderedVoteOutcome.RETRY);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException | Error rejected) {
|
||||
plugin.debug(rejected);
|
||||
completion.accept(OrderedVoteOutcome.RETRY);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleVoteBroadcast(JsonEnvelope msg) {
|
||||
@@ -148,7 +568,8 @@ public class BackendProxyMessageRouter {
|
||||
try {
|
||||
javaUuid = UUID.fromString(uuidStr);
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("Invalid UUID in VoteBroadcast: " + uuidStr);
|
||||
plugin.getLogger().warning("Invalid UUID in VoteBroadcast: "
|
||||
+ ServiceSiteValidator.sanitizeForLog(uuidStr));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -157,7 +578,8 @@ public class BackendProxyMessageRouter {
|
||||
VoteSite voteSite = plugin.getVoteSiteManager()
|
||||
.getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, service), true);
|
||||
if (voteSite == null) {
|
||||
plugin.getLogger().warning("No voting site with the service site: '" + service + "'");
|
||||
plugin.getLogger().warning("No voting site with the service site: '"
|
||||
+ ServiceSiteValidator.sanitizeForLog(service) + "'");
|
||||
return;
|
||||
}
|
||||
if (!voteSite.isEnabled()) {
|
||||
@@ -181,74 +603,144 @@ public class BackendProxyMessageRouter {
|
||||
voteSite.getDisplayName(), online, totals);
|
||||
}
|
||||
|
||||
private void handleWireVoteDelayRejected(JsonEnvelope msg) {
|
||||
if (!validSchema(msg) || !plugin.getOptions().isProcessRewards()) {
|
||||
return;
|
||||
}
|
||||
private WireVoteResult handleWireVoteDelayRejected(JsonEnvelope msg) {
|
||||
if (!validSchema(msg)) return null;
|
||||
VotingPluginWire.VoteDelayRejected rejected = VotingPluginWire.readVoteDelayRejected(msg);
|
||||
boolean reliable = VotingPluginWire.requestsVoteDeliveryAcknowledgement(msg);
|
||||
if (reliable && rejected.voteId == null) {
|
||||
plugin.getLogger().warning("Rejected VoteDelayRejected without a valid vote ID from a capable proxy");
|
||||
return null;
|
||||
}
|
||||
if (!plugin.getOptions().isProcessRewards()) {
|
||||
return new WireVoteResult(rejected.voteId, true);
|
||||
}
|
||||
if (rejected.uuid.isEmpty() || rejected.service.isEmpty()) {
|
||||
return;
|
||||
return new WireVoteResult(rejected.voteId, true);
|
||||
}
|
||||
VoteSite legacySite = null;
|
||||
if (!ServiceSiteValidator.isValid(rejected.service)
|
||||
&& ServiceSiteValidator.isValidConfiguredLegacyIdentifier(rejected.service)) {
|
||||
for (VoteSite site : plugin.getVoteSiteManager().getVoteSites().toArray(new VoteSite[0])) {
|
||||
if (site.isEnabled() && rejected.service.equalsIgnoreCase(site.getServiceSite())) {
|
||||
legacySite = site;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean configuredLegacy = legacySite != null;
|
||||
if (!ServiceSiteValidator.isValid(rejected.service) && !configuredLegacy) {
|
||||
plugin.getLogger().warning("Rejected VoteDelayRejected with invalid service site");
|
||||
return new WireVoteResult(rejected.voteId, true);
|
||||
}
|
||||
UUID javaUuid;
|
||||
try {
|
||||
javaUuid = UUID.fromString(rejected.uuid);
|
||||
} catch (IllegalArgumentException e) {
|
||||
plugin.getLogger().warning("Invalid UUID in VoteDelayRejected: " + rejected.uuid);
|
||||
return;
|
||||
plugin.getLogger().warning("Invalid UUID in VoteDelayRejected: "
|
||||
+ ServiceSiteValidator.sanitizeForLog(rejected.uuid));
|
||||
return new WireVoteResult(rejected.voteId, true);
|
||||
}
|
||||
VoteSite voteSite = plugin.getVoteSiteManager()
|
||||
.getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, rejected.service), true);
|
||||
VoteSite voteSite = configuredLegacy
|
||||
? legacySite
|
||||
: plugin.getVoteSiteManager()
|
||||
.getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, rejected.service), true);
|
||||
if (voteSite == null) {
|
||||
plugin.getLogger().warning("No voting site with the service site: '" + rejected.service + "'");
|
||||
return;
|
||||
plugin.getLogger().warning("No voting site with the service site: '"
|
||||
+ ServiceSiteValidator.sanitizeForLog(rejected.service) + "'");
|
||||
return new WireVoteResult(rejected.voteId, true);
|
||||
}
|
||||
if (!voteSite.isEnabled() || !voteSite.isWaitUntilVoteDelay()) {
|
||||
return new WireVoteResult(rejected.voteId, true);
|
||||
}
|
||||
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(javaUuid, rejected.player);
|
||||
user.cache();
|
||||
user.updateName(true);
|
||||
if (reliable && user.canVoteSite(voteSite)) {
|
||||
return new WireVoteResult(rejected.voteId, true);
|
||||
}
|
||||
if (rejected.voteId != null) {
|
||||
Reservation reservation = processedVoteCache.reserveWithOutcome(rejected.voteId);
|
||||
if (reservation == Reservation.SATURATED) {
|
||||
return new WireVoteResult(rejected.voteId, false, true);
|
||||
}
|
||||
if (reservation == Reservation.DUPLICATE) {
|
||||
return new WireVoteResult(rejected.voteId, processedVoteCache.hasCompletedEffects(rejected.voteId));
|
||||
}
|
||||
}
|
||||
voteSite.giveWaitUntilVoteDelayRewards(user, rejected.wasOnline && user.isOnline(), true);
|
||||
return new WireVoteResult(rejected.voteId, true);
|
||||
}
|
||||
|
||||
private void handleWireVote(JsonEnvelope msg) {
|
||||
private WireVoteResult handleWireVote(JsonEnvelope msg) {
|
||||
if (!validSchema(msg)) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
VotingPluginWire.Vote vote = VotingPluginWire.readVote(msg);
|
||||
if (vote.uuid == null || vote.uuid.isEmpty()) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
if (!ServiceSiteValidator.isValid(vote.service)) {
|
||||
plugin.getLogger().warning("Rejected proxy vote with invalid service site '"
|
||||
+ ServiceSiteValidator.sanitizeForLog(vote.service) + "'");
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
plugin.debug("wire vote received from " + vote.player + "/" + vote.uuid + " on " + vote.service);
|
||||
plugin.debug("wire vote received from " + ServiceSiteValidator.sanitizeForLog(vote.player) + "/"
|
||||
+ ServiceSiteValidator.sanitizeForLog(vote.uuid) + " on "
|
||||
+ ServiceSiteValidator.sanitizeForLog(vote.service));
|
||||
VoteTotalsSnapshot totals = VoteTotalsSnapshot.parseStorage(vote.totals == null ? "" : vote.totals);
|
||||
@SuppressWarnings("deprecation")
|
||||
UUID voteId = vote.voteId != null ? vote.voteId : totals.getVoteUUID();
|
||||
if (!processedVoteCache.reserve(voteId)) {
|
||||
plugin.debug("Ignoring duplicate wire vote " + voteId + " for " + vote.player + " on " + vote.service);
|
||||
return;
|
||||
Reservation reservation = processedVoteCache.reserveWithOutcome(voteId);
|
||||
if (reservation == Reservation.SATURATED) {
|
||||
if (voteReplayCacheSaturationLogged.compareAndSet(false, true)) {
|
||||
plugin.getLogger().warning("Backend vote replay cache is full; retaining votes for retry");
|
||||
}
|
||||
return new WireVoteResult(voteId, false, true);
|
||||
}
|
||||
if (reservation == Reservation.DUPLICATE) {
|
||||
plugin.debug("Ignoring duplicate wire vote " + voteId + " for "
|
||||
+ ServiceSiteValidator.sanitizeForLog(vote.player) + " on "
|
||||
+ ServiceSiteValidator.sanitizeForLog(vote.service));
|
||||
return new WireVoteResult(voteId, processedVoteCache.hasCompletedEffects(voteId), false);
|
||||
}
|
||||
voteReplayCacheSaturationLogged.set(false);
|
||||
|
||||
UUID javaUuid;
|
||||
try {
|
||||
javaUuid = UUID.fromString(vote.uuid);
|
||||
} catch (IllegalArgumentException e) {
|
||||
plugin.getLogger().warning("Invalid UUID in proxy vote: " + vote.uuid);
|
||||
return;
|
||||
processedVoteCache.cancelReservation(voteId);
|
||||
plugin.getLogger().warning("Invalid UUID in proxy vote: "
|
||||
+ ServiceSiteValidator.sanitizeForLog(vote.uuid));
|
||||
return new WireVoteResult(voteId, false, false);
|
||||
}
|
||||
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(javaUuid, vote.player);
|
||||
votePartySync.replace(totals.getVotePartyCurrent(), totals.getVotePartyRequired());
|
||||
user.cache();
|
||||
user.bungeeVotePluginMessaging(vote.service, vote.time, totals, !vote.manageTotals,
|
||||
vote.wasOnline, vote.broadcast, vote.num);
|
||||
if (plugin.getBungeeSettings().isPerServerPoints()) {
|
||||
boolean wasOnline = vote.wasOnlineKnown ? vote.wasOnline : user.isOnline();
|
||||
boolean queuedDelivery = vote.queuedDeliveryKnown ? vote.queuedDelivery : vote.delayValidated;
|
||||
// Even an immediate proxy send can arrive after a guide opened on this backend.
|
||||
// Transport freshness flags cannot establish original occurrence order. The guide
|
||||
// separately validates the canonical ingress ID against its negotiated source fence.
|
||||
var event = user.bungeeVotePluginMessagingEvent(vote.service, vote.time, totals, !vote.manageTotals,
|
||||
wasOnline, vote.broadcast, vote.num, queuedDelivery, vote.delayValidationKnown,
|
||||
vote.queuedDeliveryKnown, voteId,
|
||||
VotingPluginWire.SUB_VOTE_ONLINE.equals(msg.getSubChannel()),
|
||||
true, vote.realVote);
|
||||
if ((event == null || !event.isCancelled()) && plugin.getBungeeSettings().isPerServerPoints()) {
|
||||
user.addPoints(plugin.getConfigFile().getPointsOnVote());
|
||||
}
|
||||
if (vote.service != null && !vote.service.isEmpty()) {
|
||||
plugin.getServerData().addServiceSite(vote.service);
|
||||
}
|
||||
return new WireVoteResult(voteId, true, false);
|
||||
}
|
||||
|
||||
private record WireVoteResult(UUID voteId, boolean effectsComplete, boolean retryable) {
|
||||
private WireVoteResult(UUID voteId, boolean effectsComplete) {
|
||||
this(voteId, effectsComplete, false);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean validSchema(JsonEnvelope msg) {
|
||||
@@ -260,6 +752,40 @@ public class BackendProxyMessageRouter {
|
||||
return false;
|
||||
}
|
||||
|
||||
private OrderedVoteOutcome handleVoteDeliveryReceiptRelease(GlobalMessageHandler messages, JsonEnvelope msg) {
|
||||
if (messages == null) return OrderedVoteOutcome.QUARANTINE;
|
||||
UUID voteId = validReceiptReleaseVoteId(msg);
|
||||
if (voteId == null) return OrderedVoteOutcome.QUARANTINE;
|
||||
String subChannel = nvl(msg.getFields().get(VotingPluginWire.K_VOTE_DELIVERY_SUBCHANNEL));
|
||||
try {
|
||||
if (processedVoteCache.releaseCompletedReceipt(voteId)) {
|
||||
messages.sendMessage(VotingPluginWire.voteDeliveryReceiptReleaseAcknowledgement(
|
||||
plugin.getOptions().getServer(), voteId, subChannel));
|
||||
return OrderedVoteOutcome.COMPLETE;
|
||||
}
|
||||
return OrderedVoteOutcome.RETRY;
|
||||
} catch (IllegalArgumentException invalidVoteId) {
|
||||
plugin.debug("Ignored vote receipt release with invalid vote ID");
|
||||
return OrderedVoteOutcome.QUARANTINE;
|
||||
}
|
||||
}
|
||||
|
||||
private UUID validReceiptReleaseVoteId(JsonEnvelope msg) {
|
||||
if (msg == null || !VotingPluginWire.SUB_VOTE_DELIVERY_RECEIPT_RELEASE.equals(msg.getSubChannel())
|
||||
|| !VotingPluginWire.requestsVoteDeliveryAcknowledgement(msg)) return null;
|
||||
String server = nvl(msg.getFields().get(VotingPluginWire.K_SERVER));
|
||||
if (!plugin.getOptions().getServer().equalsIgnoreCase(server)) return null;
|
||||
String subChannel = nvl(msg.getFields().get(VotingPluginWire.K_VOTE_DELIVERY_SUBCHANNEL));
|
||||
if (!VotingPluginWire.SUB_VOTE.equals(subChannel)
|
||||
&& !VotingPluginWire.SUB_VOTE_ONLINE.equals(subChannel)
|
||||
&& !VotingPluginWire.SUB_VOTE_DELAY_REJECTED.equals(subChannel)) return null;
|
||||
try {
|
||||
return UUID.fromString(nvl(msg.getFields().get(VotingPluginWire.K_VOTE_ID)));
|
||||
} catch (IllegalArgumentException invalidVoteId) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void sendSubChannel(GlobalMessageHandler messages, String subChannel, HashMap<String, Object> fields) {
|
||||
JsonEnvelope.Builder builder = JsonEnvelope.builder(subChannel).schema(VotingPluginWire.SCHEMA_VERSION);
|
||||
for (Map.Entry<String, Object> entry : fields.entrySet()) {
|
||||
|
||||
+96
-19
@@ -3,9 +3,12 @@ package com.bencodez.votingplugin.backendproxy.presence;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
@@ -34,6 +37,8 @@ public class BackendPresenceManager {
|
||||
private final Object lifecycleLock = new Object();
|
||||
|
||||
private boolean reporting;
|
||||
/** Invalidates delayed lifecycle callbacks when a fresh incarnation starts. */
|
||||
private long lifecycleGeneration;
|
||||
private String server;
|
||||
private UUID incarnationId;
|
||||
private long startedAt;
|
||||
@@ -57,6 +62,7 @@ public class BackendPresenceManager {
|
||||
}
|
||||
String configuredServer = plugin.getBungeeSettings().getServer();
|
||||
synchronized (lifecycleLock) {
|
||||
lifecycleGeneration++;
|
||||
long now = System.currentTimeMillis();
|
||||
incarnationId = UUID.randomUUID();
|
||||
startedAt = now;
|
||||
@@ -67,24 +73,90 @@ public class BackendPresenceManager {
|
||||
lastResyncRequestAtNanos = 0L;
|
||||
lastSnapshotRequestId = null;
|
||||
lastSnapshotRequestAtNanos = 0L;
|
||||
send(VotingPluginWire.backendStarted(server, incarnationId, startedAt, now));
|
||||
send(VotingPluginWire.backendHeartbeat(server, incarnationId, startedAt, nextTimestamp()));
|
||||
|
||||
if (heartbeatTask != null) {
|
||||
heartbeatTask.cancel(false);
|
||||
}
|
||||
heartbeatTask = plugin.getTimer().scheduleAtFixedRate(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
sendHeartbeat();
|
||||
}
|
||||
}, HEARTBEAT_SECONDS, HEARTBEAT_SECONDS, TimeUnit.SECONDS);
|
||||
try {
|
||||
heartbeatTask = plugin.getTimer().scheduleAtFixedRate(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
sendHeartbeat();
|
||||
}
|
||||
}, HEARTBEAT_SECONDS, HEARTBEAT_SECONDS, TimeUnit.SECONDS);
|
||||
seedOnlinePlayers();
|
||||
} catch (RuntimeException failure) {
|
||||
if (heartbeatTask != null) heartbeatTask.cancel(false);
|
||||
heartbeatTask = null;
|
||||
reporting = false;
|
||||
server = null;
|
||||
incarnationId = null;
|
||||
throw failure;
|
||||
}
|
||||
send(VotingPluginWire.backendStarted(server, incarnationId, startedAt, now));
|
||||
send(VotingPluginWire.backendHeartbeat(server, incarnationId, startedAt, nextTimestamp()));
|
||||
}
|
||||
seedOnlinePlayers();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
stop(false);
|
||||
}
|
||||
|
||||
/** Stops presence and propagates rejection when a configuration disable must be transactional. */
|
||||
public void stopForDisable() {
|
||||
stopForDisable(System.nanoTime() + TimeUnit.SECONDS.toNanos(5));
|
||||
}
|
||||
|
||||
/** Stops presence before the caller's transactional validation deadline. */
|
||||
public void stopForDisable(long deadlineNanos) {
|
||||
// Plugin-message transport ultimately calls Bukkit's sendPluginMessage API,
|
||||
// which is primary-thread-only. Control validation runs on its worker, so
|
||||
// marshal the transactional stopped-presence send before returning the
|
||||
// result to that worker. Unit-test and shutdown contexts without a server
|
||||
// retain the direct path used by the non-Control lifecycle.
|
||||
if (method == BungeeMethod.PLUGINMESSAGING && plugin != null && plugin.getServer() != null
|
||||
&& !plugin.getServer().isPrimaryThread()) {
|
||||
long expectedGeneration;
|
||||
synchronized (lifecycleLock) {
|
||||
expectedGeneration = lifecycleGeneration;
|
||||
}
|
||||
CompletableFuture<Void> scheduled = new CompletableFuture<>();
|
||||
try {
|
||||
plugin.getBukkitScheduler().executeOrScheduleSync(plugin, () -> {
|
||||
try {
|
||||
stop(true, expectedGeneration);
|
||||
scheduled.complete(null);
|
||||
} catch (Throwable failure) {
|
||||
scheduled.completeExceptionally(failure);
|
||||
}
|
||||
});
|
||||
long remaining = deadlineNanos - System.nanoTime();
|
||||
if (remaining <= 0L) throw new TimeoutException("Backend stopped presence deadline expired");
|
||||
scheduled.get(remaining, TimeUnit.NANOSECONDS);
|
||||
return;
|
||||
} catch (Exception failure) {
|
||||
scheduled.cancel(false);
|
||||
Throwable cause = failure instanceof ExecutionException && failure.getCause() != null
|
||||
? failure.getCause() : failure;
|
||||
if (cause instanceof RuntimeException runtime) throw runtime;
|
||||
throw new IllegalStateException("Backend stopped presence could not run on the Bukkit thread", cause);
|
||||
}
|
||||
}
|
||||
stop(true);
|
||||
}
|
||||
|
||||
private void stop(boolean requireStoppedDelivery) {
|
||||
stop(requireStoppedDelivery, -1L);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops only the generation captured by a delayed callback. A cancelled future
|
||||
* does not necessarily cancel a task already queued on the Bukkit scheduler.
|
||||
*/
|
||||
private void stop(boolean requireStoppedDelivery, long expectedGeneration) {
|
||||
synchronized (lifecycleLock) {
|
||||
if (expectedGeneration >= 0L && lifecycleGeneration != expectedGeneration) {
|
||||
return;
|
||||
}
|
||||
String activeServer = server;
|
||||
UUID activeIncarnationId = incarnationId;
|
||||
long activeStartedAt = startedAt;
|
||||
@@ -95,16 +167,21 @@ public class BackendPresenceManager {
|
||||
heartbeatTask.cancel(false);
|
||||
heartbeatTask = null;
|
||||
}
|
||||
if (wasReporting && activeServer != null && activeIncarnationId != null) {
|
||||
send(VotingPluginWire.backendStopped(activeServer, activeIncarnationId, activeStartedAt,
|
||||
nextTimestamp()));
|
||||
try {
|
||||
if (wasReporting && activeServer != null && activeIncarnationId != null) {
|
||||
JsonEnvelope stopped = VotingPluginWire.backendStopped(activeServer, activeIncarnationId,
|
||||
activeStartedAt, nextTimestamp());
|
||||
if (requireStoppedDelivery) globalMessageHandler.sendMessage(stopped);
|
||||
else send(stopped);
|
||||
}
|
||||
} finally {
|
||||
incarnationId = null;
|
||||
lastResyncRequestId = null;
|
||||
lastResyncRequestAtNanos = 0L;
|
||||
lastSnapshotRequestId = null;
|
||||
lastSnapshotRequestAtNanos = 0L;
|
||||
playerSessions.clear();
|
||||
}
|
||||
incarnationId = null;
|
||||
lastResyncRequestId = null;
|
||||
lastResyncRequestAtNanos = 0L;
|
||||
lastSnapshotRequestId = null;
|
||||
lastSnapshotRequestAtNanos = 0L;
|
||||
playerSessions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -10,7 +10,17 @@ public interface BackendProxyTransport {
|
||||
|
||||
void start(GlobalMessageHandler messageHandler);
|
||||
|
||||
void send(JsonEnvelope envelope);
|
||||
/**
|
||||
* Starts the transport, optionally allowing startup-only transient failures to
|
||||
* be retried. Replacement transports disable this so Control validation stays
|
||||
* fail-fast; ordinary startup retains its recovery loop.
|
||||
*/
|
||||
default void start(GlobalMessageHandler messageHandler, boolean retryInitialization) {
|
||||
start(messageHandler);
|
||||
}
|
||||
|
||||
/** Returns true only when the delivery was accepted by the transport or its durable queue. */
|
||||
boolean send(JsonEnvelope envelope);
|
||||
|
||||
default void validate() {
|
||||
}
|
||||
@@ -19,5 +29,9 @@ public interface BackendProxyTransport {
|
||||
close();
|
||||
}
|
||||
|
||||
/** Activates transport state that must not become visible before handler publication. */
|
||||
default void activateAfterPublication() {
|
||||
}
|
||||
|
||||
void close();
|
||||
}
|
||||
+1084
-12
File diff suppressed because it is too large.
Load diff
+890
@@ -0,0 +1,890 @@
|
||||
package com.bencodez.votingplugin.backendproxy.transport;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
|
||||
import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler;
|
||||
import com.bencodez.simpleapi.servercomm.http.HttpBackendTransportConnector;
|
||||
import com.bencodez.simpleapi.servercomm.http.HttpClientCredentialStore;
|
||||
import com.bencodez.simpleapi.servercomm.http.HttpConnectionCode;
|
||||
import com.bencodez.simpleapi.servercomm.http.HttpEnvelopeWireCodec;
|
||||
import com.bencodez.simpleapi.servercomm.http.HttpTlsIdentity;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption;
|
||||
import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption.Domain;
|
||||
import com.bencodez.votingplugin.proxy.security.TransportEnvelopeHttpCodec;
|
||||
import com.bencodez.votingplugin.util.DurableFiles;
|
||||
|
||||
/** Backend adapter for the secure outbound-only HTTP proxy transport. */
|
||||
public final class HttpBackendProxyTransport implements BackendProxyTransport {
|
||||
private static final int MAX_STARTUP_QUEUE = 1024;
|
||||
private static final int MAX_PREPUBLICATION_QUEUE = 2048;
|
||||
private static final int MAX_HANDOFF_QUEUE = 4096;
|
||||
private static final int MAX_RESTORED_HANDOFF_QUEUE = MAX_HANDOFF_QUEUE + MAX_PREPUBLICATION_QUEUE;
|
||||
private static final long DEFAULT_STARTUP_VALIDATION_SECONDS = 25L;
|
||||
private static final long ENROLLMENT_RETRY_INITIAL_MILLIS = 1_000L;
|
||||
private static final long ENROLLMENT_RETRY_MAX_MILLIS = 60_000L;
|
||||
private static final long INCOMING_DISPATCH_SECONDS = 25L;
|
||||
private static final long SHUTDOWN_FLUSH_SECONDS = 5L;
|
||||
private static final ConcurrentHashMap<Path, Semaphore> DIRECTORY_OWNERS = new ConcurrentHashMap<>();
|
||||
private final VotingPluginMain plugin;
|
||||
private HttpEnvelopeWireCodec wireCodec;
|
||||
private final java.util.concurrent.atomic.AtomicBoolean encryptionFailureLogged = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private final Object lifecycle = new Object();
|
||||
private final CountDownLatch startupComplete = new CountDownLatch(1);
|
||||
private final CountDownLatch credentialRestoreComplete = new CountDownLatch(1);
|
||||
private final ArrayDeque<JsonEnvelope> startupQueue = new ArrayDeque<>();
|
||||
private final ArrayDeque<JsonEnvelope> handoffQueue = new ArrayDeque<>();
|
||||
private volatile Thread handoffWorker;
|
||||
private boolean awaitingPreparedHandoff;
|
||||
private boolean managerHandoffActive;
|
||||
private int managerHandoffCapacity = MAX_HANDOFF_QUEUE;
|
||||
/** Capacity reserved for the prepared predecessor before this transport is published. */
|
||||
private int preparedHandoffReservation;
|
||||
private volatile HttpBackendTransportConnector connector;
|
||||
private volatile Thread worker;
|
||||
private volatile RuntimeException startupFailure;
|
||||
private volatile RuntimeException credentialRestoreFailure;
|
||||
private volatile boolean started;
|
||||
private volatile boolean closed;
|
||||
/** True only after this transport has crossed the handler publication boundary. */
|
||||
private boolean published;
|
||||
private volatile boolean restartAfterFailedFlush;
|
||||
private final java.util.concurrent.atomic.AtomicBoolean flushRecoveryRunning = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
private Path configuredDirectory;
|
||||
private String configuredServerId;
|
||||
private String configuredConnectionCode;
|
||||
private GlobalMessageHandler configuredMessageHandler;
|
||||
private HttpClientCredentialStore.ActiveCredentialGeneration configuredCredentialGeneration;
|
||||
private HttpClientCredentialStore.ActiveCredentialGeneration credentialGenerationToRestore;
|
||||
private boolean retryInitialization;
|
||||
private boolean restoreUnenrolledState;
|
||||
private boolean inboundActive;
|
||||
private Semaphore directoryOwner;
|
||||
private final java.util.concurrent.atomic.AtomicBoolean queueWarning = new java.util.concurrent.atomic.AtomicBoolean();
|
||||
|
||||
public HttpBackendProxyTransport(VotingPluginMain plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
HttpBackendProxyTransport(VotingPluginMain plugin, TransportEnvelopeEncryption encryption) {
|
||||
this.plugin = plugin;
|
||||
if (encryption != null) wireCodec = createWireCodec(encryption);
|
||||
}
|
||||
|
||||
private HttpEnvelopeWireCodec loadWireCodec() {
|
||||
try {
|
||||
TransportEnvelopeEncryption encryption = TransportEnvelopeEncryption.load(
|
||||
plugin.getDataFolder().toPath().resolve("secretkey.key"), Domain.PROXY_BACKEND,
|
||||
plugin.getBungeeSettings().isCommunicationEncryption());
|
||||
return createWireCodec(encryption);
|
||||
} catch (java.io.IOException failure) {
|
||||
throw new IllegalStateException("HTTP communication encryption initialization failed", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpEnvelopeWireCodec createWireCodec(TransportEnvelopeEncryption encryption) {
|
||||
return new TransportEnvelopeHttpCodec(encryption, reason -> {
|
||||
if (encryptionFailureLogged.compareAndSet(false, true)) plugin.getLogger().warning(
|
||||
"HTTP proxy message rejected by communication encryption policy (" + reason + ")");
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(GlobalMessageHandler messageHandler) {
|
||||
start(messageHandler, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(GlobalMessageHandler messageHandler, boolean retryInitialization) {
|
||||
Path directory = plugin.getDataFolder().toPath().resolve("http");
|
||||
String serverId = plugin.getBungeeSettings().getServer();
|
||||
String connectionCode = plugin.getBungeeSettings().getHttpConnectionCode();
|
||||
start(directory, serverId, connectionCode, messageHandler, null, retryInitialization, false,
|
||||
retryInitialization);
|
||||
}
|
||||
|
||||
private void start(Path directory, String serverId, String connectionCode,
|
||||
GlobalMessageHandler messageHandler) {
|
||||
start(directory, serverId, connectionCode, messageHandler, null, true, false, true);
|
||||
}
|
||||
|
||||
private void start(Path directory, String serverId, String connectionCode,
|
||||
GlobalMessageHandler messageHandler,
|
||||
HttpClientCredentialStore.ActiveCredentialGeneration generationToRestore) {
|
||||
start(directory, serverId, connectionCode, messageHandler, generationToRestore, false, false, true);
|
||||
}
|
||||
|
||||
private void start(Path directory, String serverId, String connectionCode,
|
||||
GlobalMessageHandler messageHandler,
|
||||
HttpClientCredentialStore.ActiveCredentialGeneration generationToRestore,
|
||||
boolean retryInitialization, boolean restoreUnenrolledState, boolean inboundActive) {
|
||||
if (generationToRestore == null && !restoreUnenrolledState)
|
||||
validateConfiguration(directory, serverId, connectionCode);
|
||||
else HttpTlsIdentity.canonicalServerId(serverId);
|
||||
configuredDirectory = directory;
|
||||
configuredServerId = serverId;
|
||||
configuredConnectionCode = connectionCode;
|
||||
configuredMessageHandler = messageHandler;
|
||||
credentialGenerationToRestore = generationToRestore;
|
||||
this.retryInitialization = retryInitialization;
|
||||
this.restoreUnenrolledState = restoreUnenrolledState;
|
||||
this.inboundActive = inboundActive;
|
||||
this.published = inboundActive;
|
||||
if (wireCodec == null) wireCodec = loadWireCodec();
|
||||
started = true;
|
||||
worker = new Thread(() -> initialize(directory, serverId, connectionCode, messageHandler, retryInitialization,
|
||||
restoreUnenrolledState),
|
||||
"VotingPlugin-HTTP-Backend-Setup");
|
||||
worker.setDaemon(true);
|
||||
worker.start();
|
||||
}
|
||||
|
||||
HttpBackendProxyTransport recreatePrepared() {
|
||||
HttpBackendProxyTransport restored = new HttpBackendProxyTransport(plugin);
|
||||
restored.wireCodec = wireCodec;
|
||||
synchronized (lifecycle) {
|
||||
// Startup and handoff queues are one FIFO from the caller's perspective.
|
||||
// The handoff queue can still contain messages accepted by the previous
|
||||
// replacement, so rollback must carry it into the restored transport too.
|
||||
// Keep the combined queue in the asynchronously drained handoff lane: it can
|
||||
// be larger than the connector's bounded startup queue.
|
||||
restored.handoffQueue.addAll(takeQueuedMessages());
|
||||
}
|
||||
restored.start(configuredDirectory, configuredServerId, configuredConnectionCode, configuredMessageHandler,
|
||||
configuredCredentialGeneration, configuredCredentialGeneration == null && retryInitialization,
|
||||
restoreUnenrolledState, true);
|
||||
return restored;
|
||||
}
|
||||
|
||||
boolean isClosedForReplacement() {
|
||||
return closed;
|
||||
}
|
||||
|
||||
java.util.List<JsonEnvelope> drainPreparedMessages() {
|
||||
synchronized (lifecycle) {
|
||||
return takeQueuedMessages();
|
||||
}
|
||||
}
|
||||
|
||||
int preparedMessageCount() {
|
||||
synchronized (lifecycle) {
|
||||
return startupQueue.size() + handoffQueue.size();
|
||||
}
|
||||
}
|
||||
|
||||
java.util.List<JsonEnvelope> preparedMessagesSnapshot() {
|
||||
synchronized (lifecycle) {
|
||||
java.util.List<JsonEnvelope> pending = new java.util.ArrayList<>(startupQueue.size() + handoffQueue.size());
|
||||
pending.addAll(startupQueue);
|
||||
pending.addAll(handoffQueue);
|
||||
return pending;
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes both pending queues in their original FIFO order for replacement handoff. */
|
||||
private java.util.List<JsonEnvelope> takeQueuedMessages() {
|
||||
java.util.List<JsonEnvelope> pending = new java.util.ArrayList<>(startupQueue.size() + handoffQueue.size());
|
||||
pending.addAll(startupQueue);
|
||||
pending.addAll(handoffQueue);
|
||||
startupQueue.clear();
|
||||
handoffQueue.clear();
|
||||
return pending;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void prepareForReplacement() {
|
||||
HttpBackendTransportConnector active;
|
||||
Thread cancelledInitialization = null;
|
||||
/*
|
||||
* Keep the check, credential snapshot, and cancellation in one lifecycle
|
||||
* critical section. During ordinary first-time enrollment the setup worker
|
||||
* owns DIRECTORY_OWNERS while it retries and connector is intentionally null.
|
||||
* A Control replacement must be able to cancel that worker before claiming
|
||||
* the same credential directory; there is no credential to preserve in that
|
||||
* state. If enrollment has already published a credential but the connector
|
||||
* has not yet been installed, retain the generation just as we do for an
|
||||
* established connector.
|
||||
*/
|
||||
synchronized (lifecycle) {
|
||||
if (configuredDirectory == null)
|
||||
throw new IllegalStateException("Could not preserve the active HTTP client credential before it became ready");
|
||||
active = connector;
|
||||
if (active == null && !HttpClientCredentialStore.hasEnrolledProfile(configuredDirectory)) {
|
||||
restoreUnenrolledState = true;
|
||||
cancelledInitialization = worker;
|
||||
closeForReplacement();
|
||||
} else try {
|
||||
configuredCredentialGeneration = HttpClientCredentialStore.snapshotActiveGeneration(configuredDirectory);
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("Could not preserve the active HTTP client credential", failure);
|
||||
}
|
||||
if (cancelledInitialization == null && active == null) {
|
||||
closeForReplacement();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (cancelledInitialization != null) {
|
||||
awaitCancelledInitialization(cancelledInitialization);
|
||||
captureEnrollmentPublishedDuringCancellation();
|
||||
return;
|
||||
}
|
||||
flushForReplacement(active, System.nanoTime() + TimeUnit.SECONDS.toNanos(SHUTDOWN_FLUSH_SECONDS));
|
||||
closeForReplacement();
|
||||
}
|
||||
|
||||
private void awaitCancelledInitialization(Thread setup) {
|
||||
try { setup.join(TimeUnit.SECONDS.toMillis(SHUTDOWN_FLUSH_SECONDS)); }
|
||||
catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Interrupted while stopping HTTP client enrollment", interrupted);
|
||||
}
|
||||
if (setup.isAlive())
|
||||
throw new IllegalStateException("Could not stop HTTP client enrollment before replacement");
|
||||
}
|
||||
|
||||
private void captureEnrollmentPublishedDuringCancellation() {
|
||||
if (!HttpClientCredentialStore.hasEnrolledProfile(configuredDirectory)) return;
|
||||
try {
|
||||
HttpConnectionCode original = HttpConnectionCode.parse(configuredConnectionCode);
|
||||
if (!original.serverId().equals(HttpTlsIdentity.canonicalServerId(configuredServerId))
|
||||
|| !HttpClientCredentialStore.matchesEnrollmentCode(configuredDirectory, original))
|
||||
throw new IllegalStateException("HTTP client enrollment changed during replacement preparation");
|
||||
configuredCredentialGeneration = HttpClientCredentialStore.snapshotActiveGeneration(configuredDirectory);
|
||||
restoreUnenrolledState = false;
|
||||
} catch (IllegalStateException failure) { throw failure; }
|
||||
catch (Exception failure) {
|
||||
throw new IllegalStateException("Could not preserve the completed HTTP client enrollment", failure);
|
||||
}
|
||||
}
|
||||
|
||||
void flushForReplacement(HttpBackendTransportConnector connector, long deadlineNanos) {
|
||||
if (connector.flushOutgoing(deadlineNanos)) return;
|
||||
restartAfterFailedFlush = true;
|
||||
connector.start();
|
||||
if (flushRecoveryRunning.compareAndSet(false, true)) {
|
||||
Thread recovery = new Thread(() -> resumeAfterFailedFlush(connector),
|
||||
"VotingPlugin-HTTP-Backend-Flush-Recovery");
|
||||
recovery.setDaemon(true);
|
||||
recovery.start();
|
||||
}
|
||||
throw new IllegalStateException("Could not drain the active HTTP transport before replacement");
|
||||
}
|
||||
|
||||
private void resumeAfterFailedFlush(HttpBackendTransportConnector active) {
|
||||
try {
|
||||
// start() intentionally does nothing while the interrupted long-poll worker is
|
||||
// still winding down. Keep a single recovery owner alive until this transport is
|
||||
// closed or replaced so the connector's already-accepted queue cannot be stranded.
|
||||
while (!closed && connector == active) {
|
||||
active.start();
|
||||
synchronized (lifecycle) {
|
||||
while (!startupQueue.isEmpty() && active.send(startupQueue.peekFirst())) startupQueue.removeFirst();
|
||||
}
|
||||
try { TimeUnit.SECONDS.sleep(1); }
|
||||
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); break; }
|
||||
}
|
||||
} finally {
|
||||
flushRecoveryRunning.set(false);
|
||||
if (closed || connector != active) restartAfterFailedFlush = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void initialize(Path directory, String serverId, String configuredCode,
|
||||
GlobalMessageHandler messageHandler, boolean retryEnrollment, boolean restoreUnenrolledState) {
|
||||
Path ownerKey = directory.toAbsolutePath().normalize();
|
||||
Semaphore owner = DIRECTORY_OWNERS.computeIfAbsent(ownerKey, ignored -> new Semaphore(1));
|
||||
boolean acquired = false, installed = false;
|
||||
HttpBackendTransportConnector replacement = null;
|
||||
try {
|
||||
owner.acquire();
|
||||
acquired = true;
|
||||
synchronized (lifecycle) {
|
||||
if (closed) {
|
||||
if (credentialGenerationToRestore != null || restoreUnenrolledState)
|
||||
credentialRestoreFailure = new IllegalStateException(
|
||||
"Previous HTTP client credential state restoration was cancelled");
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (restoreUnenrolledState)
|
||||
restoreUnenrolledCredentialState(directory, serverId, configuredCode);
|
||||
if (credentialGenerationToRestore != null) {
|
||||
HttpClientCredentialStore.restoreActiveGenerationAfterReplacement(directory,
|
||||
credentialGenerationToRestore);
|
||||
}
|
||||
} catch (Exception failure) {
|
||||
credentialRestoreFailure = new IllegalStateException(
|
||||
"Could not restore the previous HTTP client credential state", failure);
|
||||
throw failure;
|
||||
}
|
||||
credentialRestoreComplete.countDown();
|
||||
// An enrolled rollback resumes the validated generation without replaying its
|
||||
// temporary code. An un-enrolled rollback reuses the original startup code
|
||||
// only after the staged credential has been made inactive.
|
||||
HttpConnectionCode code = enrollmentCode(directory, serverId,
|
||||
credentialGenerationToRestore == null ? configuredCode : null);
|
||||
if (code != null && !enrollForStartup(code, serverId, directory, retryEnrollment,
|
||||
this::waitForEnrollmentRetry)) return;
|
||||
HttpClientCredentialStore.EnrolledClient enrolled = HttpClientCredentialStore.loadEnrolled(directory);
|
||||
if (!enrolled.profile().serverId().equals(HttpTlsIdentity.canonicalServerId(serverId)))
|
||||
throw new IllegalStateException("Persisted HTTP identity belongs to a different backend Server name");
|
||||
replacement = new HttpBackendTransportConnector(directory, envelope -> {
|
||||
dispatchAfterPublication(messageHandler, envelope);
|
||||
}, wireCodec);
|
||||
invokeConnectorLifecycle(replacement, "startPaused");
|
||||
boolean discard = false;
|
||||
synchronized (lifecycle) {
|
||||
if (closed) {
|
||||
discard = true;
|
||||
} else {
|
||||
transferStartupQueue(startupQueue, replacement);
|
||||
connector = replacement;
|
||||
directoryOwner = owner;
|
||||
installed = true;
|
||||
if (inboundActive) invokeConnectorLifecycle(replacement, "activateIncoming");
|
||||
}
|
||||
}
|
||||
if (!discard) startHandoffDrainIfNeeded();
|
||||
} catch (Exception failure) {
|
||||
startupFailure = new IllegalStateException("Secure HTTP backend enrollment or connection failed", failure);
|
||||
plugin.getLogger().severe("Secure HTTP backend transport is unavailable; check the connection code and proxy endpoint");
|
||||
} finally {
|
||||
credentialRestoreComplete.countDown();
|
||||
finishInitialization(installed, replacement, acquired, owner, startupComplete);
|
||||
}
|
||||
}
|
||||
|
||||
static void finishInitialization(boolean installed, HttpBackendTransportConnector replacement,
|
||||
boolean acquired, Semaphore owner, CountDownLatch completion) {
|
||||
try {
|
||||
if (!installed) {
|
||||
try {
|
||||
if (replacement != null) replacement.close();
|
||||
} finally {
|
||||
if (acquired) owner.release();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
completion.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
static void transferStartupQueue(ArrayDeque<JsonEnvelope> queue, HttpBackendTransportConnector connector) {
|
||||
while (!queue.isEmpty()) {
|
||||
JsonEnvelope envelope = queue.peekFirst();
|
||||
if (!connector.send(envelope)) {
|
||||
throw new IllegalStateException("HTTP startup queue could not be transferred");
|
||||
}
|
||||
queue.removeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void activateAfterPublication() {
|
||||
HttpBackendTransportConnector active;
|
||||
synchronized (lifecycle) {
|
||||
if (closed) return;
|
||||
active = connector;
|
||||
}
|
||||
if (active != null) invokeConnectorLifecycle(active, "activateIncoming");
|
||||
synchronized (lifecycle) {
|
||||
if (closed) return;
|
||||
inboundActive = true;
|
||||
published = true;
|
||||
lifecycle.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean awaitInboundPublication() {
|
||||
synchronized (lifecycle) {
|
||||
while (!inboundActive && !closed) {
|
||||
try {
|
||||
lifecycle.wait();
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return !closed;
|
||||
}
|
||||
}
|
||||
|
||||
void dispatchAfterPublication(GlobalMessageHandler messageHandler, JsonEnvelope envelope) {
|
||||
if (!awaitInboundPublication())
|
||||
throw new IllegalStateException("HTTP transport closed before inbound publication");
|
||||
dispatchIncoming(messageHandler, envelope,
|
||||
System.nanoTime() + TimeUnit.SECONDS.toNanos(INCOMING_DISPATCH_SECONDS));
|
||||
}
|
||||
|
||||
private static void invokeConnectorLifecycle(HttpBackendTransportConnector connector, String method) {
|
||||
try {
|
||||
connector.getClass().getMethod(method).invoke(connector);
|
||||
} catch (java.lang.reflect.InvocationTargetException failure) {
|
||||
Throwable cause = failure.getCause();
|
||||
if (cause instanceof RuntimeException runtime) throw runtime;
|
||||
if (cause instanceof Error error) throw error;
|
||||
throw new IllegalStateException("HTTP connector " + method + " failed", cause);
|
||||
} catch (NoSuchMethodException unavailable) {
|
||||
// Older published SimpleAPI snapshots do not yet expose the publication
|
||||
// barrier. The wrapper callback above provides the same fence until #79 is
|
||||
// deployed, while newer versions use the native connector barrier.
|
||||
if ("startPaused".equals(method)) connector.start();
|
||||
else if (!"activateIncoming".equals(method))
|
||||
throw new IllegalStateException("SimpleAPI HTTP connector does not support " + method, unavailable);
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new IllegalStateException("SimpleAPI HTTP connector does not support " + method, failure);
|
||||
}
|
||||
}
|
||||
|
||||
static void restoreUnenrolledCredentialState(Path directory, String serverId, String configuredCode) throws Exception {
|
||||
Path root = directory.toAbsolutePath().normalize();
|
||||
if (configuredCode != null && !configuredCode.isBlank()
|
||||
&& HttpClientCredentialStore.hasEnrolledProfile(root)) {
|
||||
try {
|
||||
HttpConnectionCode original = HttpConnectionCode.parse(configuredCode);
|
||||
if (original.serverId().equals(HttpTlsIdentity.canonicalServerId(serverId))
|
||||
&& HttpClientCredentialStore.matchesEnrollmentCode(root, original)) return;
|
||||
} catch (Exception ignored) {
|
||||
// The pre-replacement state was un-enrolled; never retain a credential
|
||||
// that cannot be tied to its already-validated original code.
|
||||
}
|
||||
}
|
||||
Path current = root.resolve("http-transport-client-current").normalize();
|
||||
if (!current.getParent().equals(root) || Files.isSymbolicLink(current))
|
||||
throw new java.io.IOException("HTTP client credential pointer is unsafe");
|
||||
if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isRegularFile(current, LinkOption.NOFOLLOW_LINKS))
|
||||
throw new java.io.IOException("HTTP client credential pointer is unsafe");
|
||||
DurableFiles.deleteIfExists(current);
|
||||
if (HttpClientCredentialStore.hasEnrolledProfile(root))
|
||||
throw new java.io.IOException("HTTP client credential rollback did not restore the un-enrolled state");
|
||||
}
|
||||
|
||||
private boolean waitForEnrollmentRetry(long delayMillis) {
|
||||
if (closed) return false;
|
||||
try {
|
||||
TimeUnit.MILLISECONDS.sleep(delayMillis);
|
||||
return !closed;
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Performs initial enrollment with a bounded backoff for ordinary startup. */
|
||||
boolean enrollForStartup(HttpConnectionCode code, String serverId, Path directory,
|
||||
boolean retryEnrollment, java.util.function.LongPredicate waitForRetry) throws Exception {
|
||||
long retryDelayMillis = ENROLLMENT_RETRY_INITIAL_MILLIS;
|
||||
while (true) {
|
||||
if (closed) return false;
|
||||
try {
|
||||
HttpBackendTransportConnector.enroll(code, serverId, directory);
|
||||
return true;
|
||||
} catch (Exception failure) {
|
||||
if (!retryEnrollment) throw failure;
|
||||
plugin.getLogger().warning("Secure HTTP backend enrollment failed; retrying with bounded backoff");
|
||||
if (!waitForRetry.test(retryDelayMillis)) return false;
|
||||
retryDelayMillis = Math.min(ENROLLMENT_RETRY_MAX_MILLIS, retryDelayMillis * 2L);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void dispatchIncoming(GlobalMessageHandler messageHandler, JsonEnvelope envelope, long deadlineNanos) {
|
||||
CountDownLatch completed = new CountDownLatch(1);
|
||||
AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
AtomicInteger state = new AtomicInteger(0); // pending, running, cancelled, finished
|
||||
try {
|
||||
plugin.getBukkitScheduler().runTask(plugin, () -> {
|
||||
if (!state.compareAndSet(0, 1)) {
|
||||
completed.countDown();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
messageHandler.onMessage(envelope);
|
||||
} catch (Throwable thrown) {
|
||||
failure.set(thrown);
|
||||
} finally {
|
||||
state.set(3);
|
||||
completed.countDown();
|
||||
}
|
||||
});
|
||||
} catch (Throwable rejected) {
|
||||
throw new IllegalStateException("Could not schedule an incoming HTTP message on the server thread", rejected);
|
||||
}
|
||||
try {
|
||||
long remaining = deadlineNanos - System.nanoTime();
|
||||
if (remaining <= 0L || !completed.await(remaining, TimeUnit.NANOSECONDS)) {
|
||||
state.compareAndSet(0, 2);
|
||||
throw new IllegalStateException("Incoming HTTP message handling exceeded its delivery deadline");
|
||||
}
|
||||
} catch (InterruptedException interrupted) {
|
||||
state.compareAndSet(0, 2);
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Incoming HTTP message handling was interrupted", interrupted);
|
||||
}
|
||||
Throwable thrown = failure.get();
|
||||
if (thrown != null)
|
||||
throw new IllegalStateException("Incoming HTTP message handling failed", thrown);
|
||||
}
|
||||
|
||||
void awaitCredentialRestoration(long deadlineNanos) {
|
||||
if (credentialGenerationToRestore == null && !restoreUnenrolledState) return;
|
||||
try {
|
||||
long remaining = deadlineNanos - System.nanoTime();
|
||||
if (remaining <= 0L || !credentialRestoreComplete.await(remaining, TimeUnit.NANOSECONDS))
|
||||
throw new IllegalStateException("Previous HTTP client credential restoration timed out");
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Previous HTTP client credential restoration was interrupted", interrupted);
|
||||
}
|
||||
RuntimeException failure = credentialRestoreFailure;
|
||||
if (failure != null) throw failure;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean send(JsonEnvelope envelope) {
|
||||
synchronized (lifecycle) {
|
||||
if (closed) return false;
|
||||
if (awaitingPreparedHandoff || managerHandoffActive || !handoffQueue.isEmpty()) {
|
||||
int capacity = awaitingPreparedHandoff
|
||||
? Math.min(MAX_PREPUBLICATION_QUEUE, MAX_HANDOFF_QUEUE - preparedHandoffReservation)
|
||||
: managerHandoffActive ? managerHandoffCapacity : MAX_HANDOFF_QUEUE;
|
||||
if (handoffQueue.size() < capacity) {
|
||||
handoffQueue.addLast(envelope);
|
||||
return true;
|
||||
}
|
||||
warnRejectedSend();
|
||||
return false;
|
||||
}
|
||||
HttpBackendTransportConnector active = connector;
|
||||
if (active != null) {
|
||||
if (!active.send(envelope)) {
|
||||
if (restartAfterFailedFlush && startupQueue.size() < MAX_STARTUP_QUEUE) {
|
||||
startupQueue.addLast(envelope);
|
||||
return true;
|
||||
}
|
||||
warnRejectedSend();
|
||||
return false;
|
||||
}
|
||||
} else if (startupQueue.size() < MAX_STARTUP_QUEUE) {
|
||||
startupQueue.addLast(envelope);
|
||||
return true;
|
||||
} else {
|
||||
warnRejectedSend();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void beginPreparedHandoff() {
|
||||
synchronized (lifecycle) {
|
||||
if (closed) throw new IllegalStateException("HTTP replacement transport is closed");
|
||||
awaitingPreparedHandoff = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the prepared queue for the manager's worker-owned, FIFO handoff drain. */
|
||||
void activatePreparedHandoffDrain() {
|
||||
synchronized (lifecycle) {
|
||||
if (closed) throw new IllegalStateException("HTTP replacement transport is closed");
|
||||
awaitingPreparedHandoff = false;
|
||||
preparedHandoffReservation = 0;
|
||||
managerHandoffCapacity = MAX_HANDOFF_QUEUE;
|
||||
managerHandoffActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reserves the bounded one-time overflow needed to restore already accepted fenced sends. */
|
||||
void activateRestoredHandoffDrain(int managerMessages) {
|
||||
if (managerMessages < 0 || managerMessages > MAX_PREPUBLICATION_QUEUE)
|
||||
throw new IllegalStateException("HTTP restored handoff exceeds its fixed recovery capacity");
|
||||
synchronized (lifecycle) {
|
||||
if (closed) throw new IllegalStateException("HTTP restored transport is closed");
|
||||
if (handoffQueue.size() > MAX_HANDOFF_QUEUE + MAX_STARTUP_QUEUE
|
||||
|| managerMessages > MAX_RESTORED_HANDOFF_QUEUE - handoffQueue.size())
|
||||
throw new IllegalStateException("HTTP restored handoff exceeds its fixed recovery capacity");
|
||||
managerHandoffCapacity = handoffQueue.size() + managerMessages;
|
||||
managerHandoffActive = managerMessages != 0;
|
||||
}
|
||||
}
|
||||
|
||||
void finishPreparedHandoffDrain() {
|
||||
synchronized (lifecycle) {
|
||||
managerHandoffActive = false;
|
||||
managerHandoffCapacity = MAX_HANDOFF_QUEUE;
|
||||
}
|
||||
startHandoffDrainIfNeeded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the fixed HTTP handoff bound while the manager owns the older FIFO.
|
||||
* The manager calls this while holding its own monitor, which also serializes
|
||||
* its worker's transfer into {@link #handoffQueue}.
|
||||
*/
|
||||
boolean canAcceptManagerHandoff(int managerPending) {
|
||||
synchronized (lifecycle) {
|
||||
return !managerHandoffActive || managerPending >= 0
|
||||
&& managerPending < managerHandoffCapacity - handoffQueue.size();
|
||||
}
|
||||
}
|
||||
|
||||
boolean isManagerHandoffActive() {
|
||||
synchronized (lifecycle) {
|
||||
return managerHandoffActive;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserves enough of the bounded handoff queue for the predecessor before callers
|
||||
* can send through this staged replacement. This turns an otherwise late,
|
||||
* destructive capacity failure into a pre-publication validation failure.
|
||||
*/
|
||||
void reservePreparedHandoffCapacity(int messages) {
|
||||
if (messages < 0 || messages > MAX_HANDOFF_QUEUE)
|
||||
throw new IllegalStateException("HTTP prepared handoff exceeds its fixed capacity");
|
||||
synchronized (lifecycle) {
|
||||
if (closed) throw new IllegalStateException("HTTP replacement transport is closed");
|
||||
if (!awaitingPreparedHandoff)
|
||||
throw new IllegalStateException("HTTP replacement transport is not awaiting a prepared handoff");
|
||||
if (handoffQueue.size() > MAX_HANDOFF_QUEUE - messages)
|
||||
throw new IllegalStateException("HTTP handoff queue exceeded its reserved capacity");
|
||||
preparedHandoffReservation = messages;
|
||||
}
|
||||
}
|
||||
|
||||
void acceptHandoffMessages(java.util.List<JsonEnvelope> messages) {
|
||||
synchronized (lifecycle) {
|
||||
if (closed) throw new IllegalStateException("HTTP replacement transport is closed");
|
||||
if (messages.size() > MAX_HANDOFF_QUEUE - handoffQueue.size())
|
||||
throw new IllegalStateException("HTTP handoff queue exceeded its fixed capacity");
|
||||
java.util.ArrayDeque<JsonEnvelope> newer = new java.util.ArrayDeque<>(handoffQueue);
|
||||
handoffQueue.clear();
|
||||
handoffQueue.addAll(messages);
|
||||
handoffQueue.addAll(newer);
|
||||
awaitingPreparedHandoff = false;
|
||||
preparedHandoffReservation = 0;
|
||||
}
|
||||
startHandoffDrainIfNeeded();
|
||||
}
|
||||
|
||||
/** Appends a newer manager-owned remainder behind any prefix already admitted by its worker. */
|
||||
void appendShutdownHandoffMessages(java.util.List<JsonEnvelope> messages) {
|
||||
synchronized (lifecycle) {
|
||||
if (closed) throw new IllegalStateException("HTTP replacement transport is closed");
|
||||
int capacity = managerHandoffActive ? managerHandoffCapacity : MAX_HANDOFF_QUEUE;
|
||||
if (messages.size() > capacity - handoffQueue.size())
|
||||
throw new IllegalStateException("HTTP handoff queue exceeded its fixed capacity");
|
||||
handoffQueue.addAll(messages);
|
||||
awaitingPreparedHandoff = false;
|
||||
preparedHandoffReservation = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void startHandoffDrainIfNeeded() {
|
||||
Thread drain;
|
||||
synchronized (lifecycle) {
|
||||
if (closed || handoffQueue.isEmpty() || handoffWorker != null || connector == null) return;
|
||||
drain = new Thread(this::drainHandoffMessages, "VotingPlugin-HTTP-Backend-Handoff");
|
||||
drain.setDaemon(true);
|
||||
handoffWorker = drain;
|
||||
}
|
||||
drain.start();
|
||||
}
|
||||
|
||||
private void drainHandoffMessages() {
|
||||
try {
|
||||
while (!Thread.currentThread().isInterrupted()) {
|
||||
boolean delivered = false;
|
||||
synchronized (lifecycle) {
|
||||
if (closed || handoffQueue.isEmpty()) return;
|
||||
HttpBackendTransportConnector active = connector;
|
||||
if (active != null && active.send(handoffQueue.peekFirst())) {
|
||||
handoffQueue.removeFirst();
|
||||
delivered = true;
|
||||
}
|
||||
}
|
||||
if (!delivered) TimeUnit.MILLISECONDS.sleep(25L);
|
||||
}
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
} finally {
|
||||
synchronized (lifecycle) {
|
||||
if (handoffWorker == Thread.currentThread()) handoffWorker = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
java.util.List<JsonEnvelope> handoffMessagesSnapshot() {
|
||||
synchronized (lifecycle) {
|
||||
return java.util.List.copyOf(handoffQueue);
|
||||
}
|
||||
}
|
||||
|
||||
private void warnRejectedSend() {
|
||||
if (queueWarning.compareAndSet(false, true))
|
||||
plugin.getLogger().severe("Secure HTTP transport queue is full or rejected an oversized message; delivery was not accepted");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() {
|
||||
validate(System.nanoTime() + TimeUnit.SECONDS.toNanos(DEFAULT_STARTUP_VALIDATION_SECONDS));
|
||||
}
|
||||
|
||||
void validate(long deadlineNanos) {
|
||||
String serverId = plugin.getBungeeSettings().getServer();
|
||||
if (serverId == null || !serverId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}")) {
|
||||
throw new IllegalStateException("HTTP requires a valid unique backend Server name");
|
||||
}
|
||||
Path directory = plugin.getDataFolder().toPath().resolve("http");
|
||||
validateConfiguration(directory, serverId, plugin.getBungeeSettings().getHttpConnectionCode());
|
||||
if (!started) return;
|
||||
try {
|
||||
long remaining = deadlineNanos - System.nanoTime();
|
||||
if (remaining <= 0L || !startupComplete.await(remaining, TimeUnit.NANOSECONDS))
|
||||
throw new IllegalStateException("Secure HTTP backend setup did not finish within the validation deadline");
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Secure HTTP backend setup validation was interrupted", interrupted);
|
||||
}
|
||||
RuntimeException failure = startupFailure;
|
||||
if (failure != null) throw failure;
|
||||
HttpBackendTransportConnector active = connector;
|
||||
if (closed || active == null) throw new IllegalStateException("Secure HTTP backend transport did not initialize");
|
||||
try {
|
||||
if (!active.awaitFirstResponse(deadlineNanos))
|
||||
throw new IllegalStateException("HTTP backend could not authenticate with the proxy");
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Secure HTTP backend readiness validation was interrupted", interrupted);
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateConfiguration(Path directory, String serverId, String configuredCode) {
|
||||
enrollmentCode(directory, serverId, configuredCode);
|
||||
}
|
||||
|
||||
static HttpConnectionCode enrollmentCode(Path directory, String serverId, String configuredCode) {
|
||||
try { serverId = HttpTlsIdentity.canonicalServerId(serverId); }
|
||||
catch (IllegalArgumentException invalid) { throw new IllegalStateException("HTTP requires a valid unique backend Server name", invalid); }
|
||||
boolean enrolled = HttpClientCredentialStore.hasEnrolledProfile(directory);
|
||||
if (configuredCode != null && !configuredCode.isBlank()) {
|
||||
try {
|
||||
HttpConnectionCode code = HttpConnectionCode.parse(configuredCode);
|
||||
if (!code.serverId().equals(serverId))
|
||||
throw new IllegalArgumentException("Connection code belongs to a different backend");
|
||||
if (enrolled && HttpClientCredentialStore.matchesEnrollmentCode(directory, code)) return null;
|
||||
code.requireActive(Clock.systemUTC());
|
||||
return code;
|
||||
} catch (Exception invalid) {
|
||||
throw new IllegalStateException("HTTP ConnectionCode is invalid, expired, or belongs to a different backend", invalid);
|
||||
}
|
||||
}
|
||||
if (!enrolled)
|
||||
throw new IllegalStateException("HTTP requires a temporary ConnectionCode for initial enrollment");
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
close(true, false);
|
||||
}
|
||||
|
||||
private void closeForReplacement() {
|
||||
close(false, false);
|
||||
}
|
||||
|
||||
/** Performs the bounded connector drain on its caller-owned cleanup worker. */
|
||||
void closeAndAwaitFinalHandoff() {
|
||||
close(true, true);
|
||||
}
|
||||
|
||||
private void close(boolean discardQueuedMessages, boolean awaitCleanup) {
|
||||
Thread setup;
|
||||
Thread pendingHandoff;
|
||||
HttpBackendTransportConnector active;
|
||||
Semaphore owner;
|
||||
java.util.List<JsonEnvelope> finalHandoff;
|
||||
synchronized (lifecycle) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
lifecycle.notifyAll();
|
||||
if (discardQueuedMessages) {
|
||||
if (published) {
|
||||
finalHandoff = new java.util.ArrayList<>(startupQueue.size() + handoffQueue.size());
|
||||
finalHandoff.addAll(startupQueue);
|
||||
finalHandoff.addAll(handoffQueue);
|
||||
} else {
|
||||
// A staged replacement never became authoritative. Its presence and
|
||||
// handoff messages must not be flushed after rollback.
|
||||
finalHandoff = java.util.List.of();
|
||||
}
|
||||
startupQueue.clear();
|
||||
handoffQueue.clear();
|
||||
} else finalHandoff = java.util.List.of();
|
||||
awaitingPreparedHandoff = false;
|
||||
setup = worker;
|
||||
worker = null;
|
||||
pendingHandoff = handoffWorker;
|
||||
handoffWorker = null;
|
||||
active = connector;
|
||||
connector = null;
|
||||
owner = directoryOwner;
|
||||
directoryOwner = null;
|
||||
}
|
||||
startupComplete.countDown();
|
||||
if (setup != null) setup.interrupt();
|
||||
if (pendingHandoff != null) pendingHandoff.interrupt();
|
||||
if (setup == null && active == null && owner == null) return;
|
||||
if (awaitCleanup) {
|
||||
drain(setup, active, owner, finalHandoff);
|
||||
} else {
|
||||
Thread cleanup = new Thread(() -> drain(setup, active, owner, finalHandoff),
|
||||
"VotingPlugin-HTTP-Backend-Cleanup");
|
||||
cleanup.setDaemon(true);
|
||||
cleanup.start();
|
||||
}
|
||||
}
|
||||
|
||||
private static void drain(Thread setup, HttpBackendTransportConnector active, Semaphore owner,
|
||||
java.util.List<JsonEnvelope> finalHandoff) {
|
||||
try {
|
||||
if (setup != null) try { setup.join(TimeUnit.SECONDS.toMillis(5)); }
|
||||
catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
|
||||
if (active != null) {
|
||||
flushHandoffForShutdown(active, finalHandoff,
|
||||
System.nanoTime() + TimeUnit.SECONDS.toNanos(SHUTDOWN_FLUSH_SECONDS));
|
||||
active.close();
|
||||
}
|
||||
} finally { if (owner != null) owner.release(); }
|
||||
}
|
||||
|
||||
static boolean flushHandoffForShutdown(HttpBackendTransportConnector active,
|
||||
java.util.List<JsonEnvelope> finalHandoff, long deadlineNanos) {
|
||||
for (JsonEnvelope envelope : finalHandoff) {
|
||||
while (!active.send(envelope)) {
|
||||
long remaining = deadlineNanos - System.nanoTime();
|
||||
if (remaining <= 0L) return false;
|
||||
try {
|
||||
TimeUnit.NANOSECONDS.sleep(Math.min(remaining, TimeUnit.MILLISECONDS.toNanos(25L)));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return active.flushOutgoing(deadlineNanos);
|
||||
}
|
||||
}
|
||||
+130
-12
@@ -1,5 +1,7 @@
|
||||
package com.bencodez.votingplugin.backendproxy.transport;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.eclipse.paho.client.mqttv3.MqttException;
|
||||
|
||||
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
|
||||
@@ -7,6 +9,11 @@ import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler;
|
||||
import com.bencodez.simpleapi.servercomm.mqtt.MqttHandler;
|
||||
import com.bencodez.simpleapi.servercomm.mqtt.MqttServerComm;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator;
|
||||
import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Domain;
|
||||
import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode;
|
||||
import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption;
|
||||
import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption.Decryption;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@@ -15,22 +22,59 @@ public class MqttBackendProxyTransport implements BackendProxyTransport {
|
||||
private final VotingPluginMain plugin;
|
||||
@Getter
|
||||
private MqttHandler mqttHandler;
|
||||
private GlobalMessageHandler messageHandler;
|
||||
private String publishTopic;
|
||||
private String subscriptionTopic;
|
||||
private String clientId;
|
||||
private String brokerUrl;
|
||||
private String username;
|
||||
private String password;
|
||||
private volatile SharedTransportEnvelopeAuthenticator authenticator;
|
||||
private volatile SharedTransportSecurityPolicy securityPolicy;
|
||||
|
||||
void updateSecurity(SharedTransportEnvelopeAuthenticator replacementAuthenticator,
|
||||
TransportEnvelopeEncryption replacementEncryption) {
|
||||
SharedTransportSecurityPolicy current = securityPolicy();
|
||||
SharedTransportSecurityPolicy replacement = current.replace(replacementAuthenticator, replacementEncryption);
|
||||
authenticator = replacement.authenticator();
|
||||
securityPolicy = replacement;
|
||||
}
|
||||
|
||||
private final AtomicBoolean authenticationFailureLogged = new AtomicBoolean();
|
||||
|
||||
public MqttBackendProxyTransport(VotingPluginMain plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
SharedInboundPolicy sharedInboundPolicySnapshot() {
|
||||
SharedTransportSecurityPolicy policy = securityPolicy();
|
||||
return new SharedInboundPolicy(getClass(), subscriptionTopic, policy.authenticator(), policy.encryption());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start(GlobalMessageHandler messageHandler) {
|
||||
try {
|
||||
String id = plugin.getBungeeSettings().getMqttClientID();
|
||||
if (id.isEmpty()) {
|
||||
id = plugin.getOptions().getServer();
|
||||
this.messageHandler = messageHandler;
|
||||
authenticator = SharedTransportEnvelopeAuthenticator.load(
|
||||
plugin.getDataFolder().toPath().resolve("secretkey.key"),
|
||||
Mode.parse(plugin.getBungeeSettings().getSharedTransportAuthentication()));
|
||||
securityPolicy = new SharedTransportSecurityPolicy(authenticator, TransportEnvelopeEncryption.load(
|
||||
plugin.getDataFolder().toPath().resolve("secretkey.key"),
|
||||
TransportEnvelopeEncryption.Domain.PROXY_BACKEND,
|
||||
plugin.getBungeeSettings().isCommunicationEncryption()));
|
||||
if (authenticator.mode() == Mode.COMPATIBILITY) plugin.getLogger().warning(
|
||||
"SharedTransportAuthentication is COMPATIBILITY; unsigned MQTT messages are accepted during this rolling upgrade");
|
||||
publishTopic = plugin.getBungeeSettings().getMqttPrefix() + "votingplugin/servers/proxy";
|
||||
subscriptionTopic = plugin.getBungeeSettings().getMqttPrefix() + "votingplugin/servers/"
|
||||
+ plugin.getOptions().getServer();
|
||||
clientId = plugin.getBungeeSettings().getMqttClientID();
|
||||
if (clientId.isEmpty()) {
|
||||
clientId = plugin.getOptions().getServer();
|
||||
}
|
||||
mqttHandler = new MqttHandler(new MqttServerComm(id, plugin.getBungeeSettings().getMqttBrokerURL(),
|
||||
plugin.getBungeeSettings().getMqttUsername(), plugin.getBungeeSettings().getMqttPassword()), 2);
|
||||
mqttHandler.subscribeEnvelopes(plugin.getBungeeSettings().getMqttPrefix() + "votingplugin/servers/"
|
||||
+ plugin.getOptions().getServer(), (topic, envelope) -> messageHandler.onMessage(envelope));
|
||||
brokerUrl = plugin.getBungeeSettings().getMqttBrokerURL();
|
||||
username = plugin.getBungeeSettings().getMqttUsername();
|
||||
password = plugin.getBungeeSettings().getMqttPassword();
|
||||
startCapturedConnection();
|
||||
} catch (MqttException e) {
|
||||
throw new IllegalStateException("MQTT backend proxy transport initialization failed", e);
|
||||
} catch (Exception e) {
|
||||
@@ -38,21 +82,83 @@ public class MqttBackendProxyTransport implements BackendProxyTransport {
|
||||
}
|
||||
}
|
||||
|
||||
protected MqttHandler createMqttHandler(MqttServerComm server) throws MqttException {
|
||||
return new MqttHandler(server, 2);
|
||||
}
|
||||
|
||||
protected MqttServerComm createMqttServerComm() throws MqttException {
|
||||
return new MqttServerComm(clientId, brokerUrl, username, password);
|
||||
}
|
||||
|
||||
private void startCapturedConnection() throws Exception {
|
||||
MqttHandler candidate = createMqttHandler(createMqttServerComm());
|
||||
try {
|
||||
candidate.subscribeEnvelopes(subscriptionTopic,
|
||||
(topic, envelope) -> acceptAuthenticatedEnvelope(envelope, topic));
|
||||
mqttHandler = candidate;
|
||||
} catch (Exception subscriptionFailure) {
|
||||
try {
|
||||
candidate.disconnect();
|
||||
} catch (Exception disconnectFailure) {
|
||||
subscriptionFailure.addSuppressed(disconnectFailure);
|
||||
}
|
||||
throw subscriptionFailure;
|
||||
}
|
||||
}
|
||||
|
||||
void acceptAuthenticatedEnvelope(JsonEnvelope envelope, String topic) {
|
||||
SharedTransportSecurityPolicy policy = securityPolicy();
|
||||
SharedTransportEnvelopeAuthenticator.Verification verification = policy.authenticator().verify(envelope,
|
||||
Domain.MQTT_PROXY_BACKEND, topic);
|
||||
if (!verification.accepted()) {
|
||||
if (plugin != null && authenticationFailureLogged.compareAndSet(false, true)) plugin.getLogger()
|
||||
.warning("MQTT shared transport message rejected by envelope authentication ("
|
||||
+ verification.rejection() + ")");
|
||||
return;
|
||||
}
|
||||
Decryption decrypted = policy.encryption().decrypt(verification.envelope());
|
||||
if (!decrypted.accepted()) {
|
||||
if (plugin != null && authenticationFailureLogged.compareAndSet(false, true)) plugin.getLogger()
|
||||
.warning("MQTT shared transport message rejected by encryption policy");
|
||||
return;
|
||||
}
|
||||
messageHandler.onMessage(decrypted.envelope());
|
||||
}
|
||||
|
||||
private SharedTransportSecurityPolicy securityPolicy() {
|
||||
SharedTransportSecurityPolicy current = securityPolicy;
|
||||
if (current != null) return current;
|
||||
return new SharedTransportSecurityPolicy(java.util.Objects.requireNonNull(authenticator),
|
||||
TransportEnvelopeEncryption.disabled(TransportEnvelopeEncryption.Domain.PROXY_BACKEND));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() {
|
||||
if (mqttHandler == null) throw new IllegalStateException("MQTT backend proxy transport initialization failed");
|
||||
}
|
||||
|
||||
/** Returns whether a failed disconnect left the existing broker session usable. */
|
||||
public boolean isConnected() {
|
||||
return mqttHandler != null && mqttHandler.isConnected();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(JsonEnvelope envelope) {
|
||||
public boolean send(JsonEnvelope envelope) {
|
||||
if (mqttHandler == null) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
mqttHandler.publishEnvelope(plugin.getBungeeSettings().getMqttPrefix() + "votingplugin/servers/proxy",
|
||||
envelope);
|
||||
SharedTransportSecurityPolicy policy = securityPolicy();
|
||||
JsonEnvelope encrypted = policy.encryption().encrypt(envelope);
|
||||
mqttHandler.publishEnvelope(publishTopic, policy.authenticator().sign(encrypted, Domain.MQTT_PROXY_BACKEND,
|
||||
plugin.getBungeeSettings().getServer(), publishTopic));
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
if (plugin != null && plugin.getLogger() != null) {
|
||||
plugin.getLogger().warning("MQTT backend proxy delivery failed");
|
||||
plugin.debug(e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,4 +186,16 @@ public class MqttBackendProxyTransport implements BackendProxyTransport {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconnects a prepared predecessor with its original identity after rollback. */
|
||||
public void restoreAfterFailedReplacement() {
|
||||
if (messageHandler == null || clientId == null || brokerUrl == null || subscriptionTopic == null) {
|
||||
throw new IllegalStateException("MQTT backend proxy transport cannot be restored before startup");
|
||||
}
|
||||
try {
|
||||
startCapturedConnection();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("MQTT backend proxy transport restoration failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -43,14 +43,19 @@ public class MysqlBackendProxyTransport implements BackendProxyTransport {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(JsonEnvelope envelope) {
|
||||
public boolean send(JsonEnvelope envelope) {
|
||||
if (messenger == null) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
messenger.sendToProxy(envelope);
|
||||
return true;
|
||||
} catch (SQLException e) {
|
||||
e.printStackTrace();
|
||||
if (plugin != null && plugin.getLogger() != null) {
|
||||
plugin.getLogger().warning("MySQL backend proxy delivery failed");
|
||||
plugin.debug(e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
-6
@@ -11,6 +11,10 @@ public class PluginMessagingBackendProxyTransport implements BackendProxyTranspo
|
||||
|
||||
private final VotingPluginMain plugin;
|
||||
private GlobalMessageHandler messageHandler;
|
||||
private String channel;
|
||||
private EncryptionHandler encryptionHandler;
|
||||
private boolean debug;
|
||||
private boolean active;
|
||||
|
||||
public PluginMessagingBackendProxyTransport(VotingPluginMain plugin) {
|
||||
this.plugin = plugin;
|
||||
@@ -19,27 +23,53 @@ public class PluginMessagingBackendProxyTransport implements BackendProxyTranspo
|
||||
@Override
|
||||
public void start(GlobalMessageHandler messageHandler) {
|
||||
this.messageHandler = messageHandler;
|
||||
plugin.registerBungeeChannels(plugin.getBungeeSettings().getPluginMessagingChannel());
|
||||
EncryptionHandler encryptionHandler = null;
|
||||
channel = plugin.getBungeeSettings().getPluginMessagingChannel();
|
||||
encryptionHandler = null;
|
||||
if (plugin.getBungeeSettings().isPluginMessageEncryption()) {
|
||||
encryptionHandler = new EncryptionHandler(plugin.getName(),
|
||||
new File(plugin.getDataFolder(), "secretkey.key"));
|
||||
}
|
||||
debug = plugin.getBungeeSettings().isBungeeDebug();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void activateAfterPublication() {
|
||||
if (messageHandler != null && !active) {
|
||||
publishSharedState();
|
||||
active = true;
|
||||
}
|
||||
}
|
||||
|
||||
void restoreAfterFailedReplacement() {
|
||||
if (messageHandler != null) {
|
||||
publishSharedState();
|
||||
active = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void publishSharedState() {
|
||||
if (plugin.getPluginMessaging() == null || !channel.equals(plugin.getBungeeChannel())) {
|
||||
plugin.registerBungeeChannels(channel);
|
||||
}
|
||||
plugin.getPluginMessaging().setEncryptionHandler(encryptionHandler);
|
||||
plugin.getPluginMessaging().setDebug(plugin.getBungeeSettings().isBungeeDebug());
|
||||
plugin.getPluginMessaging().setDebug(debug);
|
||||
plugin.activateBackendPluginMessageHandler(messageHandler);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(JsonEnvelope envelope) {
|
||||
public boolean send(JsonEnvelope envelope) {
|
||||
plugin.getPluginMessaging().sendEnvelope(envelope);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (messageHandler != null) {
|
||||
if (messageHandler != null && active) {
|
||||
plugin.deactivateBackendPluginMessageHandler(messageHandler);
|
||||
messageHandler = null;
|
||||
}
|
||||
active = false;
|
||||
messageHandler = null;
|
||||
channel = null;
|
||||
encryptionHandler = null;
|
||||
}
|
||||
}
|
||||
+836
-29
@@ -4,6 +4,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import javax.net.ssl.SSLParameters;
|
||||
|
||||
@@ -14,6 +15,12 @@ import com.bencodez.simpleapi.servercomm.redis.RedisListener;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache;
|
||||
import com.bencodez.votingplugin.proxy.VotingPluginWire;
|
||||
import com.bencodez.votingplugin.proxy.redis.VotingPluginRedisChannels;
|
||||
import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator;
|
||||
import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Domain;
|
||||
import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode;
|
||||
import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption;
|
||||
import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption.Decryption;
|
||||
|
||||
import redis.clients.jedis.DefaultJedisClientConfig;
|
||||
import redis.clients.jedis.HostAndPort;
|
||||
@@ -22,7 +29,29 @@ import redis.clients.jedis.Jedis;
|
||||
import lombok.Getter;
|
||||
|
||||
public class RedisBackendProxyTransport implements BackendProxyTransport {
|
||||
public static final class HandoffQuiescenceException extends IllegalStateException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public HandoffQuiescenceException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class HandoffReplayBackpressureException extends IllegalStateException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public HandoffReplayBackpressureException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
static final int MAX_LEGACY_HANDOFF_DELIVERIES = 4096;
|
||||
static final int MAX_IDENTIFIED_HANDOFF_DELIVERIES = 4096;
|
||||
static final int MAX_REPLAY_HANDOFF_DELIVERIES = MAX_LEGACY_HANDOFF_DELIVERIES
|
||||
+ MAX_IDENTIFIED_HANDOFF_DELIVERIES;
|
||||
static final int REPLAY_BATCH_SIZE = 32;
|
||||
private static final long HANDOFF_QUIESCE_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(3);
|
||||
private static final long HANDOFF_REPLAY_BACKPRESSURE_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(3);
|
||||
private static final long REPLAY_BATCH_EXECUTION_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(3);
|
||||
|
||||
private final VotingPluginMain plugin;
|
||||
private final ProcessedVoteCache processedVoteCache;
|
||||
@@ -32,10 +61,46 @@ public class RedisBackendProxyTransport implements BackendProxyTransport {
|
||||
private Thread listenerThread;
|
||||
private final Object subscriberIdentity = new Object();
|
||||
private final Object legacyLifecycle = new Object();
|
||||
private final List<JsonEnvelope> bufferedLegacyDeliveries = new ArrayList<>();
|
||||
private final List<BufferedHandoffDelivery> bufferedLegacyDeliveries = new ArrayList<>();
|
||||
private final List<BufferedHandoffDelivery> bufferedIdentifiedDeliveries = new ArrayList<>();
|
||||
private long bufferedLegacyDeliveryBytes;
|
||||
private long bufferedIdentifiedDeliveryBytes;
|
||||
private long nextHandoffSequence;
|
||||
private boolean standbySubscriber;
|
||||
private boolean identifiedHandoffOverflowed;
|
||||
private boolean legacyHandoffOverflowed;
|
||||
private boolean legacyHandoffDegraded;
|
||||
private boolean retiredAfterHandoff;
|
||||
private boolean replayingHandoff;
|
||||
private boolean replayTransferFrozen;
|
||||
private boolean replayBackpressureFailureLogged;
|
||||
private long replayGeneration;
|
||||
private Thread replayWorker;
|
||||
private boolean replayTaskOutstanding;
|
||||
private long replayTaskGeneration = -1L;
|
||||
private int replayDeliveriesInFlight;
|
||||
// Counts only handlers which have already crossed the Bukkit dispatch fence.
|
||||
// It is deliberately independent of replayGeneration so cancellation cannot
|
||||
// erase accounting for a callback that was already executing.
|
||||
private int replayCallbacksInFlight;
|
||||
private int dispatchesInFlight;
|
||||
private final java.util.ArrayDeque<JsonEnvelope> deliveriesAfterReplay = new java.util.ArrayDeque<>();
|
||||
private GlobalMessageHandler messageHandler;
|
||||
private GlobalMessageHandler handoffMessageHandler;
|
||||
private String publishChannel;
|
||||
private String subscriptionChannel;
|
||||
private volatile SharedTransportEnvelopeAuthenticator authenticator;
|
||||
private volatile SharedTransportSecurityPolicy securityPolicy;
|
||||
|
||||
void updateSecurity(SharedTransportEnvelopeAuthenticator replacementAuthenticator,
|
||||
TransportEnvelopeEncryption replacementEncryption) {
|
||||
SharedTransportSecurityPolicy current = securityPolicy();
|
||||
SharedTransportSecurityPolicy replacement = current.replace(replacementAuthenticator, replacementEncryption);
|
||||
authenticator = replacement.authenticator();
|
||||
securityPolicy = replacement;
|
||||
}
|
||||
|
||||
private final AtomicBoolean authenticationFailureLogged = new AtomicBoolean();
|
||||
|
||||
public RedisBackendProxyTransport(VotingPluginMain plugin) {
|
||||
this(plugin, new ProcessedVoteCache());
|
||||
@@ -49,7 +114,24 @@ public class RedisBackendProxyTransport implements BackendProxyTransport {
|
||||
@Override
|
||||
public void start(GlobalMessageHandler messageHandler) {
|
||||
this.messageHandler = messageHandler;
|
||||
processedVoteCache.registerRedisSubscriber(subscriberIdentity);
|
||||
try {
|
||||
authenticator = SharedTransportEnvelopeAuthenticator.load(
|
||||
plugin.getDataFolder().toPath().resolve("secretkey.key"),
|
||||
Mode.parse(plugin.getBungeeSettings().getSharedTransportAuthentication()));
|
||||
securityPolicy = new SharedTransportSecurityPolicy(authenticator, TransportEnvelopeEncryption.load(
|
||||
plugin.getDataFolder().toPath().resolve("secretkey.key"),
|
||||
TransportEnvelopeEncryption.Domain.PROXY_BACKEND,
|
||||
plugin.getBungeeSettings().isCommunicationEncryption()));
|
||||
} catch (java.io.IOException authenticationFailure) {
|
||||
throw new IllegalStateException("Redis backend transport authentication initialization failed",
|
||||
authenticationFailure);
|
||||
}
|
||||
warnIfCompatibilityMode();
|
||||
publishChannel = VotingPluginRedisChannels.proxy(plugin.getBungeeSettings().getRedisPrefix());
|
||||
subscriptionChannel = VotingPluginRedisChannels.backend(plugin.getBungeeSettings().getRedisPrefix(),
|
||||
plugin.getBungeeSettings().getServer());
|
||||
retiredAfterHandoff = false;
|
||||
standbySubscriber = !processedVoteCache.registerRedisSubscriber(subscriberIdentity);
|
||||
redisHandler = new RedisHandler(plugin.getBungeeSettings().getRedisHost(),
|
||||
plugin.getBungeeSettings().getRedisPort(), plugin.getBungeeSettings().getRedisUsername(),
|
||||
plugin.getBungeeSettings().getRedisPassword(), plugin.getBungeeSettings().getRedisdbindex(),
|
||||
@@ -64,17 +146,11 @@ public class RedisBackendProxyTransport implements BackendProxyTransport {
|
||||
RedisHandler handler = redisHandler;
|
||||
CountDownLatch ready = new CountDownLatch(1);
|
||||
subscriptionReady = ready;
|
||||
RedisListener listener = new RedisListener(handler,
|
||||
plugin.getBungeeSettings().getRedisPrefix() + "VotingPlugin_" + plugin.getBungeeSettings().getServer(),
|
||||
RedisListener listener = new RedisListener(handler, subscriptionChannel,
|
||||
(ch, payload) -> {
|
||||
try {
|
||||
JsonEnvelope envelope = com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec.decode(payload);
|
||||
String deliveryId = envelope.getFields().get(VotingPluginWire.K_REDIS_DELIVERY_ID);
|
||||
if (deliveryId != null) {
|
||||
if (processedVoteCache.reserveRedisDelivery(deliveryId)) messageHandler.onMessage(envelope);
|
||||
} else {
|
||||
dispatchLegacy(envelope);
|
||||
}
|
||||
acceptAuthenticatedEnvelope(envelope, ch);
|
||||
} catch (Exception e) {
|
||||
plugin.debug("Redis decode failed: " + e.getMessage());
|
||||
}
|
||||
@@ -89,25 +165,168 @@ public class RedisBackendProxyTransport implements BackendProxyTransport {
|
||||
listenerThread.start();
|
||||
}
|
||||
|
||||
void dispatchLegacy(JsonEnvelope envelope) {
|
||||
String signature = com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec.encode(envelope);
|
||||
int encodedBytes = ProcessedVoteCache.legacyRedisDeliveryBytes(signature);
|
||||
SharedInboundPolicy sharedInboundPolicySnapshot() {
|
||||
SharedTransportSecurityPolicy policy = securityPolicy();
|
||||
return new SharedInboundPolicy(getClass(), subscriptionChannel, policy.authenticator(), policy.encryption());
|
||||
}
|
||||
|
||||
void acceptAuthenticatedEnvelope(JsonEnvelope envelope, String channel) {
|
||||
SharedTransportSecurityPolicy policy = securityPolicy();
|
||||
SharedTransportEnvelopeAuthenticator.Verification verification = policy.authenticator().verify(envelope,
|
||||
Domain.REDIS_PROXY_BACKEND, channel);
|
||||
if (!verification.accepted()) {
|
||||
logAuthenticationFailure("Redis", verification.rejection());
|
||||
return;
|
||||
}
|
||||
JsonEnvelope accepted = verification.envelope();
|
||||
String deliveryId = accepted.getFields().get(VotingPluginWire.K_REDIS_DELIVERY_ID);
|
||||
Decryption decrypted = policy.encryption().decrypt(accepted);
|
||||
if (!decrypted.accepted()) {
|
||||
logAuthenticationFailure("Redis", SharedTransportEnvelopeAuthenticator.Rejection.INVALID);
|
||||
return;
|
||||
}
|
||||
dispatchReceivedSubscriberEnvelope(decrypted.envelope(), deliveryId);
|
||||
}
|
||||
|
||||
private SharedTransportSecurityPolicy securityPolicy() {
|
||||
SharedTransportSecurityPolicy current = securityPolicy;
|
||||
if (current != null) return current;
|
||||
return new SharedTransportSecurityPolicy(java.util.Objects.requireNonNull(authenticator),
|
||||
TransportEnvelopeEncryption.disabled(TransportEnvelopeEncryption.Domain.PROXY_BACKEND));
|
||||
}
|
||||
|
||||
private void warnIfCompatibilityMode() {
|
||||
if (authenticator.mode() == Mode.COMPATIBILITY) plugin.getLogger().warning(
|
||||
"SharedTransportAuthentication is COMPATIBILITY; unsigned Redis messages are accepted during this rolling upgrade");
|
||||
}
|
||||
|
||||
private void logAuthenticationFailure(String transport,
|
||||
SharedTransportEnvelopeAuthenticator.Rejection rejection) {
|
||||
if (plugin != null && authenticationFailureLogged.compareAndSet(false, true)) plugin.getLogger().warning(
|
||||
transport + " shared transport message rejected by envelope authentication (" + rejection + ")");
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis Pub/Sub has already consumed this payload when its callback is invoked.
|
||||
* A bounded queue wait therefore cannot discard it: retry the same callback in
|
||||
* place, applying TCP/Pub/Sub backpressure until FIFO replay capacity returns or
|
||||
* this subscriber is deliberately fenced during shutdown/rollback.
|
||||
*/
|
||||
void dispatchReceivedSubscriberEnvelope(JsonEnvelope envelope, String deliveryId) {
|
||||
while (true) {
|
||||
try {
|
||||
if (deliveryId != null) dispatchIdentified(envelope, deliveryId);
|
||||
else dispatchLegacy(envelope);
|
||||
return;
|
||||
} catch (HandoffReplayBackpressureException retry) {
|
||||
synchronized (legacyLifecycle) {
|
||||
if (retiredAfterHandoff) return;
|
||||
}
|
||||
if (plugin != null)
|
||||
plugin.debug("Redis handoff replay remains full; retaining the received Pub/Sub payload for retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void dispatchIdentified(JsonEnvelope envelope, String deliveryId) {
|
||||
boolean accepted;
|
||||
synchronized (legacyLifecycle) {
|
||||
if (!awaitReplayTransferResolution()) return;
|
||||
if (retiredAfterHandoff) return;
|
||||
awaitReplayCapacity();
|
||||
if (retiredAfterHandoff) return;
|
||||
if (standbySubscriber) {
|
||||
bufferIdentifiedDelivery(envelope, deliveryId);
|
||||
return;
|
||||
}
|
||||
accepted = processedVoteCache.reserveRedisDelivery(deliveryId);
|
||||
if (accepted && replayingHandoff) {
|
||||
enqueueReplayDelivery(envelope);
|
||||
accepted = false;
|
||||
}
|
||||
if (accepted) dispatchesInFlight++;
|
||||
}
|
||||
if (accepted) dispatchTracked(envelope);
|
||||
}
|
||||
|
||||
void dispatchLegacy(JsonEnvelope envelope) {
|
||||
// A mixed-version publisher may put the reliable-only identity on a payload
|
||||
// that is delivered through the legacy path. Do not expose that identity to
|
||||
// handlers or let it alter legacy duplicate accounting.
|
||||
JsonEnvelope legacyEnvelope = withoutReliableFields(envelope);
|
||||
String signature = com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec.encode(legacyEnvelope);
|
||||
int encodedBytes = ProcessedVoteCache.legacyRedisDeliveryBytes(signature);
|
||||
boolean dispatch = false;
|
||||
synchronized (legacyLifecycle) {
|
||||
if (!awaitReplayTransferResolution()) return;
|
||||
if (retiredAfterHandoff) return;
|
||||
awaitReplayCapacity();
|
||||
if (retiredAfterHandoff) return;
|
||||
if (processedVoteCache.reserveLegacyRedisDelivery(subscriberIdentity, signature)) {
|
||||
messageHandler.onMessage(envelope);
|
||||
dispatch = true;
|
||||
} else if (encodedBytes <= ProcessedVoteCache.MAX_LEGACY_REDIS_DELIVERY_BYTES
|
||||
&& bufferedLegacyDeliveries.size() < MAX_LEGACY_HANDOFF_DELIVERIES
|
||||
&& handoffDeliveryCount() < MAX_REPLAY_HANDOFF_DELIVERIES
|
||||
&& bufferedLegacyDeliveryBytes <= ProcessedVoteCache.MAX_LEGACY_REDIS_TOTAL_BYTES - encodedBytes) {
|
||||
bufferedLegacyDeliveries.add(envelope);
|
||||
bufferedLegacyDeliveries.add(new BufferedHandoffDelivery(
|
||||
nextHandoffSequence++, legacyEnvelope, signature, false));
|
||||
bufferedLegacyDeliveryBytes += encodedBytes;
|
||||
} else {
|
||||
if (!legacyHandoffDegraded && plugin != null) {
|
||||
plugin.getLogger().warning("Redis legacy handoff exceeded its " + MAX_LEGACY_HANDOFF_DELIVERIES
|
||||
+ " delivery / " + ProcessedVoteCache.MAX_LEGACY_REDIS_TOTAL_BYTES
|
||||
+ " byte buffer; temporarily degrading duplicate suppression");
|
||||
if (standbySubscriber) {
|
||||
if (!legacyHandoffOverflowed && plugin != null)
|
||||
plugin.getLogger().warning("Redis legacy handoff buffer is full; aborting the staged handoff");
|
||||
legacyHandoffOverflowed = true;
|
||||
} else {
|
||||
if (!legacyHandoffDegraded && plugin != null) {
|
||||
plugin.getLogger().warning("Redis legacy handoff exceeded its " + MAX_LEGACY_HANDOFF_DELIVERIES
|
||||
+ " delivery / " + ProcessedVoteCache.MAX_LEGACY_REDIS_TOTAL_BYTES
|
||||
+ " byte buffer; temporarily degrading duplicate suppression");
|
||||
}
|
||||
legacyHandoffDegraded = true;
|
||||
dispatch = true;
|
||||
}
|
||||
legacyHandoffDegraded = true;
|
||||
messageHandler.onMessage(envelope);
|
||||
}
|
||||
if (dispatch && replayingHandoff) {
|
||||
enqueueReplayDelivery(envelope);
|
||||
dispatch = false;
|
||||
}
|
||||
if (dispatch) dispatchesInFlight++;
|
||||
}
|
||||
if (dispatch) dispatchTracked(legacyEnvelope);
|
||||
}
|
||||
|
||||
private static JsonEnvelope withoutReliableFields(JsonEnvelope envelope) {
|
||||
if (!envelope.getFields().containsKey(VotingPluginWire.K_REDIS_DELIVERY_ID)) return envelope;
|
||||
JsonEnvelope.Builder builder = JsonEnvelope.builder(envelope.getSubChannel()).schema(envelope.getSchema());
|
||||
for (java.util.Map.Entry<String, String> field : envelope.getFields().entrySet()) {
|
||||
if (!VotingPluginWire.K_REDIS_DELIVERY_ID.equals(field.getKey())) builder.put(field.getKey(), field.getValue());
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/** Holds a consumed subscriber callback until a same-Redis transfer commits or rolls back. */
|
||||
private boolean awaitReplayTransferResolution() {
|
||||
boolean interrupted = false;
|
||||
while (replayTransferFrozen) {
|
||||
try {
|
||||
legacyLifecycle.wait();
|
||||
} catch (InterruptedException waitInterrupted) {
|
||||
// Do not drop a Pub/Sub payload merely because the listener was nudged
|
||||
// during transfer. Its final owner will explicitly release this wait.
|
||||
interrupted = true;
|
||||
}
|
||||
}
|
||||
if (interrupted) Thread.currentThread().interrupt();
|
||||
return !retiredAfterHandoff;
|
||||
}
|
||||
|
||||
private void dispatchTracked(JsonEnvelope envelope) {
|
||||
try {
|
||||
messageHandler.onMessage(envelope);
|
||||
} finally {
|
||||
synchronized (legacyLifecycle) {
|
||||
dispatchesInFlight--;
|
||||
legacyLifecycle.notifyAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,29 +334,598 @@ public class RedisBackendProxyTransport implements BackendProxyTransport {
|
||||
/** Promotes a validated standby after the previous listener has completely stopped. */
|
||||
public void activateAfterHandoff() {
|
||||
synchronized (legacyLifecycle) {
|
||||
if (identifiedHandoffOverflowed || legacyHandoffOverflowed
|
||||
|| processedVoteCache.isLegacyRedisHandoffOverflowed())
|
||||
throw new IllegalStateException("Redis handoff buffer overflowed before publication");
|
||||
replayingHandoff = true;
|
||||
replayBackpressureFailureLogged = false;
|
||||
processedVoteCache.activateRedisSubscriber(subscriberIdentity);
|
||||
for (JsonEnvelope envelope : bufferedLegacyDeliveries) {
|
||||
String signature = com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec.encode(envelope);
|
||||
if (!processedVoteCache.consumeLegacyRedisDelivery(signature)) messageHandler.onMessage(envelope);
|
||||
standbySubscriber = false;
|
||||
java.util.ArrayList<BufferedHandoffDelivery> buffered = new java.util.ArrayList<>(
|
||||
bufferedIdentifiedDeliveries.size() + bufferedLegacyDeliveries.size());
|
||||
buffered.addAll(bufferedIdentifiedDeliveries);
|
||||
buffered.addAll(bufferedLegacyDeliveries);
|
||||
buffered.sort(java.util.Comparator.comparingLong(BufferedHandoffDelivery::sequence));
|
||||
for (BufferedHandoffDelivery delivery : buffered) {
|
||||
boolean dispatch;
|
||||
try {
|
||||
dispatch = delivery.identified()
|
||||
? processedVoteCache.reserveRedisDelivery(delivery.identity())
|
||||
: !processedVoteCache.consumeLegacyRedisDelivery(delivery.identity());
|
||||
} catch (RuntimeException replayFailure) {
|
||||
if (plugin != null) plugin.debug("Redis handoff replay failed: " + replayFailure.getMessage());
|
||||
continue;
|
||||
}
|
||||
if (dispatch) enqueueReplayDelivery(delivery.envelope());
|
||||
}
|
||||
bufferedIdentifiedDeliveries.clear();
|
||||
bufferedIdentifiedDeliveryBytes = 0;
|
||||
bufferedLegacyDeliveries.clear();
|
||||
bufferedLegacyDeliveryBytes = 0;
|
||||
identifiedHandoffOverflowed = false;
|
||||
legacyHandoffOverflowed = false;
|
||||
legacyHandoffDegraded = false;
|
||||
processedVoteCache.finishRedisHandoff();
|
||||
}
|
||||
}
|
||||
|
||||
/** Replays buffered deliveries after the owning handler opens its publication gate. */
|
||||
public void replayAfterHandoffPublication() {
|
||||
if (plugin == null) {
|
||||
// Unit tests use a transport without a Bukkit scheduler. Production always
|
||||
// takes the bounded worker path below.
|
||||
drainReplayWithoutScheduler();
|
||||
return;
|
||||
}
|
||||
startReplayWorkerIfNeeded();
|
||||
}
|
||||
|
||||
/** True while a same-Redis replay still owns accepted envelopes or Bukkit work. */
|
||||
public boolean hasPendingReplayForReplacement() {
|
||||
synchronized (legacyLifecycle) {
|
||||
return replayingHandoff || replayTransferFrozen || !deliveriesAfterReplay.isEmpty()
|
||||
|| replayDeliveriesInFlight != 0 || replayCallbacksInFlight != 0 || replayTaskOutstanding;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits only on the Control/validation worker for active replay to finish
|
||||
* before a non-Redis replacement can retire this transport. It never moves
|
||||
* Redis envelopes into a transport that cannot preserve Redis semantics.
|
||||
*/
|
||||
public boolean awaitReplayDrainForNonRedisReplacement(long deadlineNanos) {
|
||||
boolean interrupted = false;
|
||||
synchronized (legacyLifecycle) {
|
||||
while (hasPendingReplayForReplacement()) {
|
||||
long remaining = deadlineNanos - System.nanoTime();
|
||||
if (remaining <= 0L) {
|
||||
if (interrupted) Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
TimeUnit.NANOSECONDS.timedWait(legacyLifecycle, remaining);
|
||||
} catch (InterruptedException waitInterrupted) {
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (interrupted) Thread.currentThread().interrupt();
|
||||
return !interrupted;
|
||||
}
|
||||
|
||||
private void startReplayWorkerIfNeeded() {
|
||||
Thread worker;
|
||||
synchronized (legacyLifecycle) {
|
||||
if (!replayingHandoff || replayTransferFrozen || replayWorker != null || replayTaskOutstanding) return;
|
||||
worker = new Thread(this::drainReplayOnWorker, "VotingPlugin-Redis-Backend-Replay");
|
||||
worker.setDaemon(true);
|
||||
replayWorker = worker;
|
||||
}
|
||||
try {
|
||||
worker.start();
|
||||
} catch (RuntimeException failed) {
|
||||
synchronized (legacyLifecycle) {
|
||||
if (replayWorker == worker) replayWorker = null;
|
||||
legacyLifecycle.notifyAll();
|
||||
}
|
||||
throw failed;
|
||||
}
|
||||
}
|
||||
|
||||
private void enqueueReplayDelivery(JsonEnvelope envelope) {
|
||||
if (deliveriesAfterReplay.size() + replayDeliveriesInFlight >= MAX_REPLAY_HANDOFF_DELIVERIES)
|
||||
throw new IllegalStateException("Redis handoff replay capacity was not reserved");
|
||||
deliveriesAfterReplay.addLast(envelope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserves handoff FIFO without allowing the replay queue to grow without
|
||||
* bound. A timeout is explicit and happens before the identified ID is
|
||||
* reserved, so an undeliverable callback is never silently marked processed.
|
||||
*/
|
||||
private void awaitReplayCapacity() {
|
||||
if (!replayingHandoff) return;
|
||||
boolean interrupted = false;
|
||||
long deadline = System.nanoTime() + HANDOFF_REPLAY_BACKPRESSURE_TIMEOUT_NANOS;
|
||||
while (replayingHandoff && deliveriesAfterReplay.size() + replayDeliveriesInFlight
|
||||
>= MAX_REPLAY_HANDOFF_DELIVERIES) {
|
||||
long remaining = deadline - System.nanoTime();
|
||||
if (remaining <= 0L) {
|
||||
if (!replayBackpressureFailureLogged && plugin != null) {
|
||||
plugin.getLogger().severe("Redis handoff replay is not draining; rejected an unreserved callback");
|
||||
replayBackpressureFailureLogged = true;
|
||||
}
|
||||
if (interrupted) Thread.currentThread().interrupt();
|
||||
throw new HandoffReplayBackpressureException("Redis handoff replay queue did not drain before its deadline");
|
||||
}
|
||||
try {
|
||||
TimeUnit.NANOSECONDS.timedWait(legacyLifecycle, remaining);
|
||||
} catch (InterruptedException waitInterrupted) {
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new HandoffReplayBackpressureException("Interrupted while waiting for Redis handoff replay capacity");
|
||||
}
|
||||
}
|
||||
|
||||
private void drainReplayWithoutScheduler() {
|
||||
while (true) {
|
||||
java.util.ArrayList<JsonEnvelope> replay;
|
||||
GlobalMessageHandler handler;
|
||||
long generation;
|
||||
synchronized (legacyLifecycle) {
|
||||
if (!replayingHandoff || deliveriesAfterReplay.isEmpty()) {
|
||||
replayingHandoff = false;
|
||||
legacyLifecycle.notifyAll();
|
||||
return;
|
||||
}
|
||||
replay = takeReplayBatch();
|
||||
handler = messageHandler;
|
||||
generation = replayGeneration;
|
||||
}
|
||||
dispatchReplayBatch(replay, handler, generation);
|
||||
completeReplayBatch(replay.size());
|
||||
}
|
||||
}
|
||||
|
||||
/** Coordinates one bounded Bukkit batch at a time without blocking publication. */
|
||||
private void drainReplayOnWorker() {
|
||||
try {
|
||||
while (!Thread.currentThread().isInterrupted()) {
|
||||
java.util.ArrayList<JsonEnvelope> replay;
|
||||
GlobalMessageHandler handler;
|
||||
long generation;
|
||||
synchronized (legacyLifecycle) {
|
||||
if (!replayingHandoff || deliveriesAfterReplay.isEmpty()) {
|
||||
replayingHandoff = false;
|
||||
legacyLifecycle.notifyAll();
|
||||
return;
|
||||
}
|
||||
if (replayTransferFrozen) return;
|
||||
replay = takeReplayBatch();
|
||||
handler = messageHandler;
|
||||
generation = replayGeneration;
|
||||
}
|
||||
ReplayBatch batch = new ReplayBatch(generation);
|
||||
java.util.concurrent.CountDownLatch completed = new java.util.concurrent.CountDownLatch(1);
|
||||
try {
|
||||
synchronized (legacyLifecycle) {
|
||||
replayTaskOutstanding = true;
|
||||
replayTaskGeneration = batch.generation;
|
||||
}
|
||||
plugin.getBukkitScheduler().runTask(plugin, () -> {
|
||||
try {
|
||||
synchronized (legacyLifecycle) {
|
||||
if (batch.generation != replayGeneration || batch.returned || !replayingHandoff) return;
|
||||
if (replayTransferFrozen) return;
|
||||
batch.started = true;
|
||||
}
|
||||
dispatchReplayBatch(replay, handler, batch.generation);
|
||||
} finally {
|
||||
synchronized (legacyLifecycle) {
|
||||
if (batch.generation == replayGeneration && !batch.returned) {
|
||||
if (!batch.started && replayTransferFrozen) {
|
||||
batch.returned = true;
|
||||
requeueReplayBatch(replay);
|
||||
} else completeReplayBatch(replay.size());
|
||||
}
|
||||
if (replayTaskOutstanding && replayTaskGeneration == batch.generation) {
|
||||
replayTaskOutstanding = false;
|
||||
replayTaskGeneration = -1L;
|
||||
}
|
||||
legacyLifecycle.notifyAll();
|
||||
}
|
||||
completed.countDown();
|
||||
startReplayWorkerIfNeeded();
|
||||
}
|
||||
});
|
||||
if (!completed.await(REPLAY_BATCH_EXECUTION_TIMEOUT_NANOS, TimeUnit.NANOSECONDS)) {
|
||||
synchronized (legacyLifecycle) {
|
||||
if (!batch.started && !batch.returned && batch.generation == replayGeneration) {
|
||||
batch.returned = true;
|
||||
replayGeneration++;
|
||||
requeueReplayBatch(replay);
|
||||
// Do not leave replay owned by a Bukkit task that was accepted but
|
||||
// never began. A task already running must retain ownership until its
|
||||
// finally block completes; otherwise a second worker could overlap it
|
||||
// and the older task could clear the newer task's shared state.
|
||||
if (replayTaskOutstanding && replayTaskGeneration == batch.generation) {
|
||||
replayTaskOutstanding = false;
|
||||
replayTaskGeneration = -1L;
|
||||
}
|
||||
}
|
||||
legacyLifecycle.notifyAll();
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (InterruptedException interrupted) {
|
||||
synchronized (legacyLifecycle) {
|
||||
// A batch is removed before it can be scheduled. If the worker is
|
||||
// interrupted while waiting for Bukkit, put an unstarted batch back
|
||||
// at the front; otherwise the accepted deliveries would vanish.
|
||||
if (!batch.started && !batch.returned && batch.generation == replayGeneration) {
|
||||
batch.returned = true;
|
||||
requeueReplayBatch(replay);
|
||||
if (replayTaskOutstanding && replayTaskGeneration == batch.generation) {
|
||||
replayTaskOutstanding = false;
|
||||
replayTaskGeneration = -1L;
|
||||
}
|
||||
}
|
||||
legacyLifecycle.notifyAll();
|
||||
}
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
} catch (RuntimeException schedulingFailure) {
|
||||
synchronized (legacyLifecycle) {
|
||||
if (!batch.returned && batch.generation == replayGeneration) {
|
||||
batch.returned = true;
|
||||
requeueReplayBatch(replay);
|
||||
}
|
||||
if (replayTaskOutstanding && replayTaskGeneration == batch.generation) {
|
||||
replayTaskOutstanding = false;
|
||||
replayTaskGeneration = -1L;
|
||||
}
|
||||
legacyLifecycle.notifyAll();
|
||||
}
|
||||
if (plugin != null) plugin.debug("Redis handoff replay scheduling failed: " + schedulingFailure.getMessage());
|
||||
try {
|
||||
Thread.sleep(250L);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
synchronized (legacyLifecycle) {
|
||||
if (replayWorker == Thread.currentThread()) replayWorker = null;
|
||||
legacyLifecycle.notifyAll();
|
||||
}
|
||||
startReplayWorkerIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
private java.util.ArrayList<JsonEnvelope> takeReplayBatch() {
|
||||
java.util.ArrayList<JsonEnvelope> replay = new java.util.ArrayList<>(REPLAY_BATCH_SIZE);
|
||||
while (!deliveriesAfterReplay.isEmpty() && replay.size() < REPLAY_BATCH_SIZE)
|
||||
replay.add(deliveriesAfterReplay.removeFirst());
|
||||
replayDeliveriesInFlight += replay.size();
|
||||
return replay;
|
||||
}
|
||||
|
||||
private void completeReplayBatch(int size) {
|
||||
synchronized (legacyLifecycle) {
|
||||
replayDeliveriesInFlight -= size;
|
||||
if (replayDeliveriesInFlight < 0) replayDeliveriesInFlight = 0;
|
||||
legacyLifecycle.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void requeueReplayBatch(java.util.List<JsonEnvelope> replay) {
|
||||
for (int index = replay.size() - 1; index >= 0; index--) deliveriesAfterReplay.addFirst(replay.get(index));
|
||||
completeReplayBatch(replay.size());
|
||||
}
|
||||
|
||||
private static final class ReplayBatch {
|
||||
private final long generation;
|
||||
private boolean started;
|
||||
private boolean returned;
|
||||
|
||||
private ReplayBatch(long generation) {
|
||||
this.generation = generation;
|
||||
}
|
||||
}
|
||||
|
||||
private void dispatchReplayBatch(java.util.List<JsonEnvelope> replay, GlobalMessageHandler handler,
|
||||
long generation) {
|
||||
if (handler == null) return;
|
||||
for (JsonEnvelope envelope : replay) {
|
||||
synchronized (legacyLifecycle) {
|
||||
// A close/fence may happen after Bukkit accepts the batch. Never let a
|
||||
// stale generation start another callback after that point.
|
||||
if (!replayingHandoff || generation != replayGeneration) return;
|
||||
replayCallbacksInFlight++;
|
||||
}
|
||||
try {
|
||||
handler.onMessage(envelope);
|
||||
} catch (RuntimeException replayFailure) {
|
||||
if (plugin != null) plugin.debug("Redis handoff replay failed: " + replayFailure.getMessage());
|
||||
} finally {
|
||||
synchronized (legacyLifecycle) {
|
||||
replayCallbacksInFlight--;
|
||||
if (replayCallbacksInFlight < 0) replayCallbacksInFlight = 0;
|
||||
legacyLifecycle.notifyAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops the old listener while retaining its overlap accounting for standby promotion. */
|
||||
public void closeForHandoff() {
|
||||
handoffMessageHandler = messageHandler;
|
||||
fenceAfterHandoff();
|
||||
// A successful fence guarantees this listener will not dispatch the held
|
||||
// callback. Release it before listener shutdown so the Redis listener thread
|
||||
// can return and join promptly; the overlapping standby owns its duplicate.
|
||||
completeFrozenReplayTransfer();
|
||||
closeListener(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(JsonEnvelope envelope) {
|
||||
if (redisHandler != null) {
|
||||
redisHandler.publishEnvelope(plugin.getBungeeSettings().getRedisPrefix() + "VotingPlugin",
|
||||
VotingPluginWire.withRedisDeliveryId(envelope));
|
||||
/** Reopens a fenced active subscriber when standby promotion is rejected. */
|
||||
public void restoreAfterFailedHandoff() {
|
||||
restoreAfterFailedHandoff(java.util.Collections.emptyList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinstates the retired subscriber and prepends deliveries accepted by a
|
||||
* promoted replacement before publication later failed. Standby overlap IDs
|
||||
* are resolved while detaching them, so this FIFO can replay directly exactly
|
||||
* once after the old subscriber is restored.
|
||||
*/
|
||||
public void restoreAfterFailedHandoff(java.util.List<JsonEnvelope> replacementReplay) {
|
||||
GlobalMessageHandler handler = handoffMessageHandler;
|
||||
if (handler == null) throw new IllegalStateException("Redis handoff transport cannot be restored");
|
||||
Thread retiredListener = listenerThread;
|
||||
if (retiredListener != null && retiredListener.isAlive())
|
||||
throw new IllegalStateException("Redis handoff listener did not stop before rollback restart");
|
||||
synchronized (legacyLifecycle) {
|
||||
if (replacementReplay.size() > MAX_REPLAY_HANDOFF_DELIVERIES)
|
||||
throw new IllegalStateException("Redis rollback replay exceeds its bounded handoff capacity");
|
||||
deliveriesAfterReplay.clear();
|
||||
deliveriesAfterReplay.addAll(replacementReplay);
|
||||
replayDeliveriesInFlight = 0;
|
||||
replayingHandoff = !replacementReplay.isEmpty();
|
||||
replayGeneration++;
|
||||
retiredAfterHandoff = false;
|
||||
}
|
||||
processedVoteCache.restoreRedisSubscriber(subscriberIdentity);
|
||||
start(handler);
|
||||
if (!replacementReplay.isEmpty()) replayAfterHandoffPublication();
|
||||
handoffMessageHandler = null;
|
||||
}
|
||||
|
||||
/** Transfers pre-publication replay ownership to a restored predecessor. */
|
||||
public java.util.List<JsonEnvelope> detachReplayForFailedHandoff() {
|
||||
synchronized (legacyLifecycle) {
|
||||
if (replayDeliveriesInFlight != 0 || replayCallbacksInFlight != 0)
|
||||
throw new IllegalStateException("Redis rollback cannot detach a replay batch already executing");
|
||||
java.util.ArrayList<BufferedHandoffDelivery> buffered = new java.util.ArrayList<>(
|
||||
bufferedIdentifiedDeliveries.size() + bufferedLegacyDeliveries.size());
|
||||
buffered.addAll(bufferedIdentifiedDeliveries);
|
||||
buffered.addAll(bufferedLegacyDeliveries);
|
||||
buffered.sort(java.util.Comparator.comparingLong(BufferedHandoffDelivery::sequence));
|
||||
if (deliveriesAfterReplay.size() + buffered.size() > MAX_REPLAY_HANDOFF_DELIVERIES)
|
||||
throw new IllegalStateException("Redis rollback replay exceeds its bounded handoff capacity");
|
||||
java.util.ArrayList<JsonEnvelope> pending = new java.util.ArrayList<>(
|
||||
deliveriesAfterReplay.size() + buffered.size());
|
||||
pending.addAll(deliveriesAfterReplay);
|
||||
for (BufferedHandoffDelivery delivery : buffered) {
|
||||
boolean replay;
|
||||
try {
|
||||
replay = delivery.identified()
|
||||
? processedVoteCache.reserveRedisDelivery(delivery.identity())
|
||||
: !processedVoteCache.consumeLegacyRedisDelivery(delivery.identity());
|
||||
} catch (RuntimeException cacheFailure) {
|
||||
// A rollback must keep an accepted staged callback. Retain it for the
|
||||
// restored subscriber rather than letting a transient dedupe failure
|
||||
// turn replacement close into data loss.
|
||||
replay = true;
|
||||
if (plugin != null) plugin.debug("Redis rollback dedupe failed: " + cacheFailure.getMessage());
|
||||
}
|
||||
if (replay) pending.add(delivery.envelope());
|
||||
}
|
||||
retiredAfterHandoff = true;
|
||||
replayingHandoff = false;
|
||||
replayTransferFrozen = false;
|
||||
replayGeneration++;
|
||||
deliveriesAfterReplay.clear();
|
||||
bufferedIdentifiedDeliveries.clear();
|
||||
bufferedIdentifiedDeliveryBytes = 0;
|
||||
bufferedLegacyDeliveries.clear();
|
||||
bufferedLegacyDeliveryBytes = 0;
|
||||
identifiedHandoffOverflowed = false;
|
||||
legacyHandoffOverflowed = false;
|
||||
legacyHandoffDegraded = false;
|
||||
replayTaskOutstanding = false;
|
||||
replayTaskGeneration = -1L;
|
||||
legacyLifecycle.notifyAll();
|
||||
return pending;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops admitting new callbacks and transfers the remaining active replay FIFO
|
||||
* to the next same-Redis standby. An already-started Bukkit batch is allowed
|
||||
* to finish; an unstarted scheduled batch is returned to the deque first.
|
||||
*/
|
||||
public java.util.List<JsonEnvelope> freezeReplayForSuccessiveHandoff() {
|
||||
boolean interrupted = false;
|
||||
long deadline = System.nanoTime() + HANDOFF_QUIESCE_TIMEOUT_NANOS;
|
||||
synchronized (legacyLifecycle) {
|
||||
replayTransferFrozen = true;
|
||||
retiredAfterHandoff = true;
|
||||
Thread worker = replayWorker;
|
||||
if (worker != null) worker.interrupt();
|
||||
while (replayDeliveriesInFlight != 0 || replayCallbacksInFlight != 0 || replayTaskOutstanding) {
|
||||
long remaining = deadline - System.nanoTime();
|
||||
if (remaining <= 0L) {
|
||||
resumeReplayAfterFailedSuccessiveHandoffLocked();
|
||||
if (interrupted) Thread.currentThread().interrupt();
|
||||
throw new HandoffQuiescenceException("Redis replay did not quiesce before successive handoff");
|
||||
}
|
||||
try {
|
||||
TimeUnit.NANOSECONDS.timedWait(legacyLifecycle, remaining);
|
||||
} catch (InterruptedException waitInterrupted) {
|
||||
interrupted = true;
|
||||
resumeReplayAfterFailedSuccessiveHandoffLocked();
|
||||
Thread.currentThread().interrupt();
|
||||
throw new HandoffQuiescenceException("Interrupted while freezing Redis replay for successive handoff");
|
||||
}
|
||||
}
|
||||
java.util.ArrayList<JsonEnvelope> pending = new java.util.ArrayList<>(deliveriesAfterReplay);
|
||||
deliveriesAfterReplay.clear();
|
||||
replayingHandoff = false;
|
||||
replayGeneration++;
|
||||
legacyLifecycle.notifyAll();
|
||||
if (interrupted) Thread.currentThread().interrupt();
|
||||
return pending;
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepends a predecessor's still-unplayed replay FIFO before this standby's own overlap buffer. */
|
||||
public void acceptReplayFromPreviousHandoff(java.util.List<JsonEnvelope> predecessorReplay) {
|
||||
if (predecessorReplay == null || predecessorReplay.isEmpty()) return;
|
||||
synchronized (legacyLifecycle) {
|
||||
if (deliveriesAfterReplay.size() + replayDeliveriesInFlight + bufferedLegacyDeliveries.size()
|
||||
+ bufferedIdentifiedDeliveries.size() + predecessorReplay.size()
|
||||
> MAX_REPLAY_HANDOFF_DELIVERIES)
|
||||
throw new IllegalStateException("Redis replacement replay capacity is exhausted");
|
||||
for (JsonEnvelope envelope : predecessorReplay) deliveriesAfterReplay.addLast(envelope);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rolls back a not-yet-published predecessor transfer when its old listener cannot retire. */
|
||||
public void removeReplayFromPreviousHandoff(java.util.List<JsonEnvelope> predecessorReplay) {
|
||||
if (predecessorReplay == null || predecessorReplay.isEmpty()) return;
|
||||
synchronized (legacyLifecycle) {
|
||||
if (deliveriesAfterReplay.size() < predecessorReplay.size())
|
||||
throw new IllegalStateException("Redis replacement replay transfer is incomplete");
|
||||
for (JsonEnvelope expected : predecessorReplay) {
|
||||
JsonEnvelope actual = deliveriesAfterReplay.removeFirst();
|
||||
if (!java.util.Objects.equals(actual, expected))
|
||||
throw new IllegalStateException("Redis replacement replay FIFO changed during handoff rollback");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Finalizes a successful transfer and releases callbacks to the promoted standby overlap. */
|
||||
public void completeFrozenReplayTransfer() {
|
||||
synchronized (legacyLifecycle) {
|
||||
replayTransferFrozen = false;
|
||||
legacyLifecycle.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
/** Restores the frozen active FIFO when successor admission or retirement fails. */
|
||||
public void restoreFrozenReplayAfterFailedSuccessiveHandoff(java.util.List<JsonEnvelope> replay) {
|
||||
synchronized (legacyLifecycle) {
|
||||
if (!deliveriesAfterReplay.isEmpty())
|
||||
throw new IllegalStateException("Redis replay ownership changed while successive handoff was aborted");
|
||||
deliveriesAfterReplay.addAll(replay);
|
||||
resumeReplayAfterFailedSuccessiveHandoffLocked();
|
||||
}
|
||||
if (plugin != null) startReplayWorkerIfNeeded();
|
||||
}
|
||||
|
||||
private void resumeReplayAfterFailedSuccessiveHandoffLocked() {
|
||||
replayTransferFrozen = false;
|
||||
retiredAfterHandoff = false;
|
||||
if (!deliveriesAfterReplay.isEmpty()) replayingHandoff = true;
|
||||
legacyLifecycle.notifyAll();
|
||||
}
|
||||
|
||||
/** Prevents a listener that misses its shutdown deadline from dispatching duplicates. */
|
||||
void fenceAfterHandoff() {
|
||||
boolean interrupted = false;
|
||||
long deadline = System.nanoTime() + HANDOFF_QUIESCE_TIMEOUT_NANOS;
|
||||
synchronized (legacyLifecycle) {
|
||||
retiredAfterHandoff = true;
|
||||
replayGeneration++;
|
||||
Thread worker = replayWorker;
|
||||
if (worker != null) worker.interrupt();
|
||||
replayWorker = null;
|
||||
while (dispatchesInFlight > 0) {
|
||||
long remaining = deadline - System.nanoTime();
|
||||
if (remaining <= 0) break;
|
||||
try {
|
||||
TimeUnit.NANOSECONDS.timedWait(legacyLifecycle, remaining);
|
||||
} catch (InterruptedException waitInterrupted) {
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (dispatchesInFlight > 0) {
|
||||
retiredAfterHandoff = false;
|
||||
if (interrupted) Thread.currentThread().interrupt();
|
||||
throw new HandoffQuiescenceException(
|
||||
"Redis backend callbacks did not quiesce before handoff");
|
||||
}
|
||||
replayingHandoff = false;
|
||||
deliveriesAfterReplay.clear();
|
||||
replayBackpressureFailureLogged = false;
|
||||
replayTaskOutstanding = false;
|
||||
replayTaskGeneration = -1L;
|
||||
replayDeliveriesInFlight = 0;
|
||||
bufferedLegacyDeliveries.clear();
|
||||
bufferedLegacyDeliveryBytes = 0;
|
||||
bufferedIdentifiedDeliveries.clear();
|
||||
bufferedIdentifiedDeliveryBytes = 0;
|
||||
identifiedHandoffOverflowed = false;
|
||||
legacyHandoffOverflowed = false;
|
||||
}
|
||||
if (interrupted) Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
private void bufferIdentifiedDelivery(JsonEnvelope envelope, String deliveryId) {
|
||||
String signature = com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec.encode(envelope);
|
||||
int encodedBytes = ProcessedVoteCache.legacyRedisDeliveryBytes(signature);
|
||||
if (encodedBytes <= ProcessedVoteCache.MAX_LEGACY_REDIS_DELIVERY_BYTES
|
||||
&& bufferedIdentifiedDeliveries.size() < MAX_IDENTIFIED_HANDOFF_DELIVERIES
|
||||
&& handoffDeliveryCount() < MAX_REPLAY_HANDOFF_DELIVERIES
|
||||
&& bufferedIdentifiedDeliveryBytes <= ProcessedVoteCache.MAX_LEGACY_REDIS_TOTAL_BYTES - encodedBytes) {
|
||||
bufferedIdentifiedDeliveries.add(new BufferedHandoffDelivery(
|
||||
nextHandoffSequence++, envelope, deliveryId, true));
|
||||
bufferedIdentifiedDeliveryBytes += encodedBytes;
|
||||
} else {
|
||||
if (plugin != null && !identifiedHandoffOverflowed)
|
||||
plugin.getLogger().warning("Redis identified handoff buffer is full; aborting the staged handoff");
|
||||
identifiedHandoffOverflowed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Includes inherited replay plus staged overlap entries so rollback remains bounded. */
|
||||
private int handoffDeliveryCount() {
|
||||
return deliveriesAfterReplay.size() + replayDeliveriesInFlight
|
||||
+ bufferedIdentifiedDeliveries.size() + bufferedLegacyDeliveries.size();
|
||||
}
|
||||
|
||||
private record BufferedHandoffDelivery(long sequence, JsonEnvelope envelope, String identity,
|
||||
boolean identified) {}
|
||||
|
||||
@Override
|
||||
public boolean send(JsonEnvelope envelope) {
|
||||
if (redisHandler != null) {
|
||||
SharedTransportSecurityPolicy policy = securityPolicy();
|
||||
JsonEnvelope identified = VotingPluginWire.withRedisDeliveryId(policy.encryption().encrypt(envelope));
|
||||
redisHandler.publishEnvelope(publishChannel, policy.authenticator().sign(identified, Domain.REDIS_PROXY_BACKEND,
|
||||
plugin.getBungeeSettings().getServer(), publishChannel));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -179,9 +967,28 @@ public class RedisBackendProxyTransport implements BackendProxyTransport {
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
cancelReplay();
|
||||
closeListener(true);
|
||||
}
|
||||
|
||||
private void cancelReplay() {
|
||||
synchronized (legacyLifecycle) {
|
||||
retiredAfterHandoff = true;
|
||||
replayGeneration++;
|
||||
replayingHandoff = false;
|
||||
replayTransferFrozen = false;
|
||||
deliveriesAfterReplay.clear();
|
||||
replayBackpressureFailureLogged = false;
|
||||
replayTaskOutstanding = false;
|
||||
replayTaskGeneration = -1L;
|
||||
replayDeliveriesInFlight = 0;
|
||||
Thread worker = replayWorker;
|
||||
replayWorker = null;
|
||||
if (worker != null) worker.interrupt();
|
||||
legacyLifecycle.notifyAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void closeListener(boolean unregister) {
|
||||
Thread thread = listenerThread;
|
||||
if (redisHandler != null) {
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.bencodez.votingplugin.backendproxy.transport;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator;
|
||||
import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption;
|
||||
|
||||
/** Immutable comparison view of the broker policy applied before backend dispatch. */
|
||||
record SharedInboundPolicy(Class<? extends BackendProxyTransport> transportType, String destination,
|
||||
SharedTransportEnvelopeAuthenticator authenticator, TransportEnvelopeEncryption encryption) {
|
||||
boolean hasEquivalentPolicy(SharedInboundPolicy other) {
|
||||
return other != null && transportType == other.transportType && Objects.equals(destination, other.destination)
|
||||
&& authenticator != null && authenticator.hasEquivalentInboundPolicy(other.authenticator)
|
||||
&& encryption != null && encryption.hasEquivalentInboundPolicy(other.encryption);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.bencodez.votingplugin.backendproxy.transport;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator;
|
||||
import com.bencodez.votingplugin.proxy.security.TransportEnvelopeEncryption;
|
||||
|
||||
/** Immutable authentication and encryption generation for one shared transport. */
|
||||
record SharedTransportSecurityPolicy(SharedTransportEnvelopeAuthenticator authenticator,
|
||||
TransportEnvelopeEncryption encryption) {
|
||||
SharedTransportSecurityPolicy {
|
||||
Objects.requireNonNull(authenticator, "authenticator");
|
||||
Objects.requireNonNull(encryption, "encryption");
|
||||
}
|
||||
|
||||
SharedTransportSecurityPolicy replace(SharedTransportEnvelopeAuthenticator replacementAuthenticator,
|
||||
TransportEnvelopeEncryption replacementEncryption) {
|
||||
return new SharedTransportSecurityPolicy(
|
||||
replacementAuthenticator == null ? authenticator : replacementAuthenticator,
|
||||
replacementEncryption == null ? encryption : replacementEncryption);
|
||||
}
|
||||
}
|
||||
+117
-25
@@ -1,6 +1,9 @@
|
||||
package com.bencodez.votingplugin.backendproxy.transport;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.ServerSocket;
|
||||
|
||||
import com.bencodez.simpleapi.encryption.EncryptionHandler;
|
||||
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
|
||||
@@ -8,6 +11,7 @@ import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler;
|
||||
import com.bencodez.simpleapi.servercomm.sockets.ClientHandler;
|
||||
import com.bencodez.simpleapi.servercomm.sockets.SocketHandler;
|
||||
import com.bencodez.simpleapi.servercomm.sockets.SocketReceiver;
|
||||
import com.bencodez.simpleapi.servercomm.sockets.SocketServer;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
|
||||
import lombok.Getter;
|
||||
@@ -19,6 +23,13 @@ public class SocketBackendProxyTransport implements BackendProxyTransport {
|
||||
private ClientHandler clientHandler;
|
||||
@Getter
|
||||
private SocketHandler socketHandler;
|
||||
private GlobalMessageHandler messageHandler;
|
||||
private EncryptionHandler encryptionHandler;
|
||||
private String bungeeHost;
|
||||
private int bungeePort;
|
||||
private String spigotHost;
|
||||
private int spigotPort;
|
||||
private boolean debug;
|
||||
|
||||
public SocketBackendProxyTransport(VotingPluginMain plugin) {
|
||||
this.plugin = plugin;
|
||||
@@ -26,32 +37,62 @@ public class SocketBackendProxyTransport implements BackendProxyTransport {
|
||||
|
||||
@Override
|
||||
public void start(GlobalMessageHandler messageHandler) {
|
||||
EncryptionHandler encryptionHandler = new EncryptionHandler(plugin.getName(),
|
||||
this.messageHandler = messageHandler;
|
||||
encryptionHandler = new EncryptionHandler(plugin.getName(),
|
||||
new File(plugin.getDataFolder(), "secretkey.key"));
|
||||
clientHandler = new ClientHandler(plugin.getBungeeSettings().getBungeeServerHost(),
|
||||
plugin.getBungeeSettings().getBungeeServerPort(), encryptionHandler,
|
||||
plugin.getBungeeSettings().isBungeeDebug());
|
||||
socketHandler = new SocketHandler("vp-socket", plugin.getBungeeSettings().getSpigotServerHost(),
|
||||
plugin.getBungeeSettings().getSpigotServerPort(), encryptionHandler,
|
||||
plugin.getBungeeSettings().isBungeeDebug()) {
|
||||
@Override
|
||||
public void log(String str) {
|
||||
plugin.getLogger().info(str);
|
||||
}
|
||||
};
|
||||
socketHandler.add(new SocketReceiver() {
|
||||
@Override
|
||||
public void onReceiveEnvelope(JsonEnvelope envelope) {
|
||||
messageHandler.onMessage(envelope);
|
||||
}
|
||||
});
|
||||
bungeeHost = plugin.getBungeeSettings().getBungeeServerHost();
|
||||
bungeePort = plugin.getBungeeSettings().getBungeeServerPort();
|
||||
spigotHost = plugin.getBungeeSettings().getSpigotServerHost();
|
||||
spigotPort = plugin.getBungeeSettings().getSpigotServerPort();
|
||||
debug = plugin.getBungeeSettings().isBungeeDebug();
|
||||
startConnections();
|
||||
}
|
||||
|
||||
private void startConnections() {
|
||||
clientHandler = new ClientHandler(bungeeHost, bungeePort, encryptionHandler, debug);
|
||||
try {
|
||||
verifyListenerPortAvailable();
|
||||
socketHandler = new SocketHandler("vp-socket", spigotHost, spigotPort, encryptionHandler, debug) {
|
||||
@Override
|
||||
public void log(String str) {
|
||||
plugin.getLogger().info(str);
|
||||
}
|
||||
};
|
||||
socketHandler.add(new SocketReceiver() {
|
||||
@Override
|
||||
public void onReceiveEnvelope(JsonEnvelope envelope) {
|
||||
messageHandler.onMessage(envelope);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException failure) {
|
||||
clientHandler.stopConnection();
|
||||
clientHandler = null;
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SocketHandler logs and closes itself when its constructor cannot bind. Probe
|
||||
* first so replacement validation cannot mistake that swallowed failure for a
|
||||
* live listener.
|
||||
*/
|
||||
private void verifyListenerPortAvailable() {
|
||||
try (ServerSocket probe = new ServerSocket()) {
|
||||
probe.setReuseAddress(false);
|
||||
probe.bind(new InetSocketAddress(spigotHost, spigotPort));
|
||||
} catch (IOException unavailable) {
|
||||
throw new IllegalStateException("Socket backend proxy listener is unavailable at " + spigotHost + ":"
|
||||
+ spigotPort, unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(JsonEnvelope envelope) {
|
||||
public boolean send(JsonEnvelope envelope) {
|
||||
if (clientHandler != null) {
|
||||
clientHandler.sendEnvelope(envelope);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -63,13 +104,64 @@ public class SocketBackendProxyTransport implements BackendProxyTransport {
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (socketHandler != null) {
|
||||
socketHandler.closeConnection();
|
||||
socketHandler = null;
|
||||
close(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void prepareForReplacement() {
|
||||
close(true);
|
||||
}
|
||||
|
||||
private void close(boolean strict) {
|
||||
RuntimeException failure = null;
|
||||
try {
|
||||
closeSocketListener();
|
||||
} catch (RuntimeException listenerFailure) {
|
||||
failure = listenerFailure;
|
||||
}
|
||||
if (clientHandler != null) {
|
||||
clientHandler.stopConnection();
|
||||
clientHandler = null;
|
||||
ClientHandler closingClient = clientHandler;
|
||||
clientHandler = null;
|
||||
if (closingClient != null) try {
|
||||
closingClient.stopConnection();
|
||||
} catch (RuntimeException clientFailure) {
|
||||
if (failure == null) failure = clientFailure;
|
||||
else failure.addSuppressed(clientFailure);
|
||||
}
|
||||
if (failure == null) return;
|
||||
if (strict) throw failure;
|
||||
if (plugin != null && plugin.getLogger() != null) {
|
||||
plugin.getLogger().warning("Socket backend proxy transport did not stop cleanly");
|
||||
plugin.debug(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void closeSocketListener() {
|
||||
SocketHandler closing = socketHandler;
|
||||
socketHandler = null;
|
||||
if (closing == null) return;
|
||||
SocketServer server = closing.getServer();
|
||||
closing.closeConnection();
|
||||
if (server == null) return;
|
||||
try {
|
||||
server.join(1000L);
|
||||
if (server.isAlive()) {
|
||||
throw new IllegalStateException("Socket backend proxy listener did not stop before replacement");
|
||||
}
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Interrupted while retiring the socket backend proxy listener", interrupted);
|
||||
} finally {
|
||||
// SocketServer's accept-error recovery can race close() and re-bind once
|
||||
// before it observes its stopped flag. Always close the final socket, even
|
||||
// after a timeout or interruption leaves the worker incomplete.
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
|
||||
public void restoreAfterFailedReplacement() {
|
||||
if (messageHandler == null || encryptionHandler == null) {
|
||||
throw new IllegalStateException("Socket backend proxy transport cannot be restored before startup");
|
||||
}
|
||||
startConnections();
|
||||
}
|
||||
}
|
||||
+137
-20
@@ -7,9 +7,16 @@ import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.Map.Entry;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import com.bencodez.advancedcore.api.user.UserDataFetchMode;
|
||||
import com.bencodez.simpleapi.skull.SkullCache;
|
||||
@@ -23,35 +30,135 @@ import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
public final class VotingPluginBackgroundTask {
|
||||
|
||||
private final VotingPluginMain plugin;
|
||||
private final long snapshotWaitMillis;
|
||||
private volatile boolean requested;
|
||||
private volatile boolean running;
|
||||
private volatile long lastRunTimeSeconds = -1;
|
||||
|
||||
public VotingPluginBackgroundTask(VotingPluginMain plugin) {
|
||||
this(plugin, 5_000L);
|
||||
}
|
||||
|
||||
VotingPluginBackgroundTask(VotingPluginMain plugin, long snapshotWaitMillis) {
|
||||
this.plugin = plugin;
|
||||
this.snapshotWaitMillis = snapshotWaitMillis;
|
||||
}
|
||||
|
||||
public synchronized void run() {
|
||||
if (!(requested || plugin.getConfigFile().isAlwaysUpdate())) {
|
||||
return;
|
||||
public void run() {
|
||||
CompletableFuture<Void> completion = new CompletableFuture<>();
|
||||
CompletableFuture<Void> snapshotStarted = new CompletableFuture<>();
|
||||
CompletableFuture<Void> storageStarted = new CompletableFuture<>();
|
||||
AtomicBoolean snapshotPending = new AtomicBoolean(true);
|
||||
AtomicBoolean storagePending = new AtomicBoolean(false);
|
||||
synchronized (this) {
|
||||
if (!(requested || plugin.getConfigFile().isAlwaysUpdate()) || !plugin.isEnabled() || running) return;
|
||||
running = true;
|
||||
}
|
||||
if (!plugin.isEnabled() || running) {
|
||||
return;
|
||||
}
|
||||
if (plugin.getConfigFile().isUpdateWithPlayersOnlineOnly() && Bukkit.getOnlinePlayers().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
requested = false;
|
||||
try {
|
||||
runRefresh();
|
||||
} finally {
|
||||
running = false;
|
||||
plugin.captureOnlineTopVoterIgnore(online -> {
|
||||
if (!snapshotPending.compareAndSet(true, false)) return;
|
||||
snapshotStarted.complete(null);
|
||||
if (plugin.getConfigFile().isUpdateWithPlayersOnlineOnly() && online.isEmpty()) {
|
||||
storageStarted.complete(null);
|
||||
finishRun(completion);
|
||||
return;
|
||||
}
|
||||
synchronized (VotingPluginBackgroundTask.this) { requested = false; }
|
||||
storagePending.set(true);
|
||||
try {
|
||||
plugin.getUserManager().getDataManager().getTimer().execute(() -> {
|
||||
if (!storagePending.compareAndSet(true, false)) return;
|
||||
storageStarted.complete(null);
|
||||
try {
|
||||
runRefresh(online);
|
||||
completion.complete(null);
|
||||
} catch (Throwable failure) {
|
||||
plugin.debug(failure);
|
||||
setRequested(true);
|
||||
completion.complete(null);
|
||||
} finally { finishRun(completion); }
|
||||
});
|
||||
} catch (RuntimeException failure) {
|
||||
plugin.debug(failure);
|
||||
setRequested(true);
|
||||
storagePending.set(false);
|
||||
storageStarted.complete(null);
|
||||
completion.complete(null);
|
||||
finishRun(completion);
|
||||
}
|
||||
}, () -> {
|
||||
setRequested(true);
|
||||
snapshotPending.set(false);
|
||||
snapshotStarted.complete(null);
|
||||
storageStarted.complete(null);
|
||||
finishRun(completion);
|
||||
});
|
||||
} catch (RuntimeException failure) {
|
||||
plugin.debug(failure);
|
||||
setRequested(true);
|
||||
snapshotPending.set(false);
|
||||
snapshotStarted.complete(null);
|
||||
storageStarted.complete(null);
|
||||
completion.complete(null);
|
||||
finishRun(completion);
|
||||
}
|
||||
if (!isPlatformOwnedThread()) {
|
||||
try {
|
||||
snapshotStarted.get(snapshotWaitMillis, TimeUnit.MILLISECONDS);
|
||||
} catch (TimeoutException failure) {
|
||||
if (snapshotPending.compareAndSet(true, false)) {
|
||||
setRequested(true);
|
||||
finishRun(completion);
|
||||
return;
|
||||
}
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
if (snapshotPending.compareAndSet(true, false)) {
|
||||
setRequested(true);
|
||||
finishRun(completion);
|
||||
return;
|
||||
}
|
||||
} catch (java.util.concurrent.ExecutionException impossible) {
|
||||
throw new IllegalStateException(impossible);
|
||||
}
|
||||
if (completion.isDone()) return;
|
||||
try {
|
||||
storageStarted.get(snapshotWaitMillis, TimeUnit.MILLISECONDS);
|
||||
} catch (TimeoutException failure) {
|
||||
if (storagePending.compareAndSet(true, false)) {
|
||||
setRequested(true);
|
||||
finishRun(completion);
|
||||
}
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
if (storagePending.compareAndSet(true, false)) finishRun(completion);
|
||||
setRequested(true);
|
||||
} catch (java.util.concurrent.ExecutionException impossible) {
|
||||
throw new IllegalStateException(impossible);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void runRefresh() {
|
||||
private synchronized void finishRun(CompletableFuture<Void> completion) {
|
||||
running = false;
|
||||
if (!completion.isDone()) completion.complete(null);
|
||||
}
|
||||
|
||||
private boolean isPlatformOwnedThread() {
|
||||
if (Bukkit.getServer() == null) return false;
|
||||
try { if (Bukkit.isPrimaryThread()) return true; } catch (RuntimeException ignored) { }
|
||||
try {
|
||||
java.lang.reflect.Method method = Bukkit.getServer().getClass().getMethod("isGlobalTickThread");
|
||||
if (Boolean.TRUE.equals(method.invoke(Bukkit.getServer()))) return true;
|
||||
} catch (ReflectiveOperationException | RuntimeException ignored) { }
|
||||
try {
|
||||
Class<?> tickThread = Class.forName("ca.spottedleaf.moonrise.common.util.TickThread");
|
||||
if (Boolean.TRUE.equals(tickThread.getMethod("isTickThread").invoke(null))) return true;
|
||||
} catch (ReflectiveOperationException | LinkageError | RuntimeException ignored) { }
|
||||
return false;
|
||||
}
|
||||
|
||||
private void runRefresh(Map<UUID, Boolean> onlineUsers) {
|
||||
synchronized (plugin) {
|
||||
try {
|
||||
if (!plugin.isEnabled()) {
|
||||
@@ -123,10 +230,15 @@ public final class VotingPluginBackgroundTask {
|
||||
}
|
||||
}
|
||||
|
||||
if (extraBackgroundUpdate && user.isOnline()) {
|
||||
user.offVote();
|
||||
Boolean topVoterIgnore = onlineUsers.get(uuid);
|
||||
boolean online = topVoterIgnore != null;
|
||||
if (extraBackgroundUpdate && online) {
|
||||
user.offVoteWithCapturedTopVoterIgnoreAsync(topVoterIgnore.booleanValue())
|
||||
.whenComplete((ignored, failure) -> {
|
||||
if (failure == null) user.checkOfflineRewards();
|
||||
});
|
||||
}
|
||||
if (!plugin.getPlaceholders().getCacheLevel().onlineOnly() || user.isOnline()) {
|
||||
if (!plugin.getPlaceholders().getCacheLevel().onlineOnly() || online) {
|
||||
plugin.getPlaceholders().onUpdate(user, false);
|
||||
}
|
||||
} finally {
|
||||
@@ -139,7 +251,11 @@ public final class VotingPluginBackgroundTask {
|
||||
});
|
||||
|
||||
plugin.getTopVoterHandler().updateTopVoters(tempTopVoter);
|
||||
plugin.getPlaceholders().onUpdate();
|
||||
plugin.getPlaceholders().checkNonCachedPlaceholders();
|
||||
for (UUID onlineUuid : onlineUsers.keySet()) {
|
||||
VotingPluginUser onlineUser = plugin.getVotingPluginUserManager().getVotingPluginUser(onlineUuid, false);
|
||||
if (onlineUser != null) plugin.getPlaceholders().onUpdate(onlineUser, true);
|
||||
}
|
||||
plugin.setVoteToday(voteToday);
|
||||
plugin.getServerData().updateValues();
|
||||
plugin.getSigns().updateSigns();
|
||||
@@ -167,6 +283,7 @@ public final class VotingPluginBackgroundTask {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public boolean isRequested() {
|
||||
return requested;
|
||||
}
|
||||
|
||||
+39
-28
@@ -5,6 +5,7 @@ import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -55,7 +56,17 @@ public final class BroadcastHandler {
|
||||
|
||||
private final ConcurrentHashMap<UUID, LocalDate> firstVoteDay = new ConcurrentHashMap<UUID, LocalDate>();
|
||||
|
||||
private final ConcurrentHashMap<UUID, LinkedHashSet<String>> intervalSites = new ConcurrentHashMap<UUID, LinkedHashSet<String>>();
|
||||
private static final class IntervalRecord {
|
||||
private String playerName;
|
||||
private final LinkedHashSet<String> sites = new LinkedHashSet<String>();
|
||||
|
||||
private IntervalRecord(String playerName) {
|
||||
this.playerName = playerName;
|
||||
}
|
||||
}
|
||||
|
||||
private final Object intervalLock = new Object();
|
||||
private Map<UUID, IntervalRecord> intervalRecords = new HashMap<UUID, IntervalRecord>();
|
||||
private volatile BukkitTask intervalTask;
|
||||
|
||||
/**
|
||||
@@ -112,9 +123,10 @@ public final class BroadcastHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
recordInterval(uuid, siteName);
|
||||
|
||||
String name = playerName == null || playerName.isEmpty() ? resolveName(uuid) : playerName;
|
||||
if (currentSettings.getType() == VoteBroadcastType.INTERVAL_SUMMARY_GLOBAL) {
|
||||
recordInterval(uuid, name, siteName);
|
||||
}
|
||||
VoteBroadcastType type = currentSettings.getType();
|
||||
|
||||
if (type == VoteBroadcastType.EVERY_VOTE_ONLINE_ONLY) {
|
||||
@@ -389,22 +401,18 @@ public final class BroadcastHandler {
|
||||
* @param uuid the player's UUID
|
||||
* @param siteName the vote site
|
||||
*/
|
||||
private void recordInterval(UUID uuid, String siteName) {
|
||||
if (siteName == null || siteName.isEmpty()) {
|
||||
private void recordInterval(UUID uuid, String playerName, String siteName) {
|
||||
if (uuid == null || siteName == null || siteName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
LinkedHashSet<String> sites = intervalSites.get(uuid);
|
||||
if (sites == null) {
|
||||
LinkedHashSet<String> created = new LinkedHashSet<String>();
|
||||
sites = intervalSites.putIfAbsent(uuid, created);
|
||||
if (sites == null) {
|
||||
sites = created;
|
||||
// The name and its sites are added under the same lock used to rotate intervals.
|
||||
synchronized (intervalLock) {
|
||||
IntervalRecord record = intervalRecords.computeIfAbsent(uuid, ignored -> new IntervalRecord(playerName));
|
||||
if (playerName != null && !playerName.isEmpty()) {
|
||||
record.playerName = playerName;
|
||||
}
|
||||
}
|
||||
|
||||
synchronized (sites) {
|
||||
sites.add(siteName);
|
||||
record.sites.add(siteName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,9 +429,11 @@ public final class BroadcastHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
ConcurrentHashMap<UUID, LinkedHashSet<String>> snapshot =
|
||||
new ConcurrentHashMap<UUID, LinkedHashSet<String>>(intervalSites);
|
||||
intervalSites.clear();
|
||||
Map<UUID, IntervalRecord> snapshot;
|
||||
synchronized (intervalLock) {
|
||||
snapshot = intervalRecords;
|
||||
intervalRecords = new HashMap<UUID, IntervalRecord>();
|
||||
}
|
||||
|
||||
if (snapshot.isEmpty()) {
|
||||
return;
|
||||
@@ -433,22 +443,23 @@ public final class BroadcastHandler {
|
||||
List<String> players = new ArrayList<String>();
|
||||
LinkedHashSet<String> uniqueSites = new LinkedHashSet<String>();
|
||||
|
||||
for (Map.Entry<UUID, LinkedHashSet<String>> entry : snapshot.entrySet()) {
|
||||
LinkedHashSet<String> siteSet = entry.getValue();
|
||||
for (Map.Entry<UUID, IntervalRecord> entry : snapshot.entrySet()) {
|
||||
IntervalRecord record = entry.getValue();
|
||||
LinkedHashSet<String> siteSet = record.sites;
|
||||
if (siteSet == null || siteSet.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String name = resolveName(entry.getKey());
|
||||
String name = record.playerName;
|
||||
if (name == null || name.isEmpty()) {
|
||||
name = "Player";
|
||||
}
|
||||
players.add(name);
|
||||
|
||||
int siteCount;
|
||||
synchronized (siteSet) {
|
||||
siteCount = siteSet.size();
|
||||
for (String site : siteSet) {
|
||||
if (site != null && !site.isEmpty()) {
|
||||
uniqueSites.add(site);
|
||||
}
|
||||
int siteCount = siteSet.size();
|
||||
for (String site : siteSet) {
|
||||
if (site != null && !site.isEmpty()) {
|
||||
uniqueSites.add(site);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ public final class AdminAuthorization {
|
||||
return amount >= 0 || canRemovePointsFromAll(sender);
|
||||
}
|
||||
|
||||
public static boolean canAddPointsToAll(CommandSender sender, int amount, boolean allowAdminOverride) {
|
||||
return amount >= 0 || sender.hasPermission(REMOVE_POINTS_ALL_PERMISSION)
|
||||
|| allowAdminOverride && sender.hasPermission(ADMIN_PERMISSION);
|
||||
}
|
||||
|
||||
public static boolean canEditConfig(CommandSender sender, String permission) {
|
||||
return sender.hasPermission(permission) || sender.hasPermission(ADMIN_PERMISSION);
|
||||
}
|
||||
|
||||
+750
-271
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -74,7 +74,7 @@ public class CommandAliases implements CommandExecutor {
|
||||
if (adminCommand) {
|
||||
sender.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatInvalidCommandAdminVote()));
|
||||
} else {
|
||||
sender.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatInvalidCommandVote()));
|
||||
sender.sendMessage(MessageAPI.colorize(plugin.formatVoteCommandMessage(plugin.getConfigFile().getFormatInvalidCommandVote())));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ public class CommandVote implements CommandExecutor {
|
||||
}
|
||||
|
||||
// invalid command
|
||||
sender.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatInvalidCommandVote()));
|
||||
sender.sendMessage(MessageAPI.colorize(plugin.formatVoteCommandMessage(plugin.getConfigFile().getFormatInvalidCommandVote())));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.bencodez.votingplugin.commands.gui.admin.AdminVoteVoteParty;
|
||||
import com.bencodez.votingplugin.commands.gui.admin.milestones.AdminVoteVoteMilestones;
|
||||
import com.bencodez.votingplugin.commands.gui.admin.voteshop.AdminVoteVoteShop;
|
||||
import com.bencodez.votingplugin.events.PlayerVoteEvent;
|
||||
import com.bencodez.votingplugin.util.VoteTaskAdmission;
|
||||
import com.bencodez.votingplugin.votesites.VoteSite;
|
||||
|
||||
/**
|
||||
@@ -324,13 +325,16 @@ public class AdminGUI {
|
||||
if (ob != null) {
|
||||
VoteSite site = (VoteSite) ob;
|
||||
PlayerVoteEvent voteEvent = new PlayerVoteEvent(site, value, site.getServiceSite(), false);
|
||||
plugin.getVoteTimer().submit(new Runnable() {
|
||||
if (!VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), new Runnable() {
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
plugin.getServer().getPluginManager().callEvent(voteEvent);
|
||||
}
|
||||
});
|
||||
})) {
|
||||
player.sendMessage(
|
||||
"Vote could not be triggered because vote processing is busy; please try again later.");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+26
-25
@@ -1,6 +1,7 @@
|
||||
package com.bencodez.votingplugin.commands.gui.admin;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -76,16 +77,16 @@ public class AdminVotePerms extends GUIHandler {
|
||||
for (CommandHandler handle : plugin.getVoteCommand()) {
|
||||
if (sender instanceof Player) {
|
||||
if (handle.hasPerm(sender)) {
|
||||
msg.add("&6" + handle.getHelpLineCommand("/vote") + " : "
|
||||
msg.add("&6" + handle.getHelpLineCommand(plugin.getVoteCommandPrefix()) + " : "
|
||||
+ handle.getPerm().split(Pattern.quote("|"))[0] + " : &atrue");
|
||||
} else {
|
||||
msg.add("&6" + handle.getHelpLineCommand("/vote") + " : "
|
||||
msg.add("&6" + handle.getHelpLineCommand(plugin.getVoteCommandPrefix()) + " : "
|
||||
+ handle.getPerm().split(Pattern.quote("|"))[0] + " : &cfalse");
|
||||
}
|
||||
} else {
|
||||
msg.add(handle.getHelpLineCommand("/vote") + " : " + handle.getPerm().split(Pattern.quote("|"))[0]);
|
||||
msg.add(handle.getHelpLineCommand(plugin.getVoteCommandPrefix()) + " : " + handle.getPerm().split(Pattern.quote("|"))[0]);
|
||||
}
|
||||
addAdditionalPermissions(msg, sender, handle);
|
||||
addAdditionalPermissions(msg, sender, handle, plugin.getVoteCommandPrefix());
|
||||
|
||||
}
|
||||
|
||||
@@ -101,7 +102,7 @@ public class AdminVotePerms extends GUIHandler {
|
||||
} else {
|
||||
msg.add(handle.getHelpLineCommand("/av") + " : " + handle.getPerm().split(Pattern.quote("|"))[0]);
|
||||
}
|
||||
addAdditionalPermissions(msg, sender, handle);
|
||||
addAdditionalPermissions(msg, sender, handle, "/av");
|
||||
}
|
||||
|
||||
for (Permission perm : plugin.getDescription().getPermissions()) {
|
||||
@@ -163,13 +164,13 @@ public class AdminVotePerms extends GUIHandler {
|
||||
|
||||
for (CommandHandler handle : plugin.getVoteCommand()) {
|
||||
if (handle.hasPerm(p)) {
|
||||
msg.add("&6" + handle.getHelpLineCommand("/vote") + " : "
|
||||
msg.add("&6" + handle.getHelpLineCommand(plugin.getVoteCommandPrefix()) + " : "
|
||||
+ handle.getPerm().split(Pattern.quote("|"))[0] + " : &atrue");
|
||||
} else {
|
||||
msg.add("&6" + handle.getHelpLineCommand("/vote") + " : "
|
||||
msg.add("&6" + handle.getHelpLineCommand(plugin.getVoteCommandPrefix()) + " : "
|
||||
+ handle.getPerm().split(Pattern.quote("|"))[0] + " : &cfalse");
|
||||
}
|
||||
addAdditionalPermissions(msg, p, handle);
|
||||
addAdditionalPermissions(msg, p, handle, plugin.getVoteCommandPrefix());
|
||||
|
||||
}
|
||||
|
||||
@@ -181,7 +182,7 @@ public class AdminVotePerms extends GUIHandler {
|
||||
msg.add("&6" + handle.getHelpLineCommand("/av") + " : "
|
||||
+ handle.getPerm().split(Pattern.quote("|"))[0] + " : &cfalse");
|
||||
}
|
||||
addAdditionalPermissions(msg, p, handle);
|
||||
addAdditionalPermissions(msg, p, handle, "/av");
|
||||
}
|
||||
|
||||
for (Permission perm : plugin.getDescription().getPermissions()) {
|
||||
@@ -236,11 +237,11 @@ public class AdminVotePerms extends GUIHandler {
|
||||
msg.add(" ");
|
||||
|
||||
for (CommandHandler handle : plugin.getVoteCommand()) {
|
||||
msg.add(handle.getHelpLineCommand("/vote"));
|
||||
msg.add(handle.getHelpLineCommand(plugin.getVoteCommandPrefix()));
|
||||
msg.add(" " + handle.getPerm());
|
||||
msg.add(" " + handle.getHelpMessage());
|
||||
for (String permission : additionalPermissions(handle)) {
|
||||
msg.add(" " + permission);
|
||||
msg.add(" Additional permission for all target: " + permission);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +250,7 @@ public class AdminVotePerms extends GUIHandler {
|
||||
msg.add(" " + handle.getPerm());
|
||||
msg.add(" " + handle.getHelpMessage());
|
||||
for (String permission : additionalPermissions(handle)) {
|
||||
msg.add(" " + permission);
|
||||
msg.add(" Additional permission for all target: " + permission);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,14 +264,12 @@ public class AdminVotePerms extends GUIHandler {
|
||||
}
|
||||
|
||||
static ArrayList<String> additionalPermissions(CommandHandler handle) {
|
||||
ArrayList<String> permissions = new ArrayList<>();
|
||||
if (handle instanceof PlayerCommandHandler && handle.getPerm() != null) {
|
||||
String primary = handle.getPerm().split(Pattern.quote("|"))[0];
|
||||
if ("VotingPlugin.Commands.AdminVote.RemovePoints".equals(primary)) {
|
||||
permissions.add(primary + ".All");
|
||||
}
|
||||
}
|
||||
return permissions;
|
||||
if (!(handle instanceof PlayerCommandHandler playerHandler)) return new ArrayList<>();
|
||||
return new ArrayList<>(new LinkedHashSet<>(playerHandler.getAdditionalPermissions()));
|
||||
}
|
||||
|
||||
static boolean hasEffectiveBulkPermission(CommandSender sender, CommandHandler handle) {
|
||||
return handle instanceof PlayerCommandHandler playerHandler && playerHandler.hasAllPermission(sender);
|
||||
}
|
||||
|
||||
static boolean hasEffectivePermission(CommandSender sender, String permission) {
|
||||
@@ -280,14 +279,16 @@ public class AdminVotePerms extends GUIHandler {
|
||||
return sender.hasPermission(permission);
|
||||
}
|
||||
|
||||
private static void addAdditionalPermissions(ArrayList<String> output, CommandSender sender, CommandHandler handle) {
|
||||
private static void addAdditionalPermissions(ArrayList<String> output, CommandSender sender, CommandHandler handle,
|
||||
String command) {
|
||||
for (String permission : additionalPermissions(handle)) {
|
||||
String description = handle.getHelpLineCommand(command) + " : Additional permission for all target: "
|
||||
+ permission;
|
||||
if (sender instanceof Player) {
|
||||
boolean allowed = "VotingPlugin.Commands.AdminVote.RemovePoints.All".equals(permission)
|
||||
? AdminAuthorization.canRemovePointsFromAll(sender) : sender.hasPermission(permission);
|
||||
output.add("&6" + permission + (allowed ? " : &atrue" : " : &cfalse"));
|
||||
boolean allowed = hasEffectiveBulkPermission(sender, handle);
|
||||
output.add("&6" + description + (allowed ? " : &atrue" : " : &cfalse"));
|
||||
} else {
|
||||
output.add(permission);
|
||||
output.add(description);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+67
-77
@@ -22,6 +22,7 @@ import com.bencodez.advancedcore.api.inventory.BInventoryButton;
|
||||
import com.bencodez.advancedcore.api.item.ItemBuilder;
|
||||
import com.bencodez.advancedcore.api.rewards.RewardBuilder;
|
||||
import com.bencodez.advancedcore.api.user.UserDataFetchMode;
|
||||
import com.bencodez.simpleapi.messages.MessageAPI;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.commands.gui.player.VoteGUI;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoterPlayer;
|
||||
@@ -72,112 +73,101 @@ public class AdminVoteTopPoints extends GUIHandler {
|
||||
|
||||
@Override
|
||||
public void onChest(Player player) {
|
||||
org.bukkit.inventory.Inventory expectedTop = player.getOpenInventory().getTopInventory();
|
||||
try {
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = null;
|
||||
plugin.getUserManager().getDataManager().getTimer().execute(() -> {
|
||||
try {
|
||||
LinkedHashMap<TopVoterPlayer, Integer> raw = new LinkedHashMap<>();
|
||||
for (String uuidString : plugin.getVotingPluginUserManager().getAllUUIDs()) {
|
||||
UUID uuid = UUID.fromString(uuidString);
|
||||
VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, true);
|
||||
vpUser.userDataFetechMode(UserDataFetchMode.NO_CACHE);
|
||||
int points = vpUser.getPoints();
|
||||
if (points <= 0) continue;
|
||||
String name = vpUser.getUserData().getString("PlayerName", UserDataFetchMode.NO_CACHE);
|
||||
String lastOnlineValue = vpUser.getUserData().getString("LastOnline", UserDataFetchMode.NO_CACHE);
|
||||
long lastOnline = 0L;
|
||||
try {
|
||||
if (lastOnlineValue != null && !lastOnlineValue.isBlank() && !"null".equalsIgnoreCase(lastOnlineValue)) {
|
||||
lastOnline = Long.parseLong(lastOnlineValue);
|
||||
}
|
||||
} catch (NumberFormatException ignored) { }
|
||||
raw.put(new TopVoterPlayer(uuid, name == null ? "" : name, lastOnline), points);
|
||||
}
|
||||
LinkedHashMap<TopVoterPlayer, Integer> sorted = plugin.getTopVoterHandler().sortByValues(raw, false);
|
||||
plugin.getBukkitScheduler().runTask(plugin, () -> {
|
||||
if (player.getOpenInventory().getTopInventory() != expectedTop) return;
|
||||
openComputedChest(player, sorted);
|
||||
}, player);
|
||||
} catch (Throwable failure) { reportLoadFailure(player, "Failed to load top points", failure); }
|
||||
});
|
||||
} catch (RuntimeException failure) { reportLoadFailure(player, "Failed to schedule top points load", failure); }
|
||||
}
|
||||
|
||||
LinkedHashMap<TopVoterPlayer, Integer> topPoints1 = new LinkedHashMap<TopVoterPlayer, Integer>();
|
||||
|
||||
for (String uuid : plugin.getVotingPluginUserManager().getAllUUIDs()) {
|
||||
VotingPluginUser vpUser = plugin.getVotingPluginUserManager()
|
||||
.getVotingPluginUser(UUID.fromString(uuid));
|
||||
vpUser.userDataFetechMode(UserDataFetchMode.NO_CACHE);
|
||||
int points = vpUser.getPoints();
|
||||
if (points > 0) {
|
||||
topPoints1.put(vpUser.getTopVoterPlayer(), points);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
LinkedHashMap<TopVoterPlayer, Integer> topPoints = plugin.getTopVoterHandler().sortByValues(topPoints1,
|
||||
false);
|
||||
|
||||
users = topPoints.entrySet();
|
||||
private void reportLoadFailure(Player player, String message, Throwable failure) {
|
||||
plugin.getLogger().log(java.util.logging.Level.WARNING, message, failure);
|
||||
try {
|
||||
plugin.getBukkitScheduler().runTask(plugin,
|
||||
() -> player.sendMessage(MessageAPI.colorize("&cFailed to load top points")), player);
|
||||
} catch (RuntimeException schedulingFailure) {
|
||||
plugin.getLogger().log(java.util.logging.Level.WARNING,
|
||||
"Failed to notify player about top points load failure", schedulingFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private void openComputedChest(Player player, LinkedHashMap<TopVoterPlayer, Integer> topPoints) {
|
||||
try {
|
||||
Set<Entry<TopVoterPlayer, Integer>> users = topPoints.entrySet();
|
||||
ConfigurationSection customization = plugin.getGui().getChestVoteTopCustomization();
|
||||
boolean customzationEnabled = false;
|
||||
Queue<Integer> playerSlots = new ConcurrentLinkedQueue<>();
|
||||
if (customization != null) {
|
||||
customzationEnabled = customization.getBoolean("Enabled");
|
||||
List<Integer> customizationPlayerSlots = customization.getIntegerList("PlayerSlots");
|
||||
playerSlots.addAll(customizationPlayerSlots);
|
||||
playerSlots.addAll(customization.getIntegerList("PlayerSlots"));
|
||||
}
|
||||
|
||||
BInventory inv = new BInventory("Top Points");
|
||||
if (!plugin.getConfigFile().isAlwaysCloseInventory()) {
|
||||
inv.dontClose();
|
||||
}
|
||||
|
||||
if (!plugin.getConfigFile().isAlwaysCloseInventory()) inv.dontClose();
|
||||
int pos = 1;
|
||||
for (Entry<TopVoterPlayer, Integer> entry : users) {
|
||||
|
||||
ItemBuilder playerItem = new ItemBuilder(Material.PAPER);
|
||||
|
||||
if (plugin.getGui().isChestVoteTopUseSkull()) {
|
||||
playerItem = new ItemBuilder(entry.getKey().getPlayerHead());
|
||||
} else {
|
||||
playerItem = new ItemBuilder(Material.valueOf(plugin.getGui().getChestVoteTopPlayerItemMaterial()));
|
||||
}
|
||||
|
||||
ItemBuilder playerItem = plugin.getGui().isChestVoteTopUseSkull()
|
||||
? new ItemBuilder(entry.getKey().getPlayerHead())
|
||||
: new ItemBuilder(Material.valueOf(plugin.getGui().getChestVoteTopPlayerItemMaterial()));
|
||||
playerItem.setLore(new ArrayList<>());
|
||||
|
||||
BInventoryButton button = new BInventoryButton(playerItem
|
||||
.setName(plugin.getGui().getChestVoteTopItemName())
|
||||
BInventoryButton button = new BInventoryButton(playerItem.setName(plugin.getGui().getChestVoteTopItemName())
|
||||
.addLoreLine(plugin.getGui().getChestVoteTopItemLore()).addPlaceholder("position", "" + pos)
|
||||
.addPlaceholder("player", entry.getKey().getPlayerName())
|
||||
.addPlaceholder("votes", "" + entry.getValue())) {
|
||||
|
||||
@Override
|
||||
public void onClick(ClickEvent clickEvent) {
|
||||
.addPlaceholder("player", entry.getKey().getPlayerName()).addPlaceholder("votes", "" + entry.getValue())) {
|
||||
@Override public void onClick(ClickEvent clickEvent) {
|
||||
if (plugin.getGui().isChestVoteTopOpenMainGUIOnClick()) {
|
||||
TopVoterPlayer user = (TopVoterPlayer) getData("User");
|
||||
new VoteGUI(plugin, player, user.getUser())
|
||||
.open(GUIMethod.valueOf(plugin.getGui().getGuiMethodGUI().toUpperCase()));
|
||||
TopVoterPlayer selected = (TopVoterPlayer) getData("User");
|
||||
new VoteGUI(plugin, player, selected.getUser()).open(GUIMethod.valueOf(plugin.getGui().getGuiMethodGUI().toUpperCase()));
|
||||
}
|
||||
}
|
||||
}.addData("player", entry.getKey().getPlayerName()).addData("User", entry.getKey());
|
||||
|
||||
if (customzationEnabled && !playerSlots.isEmpty()) {
|
||||
button.setSlot(playerSlots.remove());
|
||||
}
|
||||
|
||||
if (customzationEnabled && !playerSlots.isEmpty()) button.setSlot(playerSlots.remove());
|
||||
inv.setCloseInv(plugin.getGui().isChestVoteTopCloseGUIOnClick());
|
||||
|
||||
inv.addButton(button);
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (plugin.getGui().isChestVoteTopBackButton()) {
|
||||
if (customzationEnabled) {
|
||||
inv.addButton(plugin.getCommandLoader().getBackButton(user)
|
||||
.setSlot(customization.getInt("BackButtonSlot", 0)));
|
||||
} else {
|
||||
inv.getPageButtons().add(plugin.getCommandLoader().getBackButton(user).setSlot(1));
|
||||
}
|
||||
if (customzationEnabled) inv.addButton(plugin.getCommandLoader().getBackButton(user).setSlot(customization.getInt("BackButtonSlot", 0)));
|
||||
else inv.getPageButtons().add(plugin.getCommandLoader().getBackButton(user).setSlot(1));
|
||||
}
|
||||
|
||||
String guiPath = "VoteTop.Customization";
|
||||
for (final String str : plugin.getGui().getChestGUIExtraItems(guiPath)) {
|
||||
inv.addButton(
|
||||
new BInventoryButton(new ItemBuilder(plugin.getGui().getChestGUIExtraItemsItem(guiPath, str))) {
|
||||
|
||||
@Override
|
||||
public void onClick(ClickEvent clickEvent) {
|
||||
plugin.getCommandLoader().processSlotClick(player, user, str);
|
||||
new RewardBuilder(plugin.getGui().getData(),
|
||||
"CHEST." + guiPath + ".ExtraItems." + str + ".Rewards").setGiveOffline(false)
|
||||
.send(clickEvent.getPlayer());
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (customization == null || !customzationEnabled || !customization.getBoolean("RemoveBottomBar")) {
|
||||
inv.setPages(true);
|
||||
inv.addButton(new BInventoryButton(new ItemBuilder(plugin.getGui().getChestGUIExtraItemsItem(guiPath, str))) {
|
||||
@Override public void onClick(ClickEvent clickEvent) {
|
||||
plugin.getCommandLoader().processSlotClick(player, user, str);
|
||||
new RewardBuilder(plugin.getGui().getData(), "CHEST." + guiPath + ".ExtraItems." + str + ".Rewards")
|
||||
.setGiveOffline(false).send(clickEvent.getPlayer());
|
||||
}
|
||||
});
|
||||
}
|
||||
if (customization == null || !customzationEnabled || !customization.getBoolean("RemoveBottomBar")) inv.setPages(true);
|
||||
inv.setMaxInvSize(plugin.getGui().getChestVoteTopSize());
|
||||
inv.openInventory(player);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
} catch (Exception failure) {
|
||||
plugin.getLogger().log(java.util.logging.Level.WARNING, "Failed to open top points", failure);
|
||||
player.sendMessage(MessageAPI.colorize("&cFailed to load top points"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
-5
@@ -50,6 +50,10 @@ public class VoteGUI extends GUIHandler {
|
||||
if (this.user == null) {
|
||||
user = plugin.getVotingPluginUserManager().getVotingPluginUser(player);
|
||||
}
|
||||
if (!isAuthorized(plugin, player, user)) {
|
||||
player.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatNoPerms()));
|
||||
return;
|
||||
}
|
||||
|
||||
com.bencodez.simpleapi.dialog.MultiActionDialogBuilder dialog = plugin.getDialogService().multiAction(player)
|
||||
.placeholder("points", "" + user.getPoints()).placeholder("player", user.getPlayerName())
|
||||
@@ -72,6 +76,10 @@ public class VoteGUI extends GUIHandler {
|
||||
if (clicked == null) {
|
||||
return;
|
||||
}
|
||||
if (!isAuthorized(plugin, clicked, user)) {
|
||||
clicked.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatNoPerms()));
|
||||
return;
|
||||
}
|
||||
|
||||
String cmd = plugin.getGui().getChestVoteGUISlotCommand(slot);
|
||||
|
||||
@@ -180,6 +188,19 @@ public class VoteGUI extends GUIHandler {
|
||||
// not available
|
||||
}
|
||||
|
||||
static String getRequiredPermission(VotingPluginMain plugin, Player player, VotingPluginUser viewedUser) {
|
||||
VotingPluginUser viewingUser = plugin.getVotingPluginUserManager().getVotingPluginUser(player);
|
||||
String viewingUuid = viewingUser == null ? null : viewingUser.getUUID();
|
||||
if (viewingUuid != null && viewedUser != null && viewingUuid.equals(viewedUser.getUUID())) {
|
||||
return "VotingPlugin.Commands.Vote.GUI";
|
||||
}
|
||||
return "VotingPlugin.Commands.Vote.GUI.Other";
|
||||
}
|
||||
|
||||
static boolean isAuthorized(VotingPluginMain plugin, Player player, VotingPluginUser viewedUser) {
|
||||
return player.hasPermission(getRequiredPermission(plugin, player, viewedUser));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChest(Player player) {
|
||||
if (this.user == null) {
|
||||
@@ -190,11 +211,7 @@ public class VoteGUI extends GUIHandler {
|
||||
inv.dontClose();
|
||||
}
|
||||
|
||||
if (player.getUniqueId().toString().equals(user.getUUID())) {
|
||||
inv.requirePermission("VotingPlugin.Commands.Vote.GUI");
|
||||
} else {
|
||||
inv.requirePermission("VotingPlugin.Commands.Vote.GUI.Other");
|
||||
}
|
||||
inv.requirePermission(getRequiredPermission(plugin, player, user));
|
||||
|
||||
inv.addPlaceholder("points", "" + user.getPoints());
|
||||
inv.addPlaceholder("player", user.getPlayerName());
|
||||
|
||||
+5
-3
@@ -55,6 +55,7 @@ public class VoteHelp extends GUIHandler {
|
||||
*/
|
||||
public ArrayList<TextComponent> helpText(CommandSender sender) {
|
||||
ArrayList<TextComponent> msg = new ArrayList<>();
|
||||
if (!plugin.isVoteCommandEnabled()) return msg;
|
||||
HashMap<String, TextComponent> unsorted = new HashMap<>();
|
||||
|
||||
boolean requirePerms = plugin.getConfigFile().isFormatCommandsVoteHelpRequirePermission();
|
||||
@@ -70,7 +71,7 @@ public class VoteHelp extends GUIHandler {
|
||||
}
|
||||
for (CommandHandler cmdHandle : plugin.getVoteCommand()) {
|
||||
if (!requirePerms || cmdHandle.hasPerm(sender)) {
|
||||
unsorted.put(cmdHandle.getHelpLineCommand("/vote"), cmdHandle.getHelpLine("/vote",
|
||||
unsorted.put(cmdHandle.getHelpLineCommand(plugin.getVoteCommandPrefix()), cmdHandle.getHelpLine(plugin.getVoteCommandPrefix(),
|
||||
plugin.getConfigFile().getFormatCommandsVoteHelpLine(), hoverColor));
|
||||
}
|
||||
}
|
||||
@@ -92,14 +93,15 @@ public class VoteHelp extends GUIHandler {
|
||||
*/
|
||||
public ArrayList<TextComponent> helpTextLegacy(CommandSender sender) {
|
||||
ArrayList<TextComponent> msg = new ArrayList<>();
|
||||
if (!plugin.isVoteCommandEnabled()) return msg;
|
||||
HashMap<String, TextComponent> unsorted = new HashMap<>();
|
||||
|
||||
boolean requirePerms = plugin.getConfigFile().isFormatCommandsVoteHelpRequirePermission();
|
||||
|
||||
for (CommandHandler cmdHandle : plugin.getVoteCommand()) {
|
||||
if (!requirePerms || cmdHandle.hasPerm(sender)) {
|
||||
unsorted.put(cmdHandle.getHelpLineCommand("/vote"),
|
||||
cmdHandle.getHelpLine("/vote", plugin.getConfigFile().getFormatCommandsVoteHelpLine()));
|
||||
unsorted.put(cmdHandle.getHelpLineCommand(plugin.getVoteCommandPrefix()),
|
||||
cmdHandle.getHelpLine(plugin.getVoteCommandPrefix(), plugin.getConfigFile().getFormatCommandsVoteHelpLine()));
|
||||
}
|
||||
}
|
||||
ArrayList<String> unsortedList = new ArrayList<>();
|
||||
|
||||
+36
-18
@@ -124,10 +124,13 @@ public class VoteShop extends GUIHandler {
|
||||
|
||||
@Override
|
||||
public void onClick(ClickEvent event) {
|
||||
VotingPluginUser clickedUser = getUser(event.getPlayer());
|
||||
if (plugin.getConfigFile().isExtraVoteShopCheck()) {
|
||||
clickedUser.cache();
|
||||
if (!canPurchaseFromCategory(event.getPlayer(), category, item)) {
|
||||
event.getPlayer().sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatNoPerms()));
|
||||
return;
|
||||
}
|
||||
VotingPluginUser clickedUser = getUser(event.getPlayer());
|
||||
plugin.getVoteShopManager().getPurchaseService().refreshUserForPurchaseValidation(clickedUser,
|
||||
item, plugin.getConfigFile().isExtraVoteShopCheck());
|
||||
|
||||
if (item.isNotBuyable()) {
|
||||
clickedUser.sendMessage(plugin.getConfigFile().getFormatShopNotPurchasable());
|
||||
@@ -169,9 +172,9 @@ public class VoteShop extends GUIHandler {
|
||||
*/
|
||||
protected void addCategoryButton(BInventory inv, final Player player, final VotingPluginUser currentUser,
|
||||
final VoteShopCategoryButton button) {
|
||||
boolean hasPermission = plugin.getVoteShopManager().getPurchaseService().hasPermission(player,
|
||||
button.getPermission());
|
||||
if (!hasPermission && button.isHideOnNoPermission()) {
|
||||
java.util.function.Predicate<String> permissionCheck = permission -> plugin.getVoteShopManager()
|
||||
.getPurchaseService().hasPermission(player, permission);
|
||||
if (!VoteShopCategoryAuthorization.mayDisplay(button, permissionCheck)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -186,6 +189,11 @@ public class VoteShop extends GUIHandler {
|
||||
|
||||
@Override
|
||||
public void onClick(ClickEvent event) {
|
||||
if (!VoteShopCategoryAuthorization.authorized(button, permission -> plugin.getVoteShopManager()
|
||||
.getPurchaseService().hasPermission(event.getPlayer(), permission))) {
|
||||
event.getPlayer().sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatNoPerms()));
|
||||
return;
|
||||
}
|
||||
new VoteShopCategoryMenu(plugin, event.getPlayer(), currentUser, category).open(GUIMethod.CHEST);
|
||||
}
|
||||
}.addData("identifier", button.getIdentifier()));
|
||||
@@ -224,20 +232,30 @@ public class VoteShop extends GUIHandler {
|
||||
*/
|
||||
protected void handlePurchase(Player player, VotingPluginUser currentUser, VoteShopItem item,
|
||||
VoteShopCategory category) {
|
||||
VoteShopPurchaseResult result = plugin.getVoteShopManager().purchase(player, currentUser, item);
|
||||
if (result != VoteShopPurchaseResult.SUCCESS) {
|
||||
plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, currentUser, item, result);
|
||||
if (!canPurchaseFromCategory(player, category, item)) {
|
||||
player.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatNoPerms()));
|
||||
return;
|
||||
}
|
||||
|
||||
plugin.getCommandLoader().processSlotClick(player, currentUser, item.getIdentifier());
|
||||
if (plugin.getVoteShopManager().getDefinition().isReopenGuiOnPurchase()) {
|
||||
if (category != null) {
|
||||
new VoteShopCategoryMenu(plugin, player, currentUser, category).open(GUIMethod.CHEST);
|
||||
} else {
|
||||
plugin.getCommandLoader().processSlotClick(player, currentUser, "shop");
|
||||
plugin.getVoteShopManager().purchase(player, currentUser, item, result -> {
|
||||
if (result != VoteShopPurchaseResult.SUCCESS) {
|
||||
plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, currentUser, item, result);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
plugin.getCommandLoader().processSlotClick(player, currentUser, item.getIdentifier());
|
||||
if (plugin.getVoteShopManager().getDefinition().isReopenGuiOnPurchase()) {
|
||||
if (category != null) {
|
||||
new VoteShopCategoryMenu(plugin, player, currentUser, category).open(GUIMethod.CHEST);
|
||||
} else {
|
||||
plugin.getCommandLoader().processSlotClick(player, currentUser, "shop");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private boolean canPurchaseFromCategory(Player player, VoteShopCategory category, VoteShopItem item) {
|
||||
return VoteShopCategoryAuthorization.canPurchase(plugin.getVoteShopManager().getDefinition(), category, item,
|
||||
permission -> plugin.getVoteShopManager().getPurchaseService().hasPermission(player, permission));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -257,4 +275,4 @@ public class VoteShop extends GUIHandler {
|
||||
public void open() {
|
||||
open(GUIMethod.CHEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package com.bencodez.votingplugin.commands.gui.player;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import com.bencodez.votingplugin.voteshop.shop.VoteShopCategory;
|
||||
import com.bencodez.votingplugin.voteshop.shop.VoteShopCategoryButton;
|
||||
import com.bencodez.votingplugin.voteshop.shop.VoteShopDefinition;
|
||||
import com.bencodez.votingplugin.voteshop.shop.VoteShopEntry;
|
||||
import com.bencodez.votingplugin.voteshop.shop.VoteShopItem;
|
||||
|
||||
/** Keeps VoteShop category visibility separate from authorization. */
|
||||
final class VoteShopCategoryAuthorization {
|
||||
private VoteShopCategoryAuthorization() {
|
||||
}
|
||||
|
||||
static boolean mayDisplay(VoteShopCategoryButton button, Predicate<String> permissionCheck) {
|
||||
return authorized(button, permissionCheck) || !button.isHideOnNoPermission();
|
||||
}
|
||||
|
||||
static boolean authorized(VoteShopCategoryButton button, Predicate<String> permissionCheck) {
|
||||
return button != null && permissionCheck.test(button.getPermission());
|
||||
}
|
||||
|
||||
/** Returns true only when an authorized path from the main shop reaches the category. */
|
||||
static boolean canOpen(VoteShopDefinition definition, String targetCategory,
|
||||
Predicate<String> permissionCheck) {
|
||||
if (definition == null || targetCategory == null || targetCategory.isEmpty()) return false;
|
||||
return reaches(definition, definition.getMainEntries().values(), targetCategory,
|
||||
permissionCheck, new HashSet<>());
|
||||
}
|
||||
|
||||
static boolean canPurchase(VoteShopDefinition definition, VoteShopCategory category, VoteShopItem item,
|
||||
Predicate<String> permissionCheck) {
|
||||
if (definition == null || item == null) return false;
|
||||
if (category == null) return containsSameEntry(definition.getMainEntries().values(), item);
|
||||
VoteShopCategory currentCategory = definition.getCategory(category.getId());
|
||||
return currentCategory == category && containsSameEntry(currentCategory.getEntries().values(), item)
|
||||
&& canOpen(definition, category.getId(), permissionCheck);
|
||||
}
|
||||
|
||||
private static boolean containsSameEntry(Iterable<VoteShopEntry> entries, VoteShopEntry expected) {
|
||||
for (VoteShopEntry entry : entries) if (entry == expected) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean reaches(VoteShopDefinition definition, Iterable<VoteShopEntry> entries,
|
||||
String targetCategory, Predicate<String> permissionCheck, Set<String> visited) {
|
||||
for (VoteShopEntry entry : entries) {
|
||||
if (!(entry instanceof VoteShopCategoryButton button) || !authorized(button, permissionCheck)) continue;
|
||||
String categoryId = button.getCategoryId();
|
||||
if (categoryId == null) continue;
|
||||
VoteShopCategory category = definition.getCategory(categoryId);
|
||||
if (category == null) continue;
|
||||
if (targetCategory.equals(categoryId)) return true;
|
||||
if (!visited.add(categoryId)) continue;
|
||||
if (reaches(definition, category.getEntries().values(), targetCategory,
|
||||
permissionCheck, visited)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+7
-11
@@ -72,6 +72,11 @@ public class VoteShopCategoryMenu extends GUIHandler {
|
||||
player.sendMessage(MessageAPI.colorize(plugin.getVoteShopManager().getDefinition().getDisabledMessage()));
|
||||
return;
|
||||
}
|
||||
if (!VoteShopCategoryAuthorization.canOpen(plugin.getVoteShopManager().getDefinition(), category.getId(),
|
||||
permission -> plugin.getVoteShopManager().getPurchaseService().hasPermission(player, permission))) {
|
||||
player.sendMessage(MessageAPI.colorize(plugin.getConfigFile().getFormatNoPerms()));
|
||||
return;
|
||||
}
|
||||
|
||||
VotingPluginUser currentUser = plugin.getVotingPluginUserManager().getVotingPluginUser(player);
|
||||
BInventory inv = new BInventory(category.getName());
|
||||
@@ -148,20 +153,11 @@ public class VoteShopCategoryMenu extends GUIHandler {
|
||||
*/
|
||||
protected void addCategoryButton(BInventory inv, final Player player, final VotingPluginUser currentUser,
|
||||
final VoteShopCategoryButton button) {
|
||||
inv.addButton(new BInventoryButton(new ItemBuilder(button.getDisplaySection())) {
|
||||
|
||||
@Override
|
||||
public void onClick(ClickEvent event) {
|
||||
VoteShopCategory target = plugin.getVoteShopManager().getCategory(button.getCategoryId());
|
||||
if (target != null) {
|
||||
new VoteShopCategoryMenu(plugin, event.getPlayer(), currentUser, target).open(GUIMethod.CHEST);
|
||||
}
|
||||
}
|
||||
});
|
||||
new VoteShop(plugin, player, currentUser).addCategoryButton(inv, player, currentUser, button);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void open() {
|
||||
open(GUIMethod.CHEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-23
@@ -1,6 +1,7 @@
|
||||
package com.bencodez.votingplugin.commands.gui.player;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
@@ -31,6 +32,8 @@ public class VoteShopConfirm extends GUIHandler {
|
||||
|
||||
private VotingPluginUser user;
|
||||
|
||||
private final AtomicBoolean purchaseSubmitted = new AtomicBoolean();
|
||||
|
||||
/**
|
||||
* Creates the GUI.
|
||||
*
|
||||
@@ -71,20 +74,28 @@ public class VoteShopConfirm extends GUIHandler {
|
||||
|
||||
@Override
|
||||
public void onClick(ClickEvent event) {
|
||||
user.cache();
|
||||
VoteShopPurchaseResult result = plugin.getVoteShopManager().purchase(player, user, item);
|
||||
if (result != VoteShopPurchaseResult.SUCCESS) {
|
||||
plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, user, item, result);
|
||||
returnToPrevious(event.getPlayer());
|
||||
if (!canPurchaseFromCategory(event.getPlayer())) {
|
||||
event.getPlayer().sendMessage(com.bencodez.simpleapi.messages.MessageAPI
|
||||
.colorize(plugin.getConfigFile().getFormatNoPerms()));
|
||||
return;
|
||||
}
|
||||
if (!beginPurchase()) return;
|
||||
event.closeInventory();
|
||||
plugin.getVoteShopManager().getPurchaseService().refreshUserForPurchaseValidation(user, item, true);
|
||||
plugin.getVoteShopManager().purchase(player, user, item, result -> {
|
||||
if (result != VoteShopPurchaseResult.SUCCESS) {
|
||||
plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, user, item, result);
|
||||
returnToPrevious(event.getPlayer());
|
||||
return;
|
||||
}
|
||||
|
||||
plugin.getCommandLoader().processSlotClick(player, user, item.getIdentifier());
|
||||
if (item.isCloseGUI()) {
|
||||
event.closeInventory();
|
||||
} else {
|
||||
returnToPrevious(event.getPlayer());
|
||||
}
|
||||
plugin.getCommandLoader().processSlotClick(player, user, item.getIdentifier());
|
||||
if (item.isCloseGUI()) {
|
||||
event.closeInventory();
|
||||
} else {
|
||||
returnToPrevious(event.getPlayer());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
inv.addButton(new BInventoryButton(new ItemBuilder(plugin.getShopFile().getShopConfirmPurchaseNoItem())) {
|
||||
@@ -101,6 +112,15 @@ public class VoteShopConfirm extends GUIHandler {
|
||||
inv.openInventory(player);
|
||||
}
|
||||
|
||||
boolean beginPurchase() {
|
||||
return purchaseSubmitted.compareAndSet(false, true);
|
||||
}
|
||||
|
||||
private boolean canPurchaseFromCategory(Player player) {
|
||||
return VoteShopCategoryAuthorization.canPurchase(plugin.getVoteShopManager().getDefinition(), category, item,
|
||||
permission -> plugin.getVoteShopManager().getPurchaseService().hasPermission(player, permission));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDialog(Player player) {
|
||||
PlayerUtils.setPlayerMeta(plugin, player, "ident", item.getIdentifier());
|
||||
@@ -116,20 +136,27 @@ public class VoteShopConfirm extends GUIHandler {
|
||||
if (clicked == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
user.cache();
|
||||
VoteShopPurchaseResult result = plugin.getVoteShopManager().purchase(clicked, user, item);
|
||||
if (result != VoteShopPurchaseResult.SUCCESS) {
|
||||
plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(clicked, user, item,
|
||||
result);
|
||||
returnToPrevious(clicked);
|
||||
if (!canPurchaseFromCategory(clicked)) {
|
||||
clicked.sendMessage(com.bencodez.simpleapi.messages.MessageAPI
|
||||
.colorize(plugin.getConfigFile().getFormatNoPerms()));
|
||||
return;
|
||||
}
|
||||
if (!beginPurchase()) return;
|
||||
|
||||
plugin.getCommandLoader().processSlotClick(clicked, user, item.getIdentifier());
|
||||
if (!item.isCloseGUI()) {
|
||||
returnToPrevious(clicked);
|
||||
}
|
||||
plugin.getVoteShopManager().getPurchaseService().refreshUserForPurchaseValidation(user, item, true);
|
||||
plugin.getVoteShopManager().purchase(clicked, user, item, result -> {
|
||||
if (result != VoteShopPurchaseResult.SUCCESS) {
|
||||
plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(clicked, user, item,
|
||||
result);
|
||||
returnToPrevious(clicked);
|
||||
return;
|
||||
}
|
||||
|
||||
plugin.getCommandLoader().processSlotClick(clicked, user, item.getIdentifier());
|
||||
if (!item.isCloseGUI()) {
|
||||
returnToPrevious(clicked);
|
||||
}
|
||||
});
|
||||
}).onNo(payload -> {
|
||||
Player clicked = player.getServer().getPlayer(payload.owner());
|
||||
if (clicked != null) {
|
||||
@@ -155,4 +182,4 @@ public class VoteShopConfirm extends GUIHandler {
|
||||
public void open() {
|
||||
open(GUIMethod.CHEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -60,7 +60,7 @@ public class VoteToday extends GUIHandler {
|
||||
Player clicked = player.getServer().getPlayer(payload.owner());
|
||||
if (clicked != null) {
|
||||
VotingPluginUser clickedUser = plugin.getVotingPluginUserManager()
|
||||
.getVotingPluginUser(uuid);
|
||||
.getVotingPluginUser(uuid, true);
|
||||
new VoteGUI(plugin, clicked, clickedUser).open(
|
||||
GUIMethod.valueOf(plugin.getGui().getGuiMethodGUI().toUpperCase()));
|
||||
}
|
||||
@@ -161,7 +161,7 @@ public class VoteToday extends GUIHandler {
|
||||
inv.addButton(inv.getNextSlot(), new BInventoryButton(item) {
|
||||
@Override
|
||||
public void onClick(ClickEvent clickEvent) {
|
||||
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid);
|
||||
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, true);
|
||||
new VoteGUI(plugin, player, user)
|
||||
.open(GUIMethod.valueOf(plugin.getGui().getGuiMethodGUI().toUpperCase()));
|
||||
}
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ public class VoteURL extends GUIHandler {
|
||||
}
|
||||
|
||||
if (plugin.getGui().isChestVoteURLBackButton()) {
|
||||
dialog.button("&eBack", "&7Return to previous menu", payload -> player.performCommand("vote gui"));
|
||||
dialog.button("&eBack", "&7Return to previous menu", payload -> plugin.getCommandLoader().performVoteCommand(player, "gui"));
|
||||
}
|
||||
|
||||
dialog.open();
|
||||
|
||||
+2
-1
@@ -119,7 +119,8 @@ public class VoteURLVoteSite extends GUIHandler {
|
||||
com.bencodez.simpleapi.dialog.MultiActionDialogBuilder dialog = plugin.getDialogService().multiAction(player)
|
||||
.placeholder("player", user.getPlayerName()).placeholder("site", site.getDisplayName())
|
||||
.placeholder("sitename", site.getDisplayName()).placeholder("SiteName", site.getDisplayName())
|
||||
.placeholder("servicesite", site.getServiceSite()).placeholder("ServiceSite", site.getServiceSite())
|
||||
.placeholder("servicesite", site.getServiceSite())
|
||||
.placeholder("ServiceSite", site.getServiceSite())
|
||||
.placeholder("VoteDelay", "" + site.getVoteDelay())
|
||||
.placeholder("VoteHour", "" + site.getVoteDelayDailyHour())
|
||||
.placeholder("Next", user.voteCommandNextInfo(site)).placeholder("Last", user.voteCommandLastLine(site))
|
||||
|
||||
@@ -42,9 +42,9 @@ public class BungeeSettings extends YMLFile {
|
||||
@Getter
|
||||
private String pluginMessagingChannel = "vp:vp";
|
||||
|
||||
@ConfigDataBoolean(path = "PluginMessageEncryption")
|
||||
@Getter
|
||||
private boolean pluginMessageEncryption = false;
|
||||
@ConfigDataBoolean(path = "CommunicationEncryption")
|
||||
@Getter
|
||||
private boolean communicationEncryption = false;
|
||||
|
||||
@ConfigDataString(path = "Redis.Prefix")
|
||||
@Getter
|
||||
@@ -81,6 +81,10 @@ public class BungeeSettings extends YMLFile {
|
||||
@ConfigDataString(path = "MQTT.Prefix")
|
||||
@Getter
|
||||
private String mqttPrefix = "";
|
||||
|
||||
@ConfigDataString(path = "SharedTransportAuthentication")
|
||||
@Getter
|
||||
private String sharedTransportAuthentication = "COMPATIBILITY";
|
||||
|
||||
@ConfigDataString(path = "BungeeServer.Host")
|
||||
@Getter
|
||||
@@ -88,7 +92,11 @@ public class BungeeSettings extends YMLFile {
|
||||
|
||||
@ConfigDataInt(path = "BungeeServer.Port")
|
||||
@Getter
|
||||
private int bungeeServerPort = 1297;
|
||||
private int bungeeServerPort = 1297;
|
||||
|
||||
@ConfigDataString(path = "HTTP.ConnectionCode")
|
||||
@Getter
|
||||
private String httpConnectionCode = "";
|
||||
|
||||
@ConfigDataBoolean(path = "PerServerPoints")
|
||||
@Getter
|
||||
@@ -157,9 +165,14 @@ public class BungeeSettings extends YMLFile {
|
||||
*
|
||||
* @return the formatted server name
|
||||
*/
|
||||
public String getServerNameStorage() {
|
||||
return getServer().replace("-", "_");
|
||||
}
|
||||
public String getServerNameStorage() {
|
||||
return getServer().replace("-", "_");
|
||||
}
|
||||
|
||||
/** Legacy plugin-message framing setting retained only for existing configuration files. */
|
||||
public boolean isPluginMessageEncryption() {
|
||||
return getData().getBoolean("PluginMessageEncryption", false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadValues() {
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.bencodez.simpleapi.file.annotation.ConfigDataKeys;
|
||||
import com.bencodez.simpleapi.file.annotation.ConfigDataListString;
|
||||
import com.bencodez.simpleapi.file.annotation.ConfigDataLong;
|
||||
import com.bencodez.simpleapi.file.annotation.ConfigDataString;
|
||||
import com.bencodez.advancedcore.api.time.TimeType;
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.placeholders.PlaceholderCacheLevel;
|
||||
import com.bencodez.votingplugin.topvoter.TopVoter;
|
||||
@@ -23,6 +24,11 @@ import com.bencodez.votingplugin.topvoter.TopVoter;
|
||||
import lombok.Getter;
|
||||
|
||||
public class Config extends YMLFile {
|
||||
|
||||
/** Explicit permission for executable deployment over private-network plaintext HTTP. */
|
||||
public boolean getControlAllowInsecureHttpPluginDeployment() {
|
||||
return getData().getBoolean("Control.AllowInsecureHttpPluginDeployment", false);
|
||||
}
|
||||
|
||||
@ConfigDataBoolean(path = "AddCustomCommands")
|
||||
@Getter
|
||||
@@ -57,6 +63,30 @@ public class Config extends YMLFile {
|
||||
@Getter
|
||||
private boolean queueVotesDuringTimeChange = false;
|
||||
|
||||
@ConfigDataBoolean(path = "AutomaticTimeChanges.Day")
|
||||
@Getter
|
||||
private boolean automaticTimeChangesDay = true;
|
||||
|
||||
@ConfigDataBoolean(path = "AutomaticTimeChanges.Week")
|
||||
@Getter
|
||||
private boolean automaticTimeChangesWeek = true;
|
||||
|
||||
@ConfigDataBoolean(path = "AutomaticTimeChanges.Month")
|
||||
@Getter
|
||||
private boolean automaticTimeChangesMonth = true;
|
||||
|
||||
public boolean isAutomaticTimeChangeEnabled(TimeType type) {
|
||||
if (type == null) {
|
||||
return true;
|
||||
}
|
||||
return switch (type) {
|
||||
case DAY -> isAutomaticTimeChangesDay();
|
||||
case WEEK -> isAutomaticTimeChangesWeek();
|
||||
case MONTH -> isAutomaticTimeChangesMonth();
|
||||
default -> true;
|
||||
};
|
||||
}
|
||||
|
||||
@ConfigDataBoolean(path = "AdvancedServiceSiteHandling")
|
||||
@Getter
|
||||
private boolean advancedServiceSiteHandling = false;
|
||||
@@ -385,7 +415,16 @@ public class Config extends YMLFile {
|
||||
|
||||
@ConfigDataString(path = "Format.Commands.Vote.GivePoints.NotEnoughPoints")
|
||||
@Getter
|
||||
private String formatCommandsVoteGivePointsNotEnoughPoints = "&cNot enough points";
|
||||
private String formatCommandsVoteGivePointsNotEnoughPoints = "&cNot enough points";
|
||||
|
||||
@ConfigDataString(path = "Format.Commands.Vote.GivePoints.Unavailable")
|
||||
@Getter
|
||||
private String formatCommandsVoteGivePointsUnavailable = "&cUnable to transfer points right now, please try again";
|
||||
|
||||
@ConfigDataString(path = "Format.Commands.Vote.GivePoints.PendingConfirmation")
|
||||
@Getter
|
||||
private String formatCommandsVoteGivePointsPendingConfirmation =
|
||||
"&eTransfer pending confirmation; do not retry it";
|
||||
|
||||
@ConfigDataString(path = "Format.Commands.Vote.GivePoints.NotJoinedServer")
|
||||
@Getter
|
||||
@@ -483,6 +522,15 @@ public class Config extends YMLFile {
|
||||
@Getter
|
||||
private boolean limitMonthlyVotes = false;
|
||||
|
||||
/** Restart-only player command registration. Missing keys preserve /vote. */
|
||||
@ConfigDataBoolean(path = "VoteCommandEnabled", defaultValue = true)
|
||||
@Getter
|
||||
private boolean voteCommandEnabled = true;
|
||||
|
||||
@ConfigDataString(path = "VoteCommandName", defaultValue = "vote")
|
||||
@Getter
|
||||
private String voteCommandName = "vote";
|
||||
|
||||
@ConfigDataBoolean(path = "LoadCommandAliases")
|
||||
@Getter
|
||||
private boolean loadCommandAliases = true;
|
||||
|
||||
@@ -80,14 +80,14 @@ public class ConfigVoteSites extends YMLFile {
|
||||
String org = siteName;
|
||||
siteName = siteName.replaceAll("[\\.\\s]+", "_");
|
||||
|
||||
plugin.getLogger().warning("Creating VoteSite " + siteName + " for the service site '" + org + "'");
|
||||
setEnabled(siteName, true);
|
||||
setServiceSite(siteName, org);
|
||||
setVoteURL(siteName, "VoteURL");
|
||||
plugin.getLogger().warning("Creating VoteSite " + siteName + " for the service site '" + org + "'");
|
||||
setEnabled(siteName, true);
|
||||
setServiceSite(siteName, org);
|
||||
setVoteURL(siteName, "VoteURL");
|
||||
setVoteDelay(siteName, "24h");
|
||||
set(siteName, "DisplayItem.Material", "STONE");
|
||||
set(siteName, "DisplayItem.Amount", 1);
|
||||
set(siteName, "Rewards.Messages.Player", "&aThanks for voting on %ServiceSite%!");
|
||||
set(siteName, "Rewards.Messages.Player", "&aThanks for voting on %ServiceSite%!");
|
||||
set(siteName, "WaitUntilVoteDelayRewards", Collections.emptyMap());
|
||||
|
||||
plugin.loadVoteSites();
|
||||
|
||||
@@ -26,6 +26,10 @@ import lombok.Getter;
|
||||
*/
|
||||
public class GUI extends YMLFile {
|
||||
|
||||
@ConfigDataString(path = "CHEST.VoteStreaks.Name")
|
||||
@Getter
|
||||
private String chestVoteStreaksName = "&6Vote streaks";
|
||||
|
||||
@ConfigDataString(path = "BOOK.VoteURLBookGUI.AlreadyVotedColor")
|
||||
@Getter
|
||||
private String bookVoteURLBookGUIAlreadyVotedColor = "RED";
|
||||
|
||||
+603
-18
@@ -5,11 +5,18 @@ import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.FileAttribute;
|
||||
import java.nio.file.attribute.BasicFileAttributeView;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.nio.file.attribute.PosixFileAttributeView;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.SeekableByteChannel;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
@@ -22,12 +29,14 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
import com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport;
|
||||
import com.bencodez.votingplugin.proxy.BungeeMethod;
|
||||
import com.bencodez.votingplugin.util.DurableFiles;
|
||||
|
||||
@@ -35,19 +44,23 @@ import com.bencodez.votingplugin.util.DurableFiles;
|
||||
public final class BackendConfigurationService {
|
||||
public static final String REDACTED = "__VOTINGPLUGIN_CONTROL_REDACTED__";
|
||||
public static final int MAX_CONTENT_BYTES = 512 * 1024;
|
||||
public static final int MAX_REWARD_FILES = 100;
|
||||
public static final int MAX_REWARD_FILE_BASENAME_LENGTH = 100;
|
||||
static final int MAX_REWARD_DIRECTORY_ENTRIES = 1024;
|
||||
private static final int READ_ATTEMPTS = 3;
|
||||
private static final long READ_RETRY_MILLIS = 25;
|
||||
private static final Set<String> READABLE_QUICK_SETUPS = Set.of("standalone", "proxy-backend", "vote-site",
|
||||
"common-settings", "vote-party", "auto-create-vote-sites", "vote-logging");
|
||||
private static final Set<String> TOP_LEVEL = Set.of("Config.yml", "VoteSites.yml", "SpecialRewards.yml",
|
||||
"GUI.yml", "Shop.yml", "BungeeSettings.yml");
|
||||
private static final Pattern REWARD_FILE_NAME = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_-]{0,99}\\.yml");
|
||||
private static final Set<String> VOTE_SITE_FIELDS = Set.of("AdvancedPriority", "Amount", "Chance",
|
||||
"DisplayItem", "Enabled", "Fallback", "ForceOffline", "Hidden", "Items", "Material", "Messages",
|
||||
"Name", "Player", "Priority", "ServiceSite", "VoteDelay", "VoteDelayDaily", "VoteURL",
|
||||
"WaitUntilVoteDelay", "PermissionToView", "IgnoreCanVote", "VoteDelayDailyHour", "VoteDelayMin",
|
||||
"GiveOffline");
|
||||
private static final Pattern COMMENT_SECRET = Pattern.compile(
|
||||
"(?i)([\"']?\\b(?:[\\w-]*(?:password|secret)[\\w-]*|token|api[ _.-]?key|authorization|[\\w.-]*webhook[ _.-]?url)"
|
||||
"(?i)([\"']?\\b(?:[\\w-]*(?:password|secret|user(?:name)?)[\\w-]*|token|connection[ _.-]?code|api[ _.-]?key|authorization|[\\w.-]*webhook[ _.-]?url)"
|
||||
+ "\\b[\"']?\\s*[:=]\\s*)(.*)$");
|
||||
private static final Pattern SECRET_PATH_URL = Pattern.compile("(?i)([\"']?\\burl\\b[\"']?\\s*[:=]\\s*)(.*)$");
|
||||
private static final Pattern BLOCK_SCALAR_INDICATOR = Pattern.compile("[|>](?:[+-][1-9]?|[1-9][+-]?)?");
|
||||
@@ -55,6 +68,8 @@ public final class BackendConfigurationService {
|
||||
private final Path dataDirectory;
|
||||
private final ApplyAction reload;
|
||||
private final MoveAction mover;
|
||||
private Object checkedStagingDirectory;
|
||||
private boolean privateStagingAvailable;
|
||||
|
||||
public BackendConfigurationService(Path dataDirectory, ReloadAction reload) {
|
||||
this(dataDirectory, ignored -> reload.run());
|
||||
@@ -70,7 +85,60 @@ public final class BackendConfigurationService {
|
||||
this.mover = mover;
|
||||
}
|
||||
|
||||
/** Whether this filesystem can provide the pinned, private writes required by named rewards. */
|
||||
synchronized boolean supportsNamedRewardFiles() {
|
||||
return supportsNamedRewardFiles(BackendConfigurationService::privateStagingSupported);
|
||||
}
|
||||
|
||||
synchronized boolean supportsNamedRewardFiles(DirectoryStagingSupport staging) {
|
||||
return supportsNamedRewardFiles(dataDirectory, channel -> channel.force(true), directory -> {
|
||||
Object key = rewardDirectoryKey(directory);
|
||||
if (!key.equals(checkedStagingDirectory) || !privateStagingAvailable) {
|
||||
try {
|
||||
privateStagingAvailable = staging.supports(directory);
|
||||
} catch (IOException | UnsupportedOperationException unavailable) {
|
||||
privateStagingAvailable = false;
|
||||
}
|
||||
checkedStagingDirectory = key;
|
||||
}
|
||||
return privateStagingAvailable;
|
||||
});
|
||||
}
|
||||
|
||||
static boolean supportsNamedRewardFiles(Path directory) {
|
||||
return supportsNamedRewardFiles(directory, channel -> channel.force(true));
|
||||
}
|
||||
|
||||
static boolean supportsNamedRewardFiles(Path directory, DirectoryForcer force) {
|
||||
return supportsNamedRewardFiles(directory, force, BackendConfigurationService::privateStagingSupported);
|
||||
}
|
||||
|
||||
static boolean supportsNamedRewardFiles(Path directory, DirectoryForcer force, DirectoryStagingSupport staging) {
|
||||
try {
|
||||
try (DirectoryStream<Path> entries = Files.newDirectoryStream(directory)) {
|
||||
if (!(entries instanceof java.nio.file.SecureDirectoryStream<?> secure)) return false;
|
||||
@SuppressWarnings("unchecked")
|
||||
java.nio.file.SecureDirectoryStream<Path> root =
|
||||
(java.nio.file.SecureDirectoryStream<Path>) secure;
|
||||
rewardDirectoryKey(root);
|
||||
java.nio.file.SecureDirectoryStream<Path> openedRewards;
|
||||
try {
|
||||
openedRewards = root.newDirectoryStream(Path.of("Rewards"), LinkOption.NOFOLLOW_LINKS);
|
||||
} catch (java.nio.file.NoSuchFileException absent) {
|
||||
return staging.supports(root) && pinnedDirectoryForceAvailable(root, force);
|
||||
}
|
||||
try (java.nio.file.SecureDirectoryStream<Path> rewards = openedRewards) {
|
||||
rewardDirectoryKey(rewards);
|
||||
return staging.supports(rewards) && pinnedDirectoryForceAvailable(rewards, force);
|
||||
}
|
||||
}
|
||||
} catch (IOException | SecurityException | UnsupportedOperationException unavailable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public Document read(String fileName) throws IOException {
|
||||
if (managedRewardFile(fileName)) return retryRead(() -> readNamedReward(fileName));
|
||||
Path path = resolve(fileName);
|
||||
return retryRead(() -> {
|
||||
String raw = readRaw(path, false);
|
||||
@@ -79,7 +147,117 @@ public final class BackendConfigurationService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Recovery checks the intended file and bounded case aliases, without validating unrelated inventory entries. */
|
||||
Document readForRecovery(String fileName, String expectedRevision) throws IOException {
|
||||
if (!managedRewardFile(fileName)) {
|
||||
Path path = resolve(fileName);
|
||||
return retryRead(() -> recoveryDocument(fileName, readRaw(path, false), expectedRevision));
|
||||
}
|
||||
return retryRead(() -> withRewardDirectory(rewards -> {
|
||||
String name = rewardFileNamePart(fileName);
|
||||
rejectRewardCaseAliases(rewards, name);
|
||||
return recoveryDocument(fileName, readNamedForRecovery(rewards, name), expectedRevision);
|
||||
}));
|
||||
}
|
||||
|
||||
private static String readNamedForRecovery(java.nio.file.SecureDirectoryStream<Path> rewards, String name)
|
||||
throws IOException {
|
||||
requireRecoverableRewardEntry(rewards, name);
|
||||
try {
|
||||
return readRaw(rewards, name, false);
|
||||
} catch (IOException failure) {
|
||||
// A target replaced between the attribute check and open can fail as an
|
||||
// ordinary I/O error. Reclassify only a provably missing/unsafe entry.
|
||||
requireRecoverableRewardEntry(rewards, name);
|
||||
if (failure.getCause() instanceof CharacterCodingException) {
|
||||
throw new UnconfirmableNamedRewardException();
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireRecoverableRewardEntry(java.nio.file.SecureDirectoryStream<Path> rewards,
|
||||
String name) throws IOException {
|
||||
BasicFileAttributeView view = rewards.getFileAttributeView(Path.of(name), BasicFileAttributeView.class,
|
||||
LinkOption.NOFOLLOW_LINKS);
|
||||
if (view == null) throw new IOException("named reward attributes are unavailable");
|
||||
BasicFileAttributes attributes;
|
||||
try {
|
||||
attributes = view.readAttributes();
|
||||
} catch (java.nio.file.NoSuchFileException missing) {
|
||||
throw new MissingNamedRewardException(missing);
|
||||
}
|
||||
if (!attributes.isRegularFile() || attributes.size() > MAX_CONTENT_BYTES) {
|
||||
throw new UnconfirmableNamedRewardException();
|
||||
}
|
||||
}
|
||||
|
||||
private static Document recoveryDocument(String fileName, String raw, String expectedRevision) {
|
||||
String actualRevision = revision(raw);
|
||||
if (!actualRevision.equals(expectedRevision)) return null;
|
||||
return new Document(fileName, mask(parse(raw)), actualRevision);
|
||||
}
|
||||
|
||||
private static void rejectRewardCaseAliases(java.nio.file.SecureDirectoryStream<Path> rewards, String name)
|
||||
throws IOException {
|
||||
int entries = 0;
|
||||
for (Path entry : rewards) {
|
||||
if (++entries > MAX_REWARD_DIRECTORY_ENTRIES) throw new UnconfirmableNamedRewardException();
|
||||
String candidate = entry.getFileName().toString();
|
||||
if (!candidate.equals(name) && candidate.equalsIgnoreCase(name)) {
|
||||
throw new UnconfirmableNamedRewardException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists only directly-contained, approved named reward files. This is not a
|
||||
* filesystem browser: unrecognised extensions and subdirectories are not
|
||||
* exposed, while unsafe links or ambiguous case-only names fail closed.
|
||||
*/
|
||||
static List<String> rewardFileInventory(Path dataDirectory) throws IOException {
|
||||
return rewardFileInventory(dataDirectory, BackendConfigurationService::rewardFileInventory);
|
||||
}
|
||||
|
||||
static List<String> rewardFileInventory(Path dataDirectory, RewardDirectoryAction<List<String>> scan)
|
||||
throws IOException {
|
||||
try {
|
||||
return withRewardDirectory(dataDirectory.toAbsolutePath().normalize(), scan);
|
||||
} catch (MissingRewardDirectoryException missing) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> rewardFileInventory(java.nio.file.SecureDirectoryStream<Path> rewards) throws IOException {
|
||||
List<String> files = new ArrayList<>();
|
||||
Set<String> logicalNames = new HashSet<>();
|
||||
int entries = 0;
|
||||
for (Path entry : rewards) {
|
||||
if (++entries > MAX_REWARD_DIRECTORY_ENTRIES) throw new IOException("reward directory exceeds the 1024 entry limit");
|
||||
String name = entry.getFileName().toString();
|
||||
java.nio.file.attribute.BasicFileAttributes attributes = rewards.getFileAttributeView(Path.of(name),
|
||||
java.nio.file.attribute.BasicFileAttributeView.class, LinkOption.NOFOLLOW_LINKS).readAttributes();
|
||||
if (attributes.isSymbolicLink()) throw new IOException("reward file inventory contains symbolic link");
|
||||
if (!attributes.isRegularFile()) continue;
|
||||
if (!rewardFileName(name)) continue;
|
||||
if (!logicalNames.add(name.toLowerCase(Locale.ROOT))) {
|
||||
throw new IOException("reward file inventory contains ambiguous names");
|
||||
}
|
||||
if (attributes.size() > MAX_CONTENT_BYTES) throw new IOException("reward file exceeds the 512 KiB limit");
|
||||
files.add(name);
|
||||
if (files.size() > MAX_REWARD_FILES) throw new IOException("reward file inventory exceeds the 100 file limit");
|
||||
}
|
||||
files.sort(String.CASE_INSENSITIVE_ORDER.thenComparing(java.util.Comparator.naturalOrder()));
|
||||
return List.copyOf(files);
|
||||
}
|
||||
|
||||
public Preview preview(String fileName, String proposedContent) throws IOException {
|
||||
if (managedRewardFile(fileName)) {
|
||||
return withRewardDirectory(rewards -> {
|
||||
validateNamedReward(rewards, rewardFileNamePart(fileName));
|
||||
return preview(fileName, proposedContent, readRaw(rewards, rewardFileNamePart(fileName), false));
|
||||
});
|
||||
}
|
||||
Path path = resolve(fileName);
|
||||
String current = readRaw(path, false);
|
||||
return preview(fileName, proposedContent, current);
|
||||
@@ -101,6 +279,9 @@ public final class BackendConfigurationService {
|
||||
|
||||
private ApplyResult apply(String fileName, String proposedContent, String expectedRevision,
|
||||
boolean restoreRedactedSecrets, ApplyAction applyAction) throws IOException {
|
||||
if (managedRewardFile(fileName)) {
|
||||
return applyNamedReward(fileName, proposedContent, expectedRevision, restoreRedactedSecrets, applyAction);
|
||||
}
|
||||
Path target = resolve(fileName);
|
||||
String current = readRaw(target, false);
|
||||
if (expectedRevision == null || !revision(current).equals(expectedRevision)) throw new StaleRevisionException();
|
||||
@@ -109,6 +290,7 @@ public final class BackendConfigurationService {
|
||||
Path staging = Files.createTempFile(target.getParent(), ".control-", ".yml");
|
||||
Path backupStaging = Files.createTempFile(target.getParent(), ".control-backup-", ".yml");
|
||||
boolean installed = false;
|
||||
boolean reloadAttempted = false;
|
||||
try {
|
||||
Files.writeString(staging, preview.resolvedContent(), StandardCharsets.UTF_8,
|
||||
StandardOpenOption.TRUNCATE_EXISTING);
|
||||
@@ -125,6 +307,7 @@ public final class BackendConfigurationService {
|
||||
installed = true;
|
||||
throw published;
|
||||
}
|
||||
reloadAttempted = true;
|
||||
applyAction.run(fileName);
|
||||
String applied = readRaw(target, false);
|
||||
String installedRevision = revision(preview.resolvedContent());
|
||||
@@ -157,13 +340,287 @@ public final class BackendConfigurationService {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
throw new ApplyFailureException(rolledBack, failure);
|
||||
throw new ApplyFailureException(rolledBack, reloadAttempted, failure);
|
||||
} finally {
|
||||
Files.deleteIfExists(staging);
|
||||
Files.deleteIfExists(backupStaging);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* A named reward lives below a directory an administrator can manipulate while
|
||||
* Control is running. Do not turn its validated Path back into a pathname: a
|
||||
* rename of Rewards after validation would otherwise make the subsequent read
|
||||
* or atomic move operate in an attacker-selected directory. SecureDirectoryStream
|
||||
* keeps the directory handle pinned for the complete operation.
|
||||
*/
|
||||
private Document readNamedReward(String fileName) throws IOException {
|
||||
return withRewardDirectory(rewards -> {
|
||||
validateNamedReward(rewards, rewardFileNamePart(fileName));
|
||||
String raw = readRaw(rewards, rewardFileNamePart(fileName), false);
|
||||
return new Document(fileName, mask(parse(raw)), revision(raw));
|
||||
});
|
||||
}
|
||||
|
||||
private synchronized ApplyResult applyNamedReward(String fileName, String proposedContent, String expectedRevision,
|
||||
boolean restoreRedactedSecrets, ApplyAction applyAction) throws IOException {
|
||||
return withRewardDirectory(rewards -> {
|
||||
Object directoryKey = rewardDirectoryKey(rewards);
|
||||
requireCurrentRewardDirectory(directoryKey);
|
||||
String name = rewardFileNamePart(fileName);
|
||||
validateNamedReward(rewards, name);
|
||||
String current = readRaw(rewards, name, false);
|
||||
if (expectedRevision == null || !revision(current).equals(expectedRevision)) throw new StaleRevisionException();
|
||||
Preview preview = preview(fileName, proposedContent, current, restoreRedactedSecrets);
|
||||
String staging = controlTemporaryName(".control-");
|
||||
String backupStaging = controlTemporaryName(".control-backup-");
|
||||
String rollbackStaging = null;
|
||||
String backup = name + ".control-backup";
|
||||
boolean installed = false;
|
||||
boolean reloadAttempted = false;
|
||||
try {
|
||||
writeRaw(rewards, staging, preview.resolvedContent(), true);
|
||||
rejectSymbolicBackup(rewards, backup);
|
||||
writeRaw(rewards, backupStaging, current, true);
|
||||
if (!revision(readRaw(rewards, name, false)).equals(expectedRevision)) throw new StaleRevisionException();
|
||||
rewards.move(Path.of(backupStaging), rewards, Path.of(backup));
|
||||
forcePinnedRewardDirectory(rewards, directoryKey);
|
||||
if (!revision(readRaw(rewards, name, false)).equals(expectedRevision)) throw new StaleRevisionException();
|
||||
rewards.move(Path.of(staging), rewards, Path.of(name));
|
||||
installed = true;
|
||||
forcePinnedRewardDirectory(rewards, directoryKey);
|
||||
reloadAttempted = true;
|
||||
applyAction.runNamedReward(fileName, preview.resolvedContent(),
|
||||
() -> requireCurrentRewardDirectory(directoryKey));
|
||||
requireCurrentRewardDirectory(directoryKey);
|
||||
String applied = readRaw(rewards, name, false);
|
||||
if (!revision(applied).equals(revision(preview.resolvedContent()))) {
|
||||
reconcileConcurrentEdit(rewards, name, fileName, directoryKey, applyAction);
|
||||
throw new StaleRevisionException();
|
||||
}
|
||||
return new ApplyResult(new Document(fileName, mask(parse(applied)), revision(applied)), preview.changes(), false);
|
||||
} catch (StaleRevisionException stale) {
|
||||
throw stale;
|
||||
} catch (Exception failure) {
|
||||
boolean rolledBack = false;
|
||||
if (installed) {
|
||||
try {
|
||||
if (!revision(readRaw(rewards, name, false)).equals(revision(preview.resolvedContent()))) {
|
||||
throw new IOException("Managed configuration changed while reload failed; backup was not restored");
|
||||
}
|
||||
rollbackStaging = controlTemporaryName(".control-rollback-");
|
||||
writeRaw(rewards, rollbackStaging, current, true);
|
||||
if (!revision(readRaw(rewards, name, false)).equals(revision(preview.resolvedContent()))) {
|
||||
throw new IOException("Managed configuration changed while rollback was staged");
|
||||
}
|
||||
rewards.move(Path.of(rollbackStaging), rewards, Path.of(name));
|
||||
forcePinnedRewardDirectory(rewards, directoryKey);
|
||||
reloadAttempted = true;
|
||||
applyAction.runNamedReward(fileName, current,
|
||||
() -> requireCurrentRewardDirectory(directoryKey));
|
||||
requireCurrentRewardDirectory(directoryKey);
|
||||
rolledBack = true;
|
||||
} catch (Exception rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
throw new ApplyFailureException(rolledBack, reloadAttempted, failure);
|
||||
} finally {
|
||||
deleteIfPresent(rewards, staging);
|
||||
deleteIfPresent(rewards, backupStaging);
|
||||
if (rollbackStaging != null) deleteIfPresent(rewards, rollbackStaging);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Object rewardDirectoryKey(java.nio.file.SecureDirectoryStream<Path> rewards) throws IOException {
|
||||
Object key = rewards.getFileAttributeView(BasicFileAttributeView.class).readAttributes().fileKey();
|
||||
if (key == null) throw new IOException("named reward directory identity is unavailable");
|
||||
return key;
|
||||
}
|
||||
|
||||
private static boolean pinnedDirectoryForceAvailable(java.nio.file.SecureDirectoryStream<Path> directory,
|
||||
DirectoryForcer force)
|
||||
throws IOException {
|
||||
try (SeekableByteChannel channel = directory.newByteChannel(Path.of("."),
|
||||
Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) {
|
||||
if (!(channel instanceof FileChannel fileChannel)) return false;
|
||||
force.force(fileChannel);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean privateStagingSupported(java.nio.file.SecureDirectoryStream<Path> directory)
|
||||
throws IOException {
|
||||
PosixFileAttributeView view = directory.getFileAttributeView(PosixFileAttributeView.class);
|
||||
if (view == null) return false;
|
||||
view.readAttributes();
|
||||
// The view alone does not prove that this provider permits the exact
|
||||
// descriptor-relative, private create and force path used by APPLY.
|
||||
Path probe = Path.of(".control-capability-" + UUID.randomUUID() + ".tmp");
|
||||
boolean created = false;
|
||||
try {
|
||||
try (SeekableByteChannel channel = directory.newByteChannel(probe,
|
||||
Set.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS),
|
||||
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")))) {
|
||||
created = true;
|
||||
if (!(channel instanceof FileChannel fileChannel)) return false;
|
||||
fileChannel.force(true);
|
||||
}
|
||||
PosixFileAttributeView staged = directory.getFileAttributeView(probe,
|
||||
PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS);
|
||||
return staged != null && staged.readAttributes().permissions().equals(
|
||||
PosixFilePermissions.fromString("rw-------"));
|
||||
} finally {
|
||||
if (created) directory.deleteFile(probe);
|
||||
}
|
||||
}
|
||||
|
||||
private void forcePinnedRewardDirectory(java.nio.file.SecureDirectoryStream<Path> rewards, Object directoryKey)
|
||||
throws IOException {
|
||||
requireCurrentRewardDirectory(directoryKey);
|
||||
try (SeekableByteChannel channel = rewards.newByteChannel(Path.of("."),
|
||||
Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) {
|
||||
if (!(channel instanceof FileChannel fileChannel)) {
|
||||
throw new IOException("durable named reward directory publication is unavailable");
|
||||
}
|
||||
fileChannel.force(true);
|
||||
}
|
||||
requireCurrentRewardDirectory(directoryKey);
|
||||
}
|
||||
|
||||
private void requireCurrentRewardDirectory(Object pinnedKey) throws IOException {
|
||||
BasicFileAttributes current = Files.readAttributes(dataDirectory.resolve("Rewards"),
|
||||
BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
|
||||
if (!current.isDirectory() || !pinnedKey.equals(current.fileKey())) {
|
||||
throw new IOException("named reward directory changed during apply");
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T withRewardDirectory(RewardDirectoryAction<T> action) throws IOException {
|
||||
return withRewardDirectory(dataDirectory, action);
|
||||
}
|
||||
|
||||
private static <T> T withRewardDirectory(Path directory, RewardDirectoryAction<T> action) throws IOException {
|
||||
try (DirectoryStream<Path> rootEntries = Files.newDirectoryStream(directory)) {
|
||||
if (!(rootEntries instanceof java.nio.file.SecureDirectoryStream<?> secureRoot)) {
|
||||
throw new IOException("secure named reward file access is unavailable");
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
java.nio.file.SecureDirectoryStream<Path> root =
|
||||
(java.nio.file.SecureDirectoryStream<Path>) secureRoot;
|
||||
java.nio.file.SecureDirectoryStream<Path> openedRewards;
|
||||
try {
|
||||
openedRewards = root.newDirectoryStream(Path.of("Rewards"), LinkOption.NOFOLLOW_LINKS);
|
||||
} catch (java.nio.file.NoSuchFileException missing) {
|
||||
throw new MissingRewardDirectoryException(missing);
|
||||
}
|
||||
try (java.nio.file.SecureDirectoryStream<Path> rewards = openedRewards) {
|
||||
return action.run(rewards);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateNamedReward(java.nio.file.SecureDirectoryStream<Path> directory, String name)
|
||||
throws IOException {
|
||||
List<String> names = rewardFileInventory(directory);
|
||||
if (!names.contains(name)) throw new MissingNamedRewardException();
|
||||
}
|
||||
|
||||
private static String rewardFileNamePart(String fileName) {
|
||||
return fileName.substring("Rewards/".length());
|
||||
}
|
||||
|
||||
private static String controlTemporaryName(String prefix) {
|
||||
return prefix + UUID.randomUUID() + ".yml";
|
||||
}
|
||||
|
||||
static String readRaw(java.nio.file.SecureDirectoryStream<Path> directory, String name,
|
||||
boolean allowMissing) throws IOException {
|
||||
try (SeekableByteChannel channel = directory.newByteChannel(Path.of(name),
|
||||
Set.of(StandardOpenOption.READ, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS))) {
|
||||
// A raced FIFO can block forever when opened read-only. Read/write opens it
|
||||
// without waiting for a writer on supported Unix providers; seeking then
|
||||
// rejects that non-regular handle before any body read. A read-only file
|
||||
// fails closed rather than risking a blocking open on the connector worker.
|
||||
channel.position(0);
|
||||
if (channel.size() > MAX_CONTENT_BYTES) throw new IOException("configuration file is missing or too large");
|
||||
byte[] bytes = Channels.newInputStream(channel).readNBytes(MAX_CONTENT_BYTES + 1);
|
||||
if (bytes.length > MAX_CONTENT_BYTES) throw new IOException("configuration file is missing or too large");
|
||||
try {
|
||||
return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT).decode(java.nio.ByteBuffer.wrap(bytes)).toString();
|
||||
} catch (CharacterCodingException e) {
|
||||
throw new IOException("configuration file is not valid UTF-8", e);
|
||||
}
|
||||
} catch (java.nio.file.NoSuchFileException missing) {
|
||||
if (allowMissing) return "";
|
||||
throw new MissingNamedRewardException(missing);
|
||||
}
|
||||
}
|
||||
|
||||
static boolean namedRewardCannotBeConfirmed(IOException failure) {
|
||||
return failure instanceof MissingRewardDirectoryException || failure instanceof MissingNamedRewardException
|
||||
|| failure instanceof UnconfirmableNamedRewardException;
|
||||
}
|
||||
|
||||
private static void writeRaw(java.nio.file.SecureDirectoryStream<Path> directory, String name, String content,
|
||||
boolean createNew) throws IOException {
|
||||
Set<java.nio.file.OpenOption> options = new HashSet<>();
|
||||
options.add(StandardOpenOption.WRITE);
|
||||
options.add(createNew ? StandardOpenOption.CREATE_NEW : StandardOpenOption.TRUNCATE_EXISTING);
|
||||
options.add(LinkOption.NOFOLLOW_LINKS);
|
||||
// Staging and backup files contain the unredacted document. Establish private
|
||||
// permissions atomically at creation rather than narrowing them afterward.
|
||||
FileAttribute<?>[] attributes = createNew
|
||||
? new FileAttribute<?>[] {PosixFilePermissions.asFileAttribute(
|
||||
PosixFilePermissions.fromString("rw-------"))}
|
||||
: new FileAttribute<?>[0];
|
||||
try (SeekableByteChannel channel = directory.newByteChannel(Path.of(name), options, attributes)) {
|
||||
java.nio.ByteBuffer bytes = StandardCharsets.UTF_8.encode(content);
|
||||
while (bytes.hasRemaining()) channel.write(bytes);
|
||||
if (!(channel instanceof FileChannel fileChannel)) {
|
||||
throw new IOException("durable named reward file write is unavailable");
|
||||
}
|
||||
fileChannel.force(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void rejectSymbolicBackup(java.nio.file.SecureDirectoryStream<Path> directory, String name)
|
||||
throws IOException {
|
||||
try {
|
||||
if (directory.getFileAttributeView(Path.of(name), java.nio.file.attribute.BasicFileAttributeView.class,
|
||||
LinkOption.NOFOLLOW_LINKS).readAttributes().isSymbolicLink()) {
|
||||
throw new IOException("Symbolic configuration backups are not allowed");
|
||||
}
|
||||
} catch (java.nio.file.NoSuchFileException ignored) {
|
||||
// A missing backup is expected on the first successful apply.
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteIfPresent(java.nio.file.SecureDirectoryStream<Path> directory, String name)
|
||||
throws IOException {
|
||||
try {
|
||||
directory.deleteFile(Path.of(name));
|
||||
} catch (java.nio.file.NoSuchFileException ignored) {
|
||||
// The entry was atomically published or did not get created.
|
||||
}
|
||||
}
|
||||
|
||||
private void reconcileConcurrentEdit(java.nio.file.SecureDirectoryStream<Path> directory, String name,
|
||||
String fileName, Object directoryKey, ApplyAction applyAction) throws Exception {
|
||||
for (int attempt = 0; attempt < 3; attempt++) {
|
||||
requireCurrentRewardDirectory(directoryKey);
|
||||
String snapshot = readRaw(directory, name, false);
|
||||
String snapshotRevision = revision(snapshot);
|
||||
applyAction.runNamedReward(fileName, snapshot,
|
||||
() -> requireCurrentRewardDirectory(directoryKey));
|
||||
requireCurrentRewardDirectory(directoryKey);
|
||||
if (revision(readRaw(directory, name, false)).equals(snapshotRevision)) return;
|
||||
}
|
||||
throw new StaleRevisionException();
|
||||
}
|
||||
|
||||
private void reconcileConcurrentEdit(String fileName, Path target, ApplyAction applyAction) throws Exception {
|
||||
for (int attempt = 0; attempt < 3; attempt++) {
|
||||
String snapshotRevision = revision(readRaw(target, false));
|
||||
@@ -227,8 +684,12 @@ public final class BackendConfigurationService {
|
||||
if (!READABLE_QUICK_SETUPS.contains(preset)) {
|
||||
throw new IllegalArgumentException("quick setup preset cannot be read");
|
||||
}
|
||||
rejectUnknownOptions(options, "vote-site".equals(preset) ? Set.of("name") : Set.of());
|
||||
rejectUnknownOptions(options, "vote-site".equals(preset) ? Set.of("name")
|
||||
: "vote-party".equals(preset) ? Set.of("enabled") : Set.of());
|
||||
if ("vote-site".equals(preset)) option(options, "name", "[A-Za-z0-9_-]{1,64}");
|
||||
if ("vote-party".equals(preset) && options != null && options.containsKey("enabled")) {
|
||||
booleanOption(options, "enabled");
|
||||
}
|
||||
return retryRead(() -> readQuickSetupOnce(preset, options));
|
||||
}
|
||||
|
||||
@@ -393,7 +854,7 @@ public final class BackendConfigurationService {
|
||||
case "vote-logging" -> Set.of("enabled", "purgeDays", "useMainMySQL");
|
||||
case "common-settings" -> Set.of("processRewards", "autoCreateVoteSites", "extraAllSitesCheck",
|
||||
"countFakeVotes", "disableNoServiceSiteMessage", "disableUpdateChecking");
|
||||
case "vote-party" -> Set.of("votesRequired", "broadcast", "giveAllPlayers", "onlineOnly", "command");
|
||||
case "vote-party" -> Set.of("enabled", "votesRequired", "broadcast", "giveAllPlayers", "onlineOnly", "command");
|
||||
case "sync-vote-sites" -> Set.of("sourceContent");
|
||||
default -> throw new IllegalArgumentException("quick setup preset is unsupported");
|
||||
});
|
||||
@@ -497,7 +958,11 @@ public final class BackendConfigurationService {
|
||||
return new QuickProposal(fileName, yaml.saveToString());
|
||||
}
|
||||
if ("vote-party".equals(preset)) {
|
||||
yaml.set("VoteParty.Enabled", true);
|
||||
// v1 does not carry Enabled, so it must leave the installed value untouched.
|
||||
// v2 supplies the field so its actual state can round-trip explicitly.
|
||||
if (options != null && options.containsKey("enabled")) {
|
||||
yaml.set("VoteParty.Enabled", booleanOption(options, "enabled"));
|
||||
}
|
||||
yaml.set("VoteParty.VotesRequired", boundedInteger(option(options, "votesRequired", "[0-9]{1,6}"), 1, 100000));
|
||||
yaml.set("VoteParty.GiveAllPlayers", booleanOption(options, "giveAllPlayers"));
|
||||
yaml.set("VoteParty.GiveOnlinePlayersOnly", booleanOption(options, "onlineOnly"));
|
||||
@@ -602,6 +1067,11 @@ public final class BackendConfigurationService {
|
||||
case SOCKETS:
|
||||
configuredHostAndPort(settings, "BungeeServer.Host", "BungeeServer.Port", 1297, "BungeeServer");
|
||||
break;
|
||||
case HTTP:
|
||||
String connectionCode = settings.getString("HTTP.ConnectionCode", "");
|
||||
try { HttpBackendProxyTransport.validateConfiguration(dataDirectory.resolve("http"), server, connectionCode); }
|
||||
catch (IllegalStateException invalid) { throw new IllegalArgumentException(invalid.getMessage(), invalid); }
|
||||
break;
|
||||
case MYSQL:
|
||||
try {
|
||||
YamlConfiguration main = parse(readRaw(resolve("Config.yml"), false));
|
||||
@@ -765,7 +1235,7 @@ public final class BackendConfigurationService {
|
||||
}
|
||||
|
||||
private static List<String> withoutRedactedComments(List<String> comments) {
|
||||
return comments.stream().filter(comment -> !comment.contains(REDACTED)).toList();
|
||||
return comments.stream().filter(comment -> comment == null || !comment.contains(REDACTED)).toList();
|
||||
}
|
||||
|
||||
private static String matchingKey(ConfigurationSection section, String expected, boolean ignoreCase) {
|
||||
@@ -804,18 +1274,43 @@ public final class BackendConfigurationService {
|
||||
return target;
|
||||
}
|
||||
|
||||
static boolean managedRewardFile(String fileName) {
|
||||
if (fileName == null || !fileName.startsWith("Rewards/")) return false;
|
||||
String name = fileName.substring("Rewards/".length());
|
||||
return rewardFileName(name);
|
||||
}
|
||||
|
||||
private static boolean rewardFileName(String name) {
|
||||
return name != null && name.length() <= MAX_REWARD_FILE_BASENAME_LENGTH + ".yml".length()
|
||||
&& REWARD_FILE_NAME.matcher(name).matches();
|
||||
}
|
||||
|
||||
private static String readRaw(Path path, boolean allowMissing) throws IOException {
|
||||
if (!Files.exists(path) && allowMissing) return "";
|
||||
if (!Files.isRegularFile(path, java.nio.file.LinkOption.NOFOLLOW_LINKS)
|
||||
|| Files.size(path) > MAX_CONTENT_BYTES) {
|
||||
throw new IOException("configuration file is missing or too large");
|
||||
if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (allowMissing) return "";
|
||||
throw new java.nio.file.NoSuchFileException(path.toString());
|
||||
}
|
||||
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new ConfigurationReadException(ConfigurationReadFailure.UNSAFE,
|
||||
"configuration file is not a regular file");
|
||||
}
|
||||
byte[] bytes;
|
||||
// Keep the no-follow check attached to the opened file. A separate Files.size/readAllBytes
|
||||
// would follow a link swapped in after the regular-file check.
|
||||
try (SeekableByteChannel channel = Files.newByteChannel(path,
|
||||
Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) {
|
||||
if (channel.size() > MAX_CONTENT_BYTES) throw new ConfigurationReadException(
|
||||
ConfigurationReadFailure.TOO_LARGE, "configuration file exceeds the managed size limit");
|
||||
bytes = Channels.newInputStream(channel).readNBytes(MAX_CONTENT_BYTES + 1);
|
||||
if (bytes.length > MAX_CONTENT_BYTES) throw new ConfigurationReadException(
|
||||
ConfigurationReadFailure.TOO_LARGE, "configuration file exceeds the managed size limit");
|
||||
}
|
||||
byte[] bytes = Files.readAllBytes(path);
|
||||
try {
|
||||
return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT).decode(java.nio.ByteBuffer.wrap(bytes)).toString();
|
||||
} catch (CharacterCodingException e) {
|
||||
throw new IOException("configuration file is not valid UTF-8", e);
|
||||
throw new ConfigurationReadException(ConfigurationReadFailure.INVALID_ENCODING,
|
||||
"configuration file is not valid UTF-8", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -872,10 +1367,17 @@ public final class BackendConfigurationService {
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> sanitizeComments(List<String> comments, List<String> secretValues, boolean secretPath) {
|
||||
/** Bukkit uses null comment entries to preserve blank lines. */
|
||||
static List<String> sanitizeComments(List<String> comments, List<String> secretValues, boolean secretPath) {
|
||||
if (comments == null || comments.isEmpty()) return List.of();
|
||||
List<String> sanitized = new ArrayList<>(comments.size());
|
||||
boolean redactContinuation = false;
|
||||
for (String original : comments) {
|
||||
if (original == null) {
|
||||
sanitized.add(null);
|
||||
redactContinuation = false;
|
||||
continue;
|
||||
}
|
||||
String comment = original;
|
||||
if (redactContinuation) {
|
||||
if (original.isBlank()) {
|
||||
@@ -920,6 +1422,7 @@ public final class BackendConfigurationService {
|
||||
|
||||
private static void restoreCommentSecrets(YamlConfiguration proposal, YamlConfiguration current,
|
||||
YamlConfiguration redactedCurrent) {
|
||||
validateRedactedCommentOwners(proposal, redactedCurrent);
|
||||
proposal.options().setHeader(restoreCommentSecrets(proposal.options().getHeader(),
|
||||
current.options().getHeader(), redactedCurrent.options().getHeader()));
|
||||
proposal.options().setFooter(restoreCommentSecrets(proposal.options().getFooter(),
|
||||
@@ -939,11 +1442,37 @@ public final class BackendConfigurationService {
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateRedactedCommentOwners(YamlConfiguration proposal, YamlConfiguration redactedCurrent) {
|
||||
Map<String, List<String>> expected = redactedCommentsByOwner(redactedCurrent);
|
||||
Map<String, List<String>> actual = redactedCommentsByOwner(proposal);
|
||||
if (!expected.equals(actual)) {
|
||||
throw new IllegalArgumentException("redacted comment placeholders must not be edited or moved");
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> redactedCommentsByOwner(YamlConfiguration yaml) {
|
||||
Map<String, List<String>> markers = new LinkedHashMap<>();
|
||||
addRedactedCommentOwner(markers, "header", yaml.options().getHeader());
|
||||
addRedactedCommentOwner(markers, "footer", yaml.options().getFooter());
|
||||
for (String path : yaml.getKeys(true)) {
|
||||
addRedactedCommentOwner(markers, "comments:" + path, yaml.getComments(path));
|
||||
addRedactedCommentOwner(markers, "inline-comments:" + path, yaml.getInlineComments(path));
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
private static void addRedactedCommentOwner(Map<String, List<String>> markers, String owner,
|
||||
List<String> comments) {
|
||||
List<String> ownerMarkers = comments.stream()
|
||||
.filter(comment -> comment != null && comment.contains(REDACTED)).toList();
|
||||
if (!ownerMarkers.isEmpty()) markers.put(owner, ownerMarkers);
|
||||
}
|
||||
|
||||
private static List<String> restoreCommentSecrets(List<String> proposed, List<String> current,
|
||||
List<String> redactedCurrent) {
|
||||
for (int index = 0; index < redactedCurrent.size(); index++) {
|
||||
String redacted = redactedCurrent.get(index);
|
||||
if (redacted.contains(REDACTED)
|
||||
if (redacted != null && redacted.contains(REDACTED)
|
||||
&& (index >= proposed.size() || !redacted.equals(proposed.get(index)))) {
|
||||
throw new IllegalArgumentException("redacted comment placeholders must not be edited or moved");
|
||||
}
|
||||
@@ -951,7 +1480,7 @@ public final class BackendConfigurationService {
|
||||
List<String> restored = new ArrayList<>(proposed.size());
|
||||
for (int index = 0; index < proposed.size(); index++) {
|
||||
String comment = proposed.get(index);
|
||||
if (!comment.contains(REDACTED)) {
|
||||
if (comment == null || !comment.contains(REDACTED)) {
|
||||
restored.add(comment);
|
||||
continue;
|
||||
}
|
||||
@@ -967,7 +1496,7 @@ public final class BackendConfigurationService {
|
||||
private static boolean secret(String path) {
|
||||
String key = path.substring(path.lastIndexOf('.') + 1).replace("-", "").replace("_", "")
|
||||
.toLowerCase(Locale.ROOT);
|
||||
return key.contains("password") || key.contains("secret") || key.equals("token")
|
||||
return key.contains("password") || key.contains("secret") || key.equals("token") || key.equals("connectioncode")
|
||||
|| key.equals("apikey") || key.equals("authorization") || key.equals("webhookurl") || key.equals("url")
|
||||
&& path.toLowerCase(Locale.ROOT).contains("webhook");
|
||||
}
|
||||
@@ -1099,14 +1628,70 @@ public final class BackendConfigurationService {
|
||||
public record QuickPreview(QuickProposal proposal, String revision, List<String> changes) { }
|
||||
public record QuickState(Map<String, String> options, String revision) { }
|
||||
@FunctionalInterface interface ReadAction<T> { T run() throws IOException; }
|
||||
@FunctionalInterface interface DirectoryForcer { void force(FileChannel channel) throws IOException; }
|
||||
@FunctionalInterface interface DirectoryStagingSupport {
|
||||
boolean supports(java.nio.file.SecureDirectoryStream<Path> directory) throws IOException;
|
||||
}
|
||||
@FunctionalInterface interface RewardDirectoryAction<T> {
|
||||
T run(java.nio.file.SecureDirectoryStream<Path> directory) throws IOException;
|
||||
}
|
||||
@SuppressWarnings("serial") private static final class MissingRewardDirectoryException extends IOException {
|
||||
private MissingRewardDirectoryException(java.nio.file.NoSuchFileException cause) {
|
||||
super("named reward directory is absent", cause);
|
||||
}
|
||||
}
|
||||
@SuppressWarnings("serial") private static final class MissingNamedRewardException extends IOException {
|
||||
private MissingNamedRewardException() { super("named reward file is unavailable"); }
|
||||
private MissingNamedRewardException(java.nio.file.NoSuchFileException cause) {
|
||||
super("named reward file is unavailable", cause);
|
||||
}
|
||||
}
|
||||
@SuppressWarnings("serial") private static final class UnconfirmableNamedRewardException extends IOException {
|
||||
private UnconfirmableNamedRewardException() {
|
||||
super("named reward identity cannot be confirmed during recovery");
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface public interface ReloadAction { void run() throws Exception; }
|
||||
@FunctionalInterface public interface ApplyAction { void run(String fileName) throws Exception; }
|
||||
@FunctionalInterface public interface NamedRewardGuard { void verify() throws IOException; }
|
||||
@FunctionalInterface public interface ApplyAction {
|
||||
void run(String fileName) throws Exception;
|
||||
|
||||
/** The expected bytes come from the pinned Rewards handle, never a reopened pathname. */
|
||||
default void runNamedReward(String fileName, String expectedContent) throws Exception {
|
||||
run(fileName);
|
||||
}
|
||||
|
||||
/** The connector checks the pinned directory inside the scheduled reload, not only before scheduling it. */
|
||||
default void runNamedReward(String fileName, String expectedContent, NamedRewardGuard guard) throws Exception {
|
||||
guard.verify();
|
||||
runNamedReward(fileName, expectedContent);
|
||||
}
|
||||
}
|
||||
@FunctionalInterface interface MoveAction { void move(Path source, Path target) throws IOException; }
|
||||
public enum ConfigurationReadFailure { UNSAFE, TOO_LARGE, INVALID_ENCODING }
|
||||
@SuppressWarnings("serial") public static final class ConfigurationReadException extends IOException {
|
||||
private final ConfigurationReadFailure failure;
|
||||
private ConfigurationReadException(ConfigurationReadFailure failure, String message) {
|
||||
super(message);
|
||||
this.failure = failure;
|
||||
}
|
||||
private ConfigurationReadException(ConfigurationReadFailure failure, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.failure = failure;
|
||||
}
|
||||
public ConfigurationReadFailure failure() { return failure; }
|
||||
}
|
||||
@SuppressWarnings("serial") public static final class StaleRevisionException extends RuntimeException { }
|
||||
@SuppressWarnings("serial") public static final class ApplyFailureException extends IOException {
|
||||
private final boolean rolledBack;
|
||||
private ApplyFailureException(boolean rolledBack, Throwable cause) { super(cause); this.rolledBack = rolledBack; }
|
||||
private final boolean reloadAttempted;
|
||||
private ApplyFailureException(boolean rolledBack, boolean reloadAttempted, Throwable cause) {
|
||||
super(cause);
|
||||
this.rolledBack = rolledBack;
|
||||
this.reloadAttempted = reloadAttempted;
|
||||
}
|
||||
public boolean rolledBack() { return rolledBack; }
|
||||
public boolean reloadAttempted() { return reloadAttempted; }
|
||||
}
|
||||
}
|
||||
+78
-16
@@ -2,6 +2,9 @@ package com.bencodez.votingplugin.control;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
@@ -32,20 +35,28 @@ public final class BackendControlAutoEnrollment implements AutoCloseable {
|
||||
private final String credentialFile;
|
||||
private final String endpoint;
|
||||
private final UUID requestId = UUID.randomUUID();
|
||||
private final BungeeMethod method;
|
||||
private final ControlEnrollmentAuthenticator authenticator;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
private volatile BukkitTask retryTask;
|
||||
private volatile ScheduledExecutorService retryExecutor;
|
||||
private volatile ScheduledFuture<?> retryFuture;
|
||||
private PendingAutoEnrollment enrollment;
|
||||
private boolean verifierInstalled;
|
||||
private boolean connectorAuthenticated;
|
||||
private long verifierAcknowledgedAt;
|
||||
private String routeChallenge = "";
|
||||
|
||||
private BackendControlAutoEnrollment(VotingPluginMain plugin, String nodeId, String credentialFile,
|
||||
String endpoint, PendingAutoEnrollment enrollment) {
|
||||
String endpoint, PendingAutoEnrollment enrollment, BungeeMethod method,
|
||||
ControlEnrollmentAuthenticator authenticator) {
|
||||
this.plugin = plugin;
|
||||
this.nodeId = nodeId;
|
||||
this.credentialFile = credentialFile;
|
||||
this.endpoint = endpoint;
|
||||
this.enrollment = enrollment;
|
||||
this.method = method;
|
||||
this.authenticator = authenticator;
|
||||
}
|
||||
|
||||
/** Prepares a verifier that this Bukkit process's own hosted Control will install. */
|
||||
@@ -59,14 +70,13 @@ public final class BackendControlAutoEnrollment implements AutoCloseable {
|
||||
return prepare(plugin, control);
|
||||
}
|
||||
|
||||
/** Prepares proxy-mediated enrollment only when source-bound plugin messaging is active. */
|
||||
/** Prepares proxy-mediated enrollment through the configured backend transport. */
|
||||
public static BackendControlAutoEnrollment create(VotingPluginMain plugin, HostConfiguration hosted)
|
||||
throws IOException {
|
||||
ConfigurationSection control = control(plugin);
|
||||
if (control == null || !control.getBoolean("Enabled", false)
|
||||
|| HostedControlManager.isDirectLocalEndpoint(control.getString("Endpoint", ""), hosted)
|
||||
|| !plugin.getBungeeSettings().isUseBungeecoord()
|
||||
|| BungeeMethod.getByName(plugin.getBungeeSettings().getBungeeMethod()) != BungeeMethod.PLUGINMESSAGING) {
|
||||
|| !plugin.getBungeeSettings().isUseBungeecoord()) {
|
||||
return null;
|
||||
}
|
||||
String serverName = plugin.getOptions().getServer();
|
||||
@@ -81,8 +91,11 @@ public final class BackendControlAutoEnrollment implements AutoCloseable {
|
||||
PendingAutoEnrollment pending = inspection.pending();
|
||||
if (pending == null && inspection.credentialPresent()) return null;
|
||||
if (pending != null && !nodeId.equals(pending.nodeId())) pending = null;
|
||||
BungeeMethod method = BungeeMethod.getByName(plugin.getBungeeSettings().getBungeeMethod());
|
||||
ControlEnrollmentAuthenticator authenticator = method == BungeeMethod.PLUGINMESSAGING || method == BungeeMethod.HTTP
|
||||
? null : ControlEnrollmentAuthenticator.load(plugin.getDataFolder().toPath().resolve("secretkey.key"));
|
||||
return new BackendControlAutoEnrollment(plugin, nodeId, credentialFile,
|
||||
endpoint == null ? "" : endpoint.trim(), pending);
|
||||
endpoint == null ? "" : endpoint.trim(), pending, method, authenticator);
|
||||
}
|
||||
|
||||
public static String configuredNodeId(VotingPluginMain plugin, ConfigurationSection control) {
|
||||
@@ -110,45 +123,88 @@ public final class BackendControlAutoEnrollment implements AutoCloseable {
|
||||
}
|
||||
|
||||
public synchronized void start() {
|
||||
if (closed.get() || retryTask != null) return;
|
||||
if (closed.get() || retryTask != null || retryFuture != null) return;
|
||||
try {
|
||||
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::send, 1L, RETRY_TICKS);
|
||||
if (method == BungeeMethod.PLUGINMESSAGING) {
|
||||
retryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::sendSafely, 1L, RETRY_TICKS);
|
||||
} else {
|
||||
retryExecutor = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "VotingPlugin-ControlEnrollment");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
retryFuture = retryExecutor.scheduleWithFixedDelay(this::sendSafely, 0L, 15L, TimeUnit.SECONDS);
|
||||
}
|
||||
} catch (RuntimeException failure) {
|
||||
ScheduledExecutorService executor = retryExecutor;
|
||||
if (executor != null) executor.shutdownNow();
|
||||
retryExecutor = null;
|
||||
retryFuture = null;
|
||||
closed.set(true);
|
||||
plugin.getLogger().warning("[Control] Automatic backend enrollment could not be scheduled");
|
||||
}
|
||||
}
|
||||
|
||||
private void sendSafely() {
|
||||
try {
|
||||
send();
|
||||
} catch (RuntimeException failure) {
|
||||
plugin.getLogger().warning("[Control] Automatic backend enrollment send failed; it will retry");
|
||||
plugin.debug(failure);
|
||||
}
|
||||
}
|
||||
|
||||
/** True while the proxy must confirm that this route reaches its hosted Control. */
|
||||
public synchronized boolean isAwaitingCredential() {
|
||||
return enrollment == null;
|
||||
}
|
||||
|
||||
private void send() {
|
||||
void send() {
|
||||
PendingAutoEnrollment pending;
|
||||
String challenge;
|
||||
synchronized (this) {
|
||||
if (closed.get() || (verifierInstalled
|
||||
&& System.nanoTime() - verifierAcknowledgedAt < VERIFIER_REFRESH_NANOS)) return;
|
||||
pending = enrollment;
|
||||
challenge = routeChallenge;
|
||||
}
|
||||
BackendProxyHandler handler = plugin.getBackendProxyHandler();
|
||||
if (handler == null || handler.getMethod() != BungeeMethod.PLUGINMESSAGING
|
||||
|| handler.getGlobalMessageHandler() == null) return;
|
||||
if (handler == null || handler.getGlobalMessageHandler() == null) return;
|
||||
String verifier = pending == null ? "" : pending.verifier();
|
||||
String proof = authenticator == null ? ""
|
||||
: authenticator.signRequest(nodeId, requestId, endpoint, verifier, challenge);
|
||||
if (closed.get()) return;
|
||||
handler.getGlobalMessageHandler().sendMessage(VotingPluginWire.controlEnrollmentRequest(
|
||||
nodeId, pending == null ? "" : pending.verifier(), endpoint, requestId));
|
||||
nodeId, verifier, endpoint, requestId, challenge, proof));
|
||||
}
|
||||
|
||||
public void handle(JsonEnvelope envelope) {
|
||||
VotingPluginWire.ControlEnrollmentResult result = VotingPluginWire.readControlEnrollmentResult(envelope);
|
||||
boolean restartConnector = false;
|
||||
if (!result.valid || (authenticator != null && !authenticator.verifiesResult(result.authenticator,
|
||||
result.nodeId, result.requestId, result.success, result.challenge))) return;
|
||||
boolean ensureConnector = false;
|
||||
synchronized (this) {
|
||||
if (closed.get() || !result.valid || !result.success || !requestId.equals(result.requestId)
|
||||
if (closed.get() || !requestId.equals(result.requestId)
|
||||
|| !nodeId.equals(result.nodeId)) return;
|
||||
if (enrollment == null) {
|
||||
if (!result.challenge.isEmpty()) {
|
||||
routeChallenge = result.challenge;
|
||||
if (enrollment == null) {
|
||||
try {
|
||||
enrollment = prepare(plugin, credentialFile, nodeId);
|
||||
if (enrollment == null) close();
|
||||
} catch (IOException e) {
|
||||
plugin.getLogger().warning(
|
||||
"[Control] Automatic backend credential could not be prepared; it will retry");
|
||||
}
|
||||
}
|
||||
ensureConnector = enrollment != null;
|
||||
} else if (!result.success) {
|
||||
return;
|
||||
} else if (enrollment == null) {
|
||||
try {
|
||||
enrollment = prepare(plugin, credentialFile, nodeId);
|
||||
if (enrollment == null) close();
|
||||
restartConnector = true;
|
||||
ensureConnector = enrollment != null;
|
||||
} catch (IOException e) {
|
||||
plugin.getLogger().warning(
|
||||
"[Control] Automatic backend credential could not be prepared; it will retry");
|
||||
@@ -159,7 +215,7 @@ public final class BackendControlAutoEnrollment implements AutoCloseable {
|
||||
completeIfConnected();
|
||||
}
|
||||
}
|
||||
if (restartConnector) plugin.restartBackendControlConnector();
|
||||
if (ensureConnector) plugin.startBackendControlConnectorForEnrollment(this);
|
||||
}
|
||||
|
||||
/** Completes durable enrollment only after this credential authenticated to the configured endpoint. */
|
||||
@@ -193,5 +249,11 @@ public final class BackendControlAutoEnrollment implements AutoCloseable {
|
||||
BukkitTask task = retryTask;
|
||||
if (task != null) task.cancel();
|
||||
retryTask = null;
|
||||
ScheduledFuture<?> future = retryFuture;
|
||||
if (future != null) future.cancel(true);
|
||||
retryFuture = null;
|
||||
ScheduledExecutorService executor = retryExecutor;
|
||||
if (executor != null) executor.shutdownNow();
|
||||
retryExecutor = null;
|
||||
}
|
||||
}
|
||||
+496
-42
@@ -5,6 +5,7 @@ import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
@@ -15,6 +16,7 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
@@ -22,15 +24,20 @@ import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.logging.Level;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
import com.bencodez.votingplugin.VotingPluginMain;
|
||||
import com.bencodez.votingplugin.control.BackendControlResultStore.Route;
|
||||
import com.bencodez.votingplugin.control.BackendControlResultStore.StoredResult;
|
||||
import com.bencodez.votingplugin.proxy.control.HostedControlManager;
|
||||
import com.bencodez.votingplugin.proxy.control.HostedControlManager.HostConfiguration;
|
||||
import com.bencodez.votingplugin.util.BoundedHttpBodyHandler;
|
||||
import com.bencodez.votingplugin.util.ControlCredentialFile;
|
||||
@@ -48,7 +55,11 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
private static final long INSPECTION_SHUTDOWN_TIMEOUT_SECONDS = 5;
|
||||
private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}");
|
||||
private static final Set<String> CAPABILITIES = Set.of("config.files.v1", "config.file-comments.v1",
|
||||
"config.quick-setup.v1", "config.vote-sites-sync.v1", "config.proxy-method.v1", "data.inspect.v1");
|
||||
"config.quick-setup.v1", "config.quick-setup.v2", "config.vote-sites-sync.v1",
|
||||
"config.proxy-method.v1", "config.proxy-method.v2", "config.reward-files.v1", "data.inspect.v1",
|
||||
"data.network-health.v1");
|
||||
private static final String NETWORK_HEALTH_CAPABILITY = "data.network-health.v1";
|
||||
private static final Set<String> DEPLOYMENT_TASK_FIELDS = Set.of("deploymentId", "artifactId", "sha256", "size", "attemptId");
|
||||
|
||||
private final VotingPluginMain plugin;
|
||||
private final Path dataDirectory;
|
||||
@@ -61,25 +72,35 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
private final HttpClient http;
|
||||
private final BackendConfigurationService configurations;
|
||||
private final ControlInspectionService inspections;
|
||||
private final PluginDeploymentService deployments;
|
||||
private final boolean directLocalDeploymentEndpoint;
|
||||
private final boolean allowInsecureHttpPluginDeployment;
|
||||
private final UUID sessionId = UUID.randomUUID();
|
||||
private final Map<UUID, StoredResult> completed = new LinkedHashMap<>();
|
||||
private final boolean recovering;
|
||||
private final AtomicBoolean running = new AtomicBoolean();
|
||||
private final AtomicBoolean inspecting = new AtomicBoolean();
|
||||
private final Object operationLifecycle = new Object();
|
||||
private final AtomicReference<PendingBackendProxyRollback> pendingBackendProxyRollback = new AtomicReference<>();
|
||||
private final Object journalLifecycle = new Object();
|
||||
private volatile boolean closed;
|
||||
private volatile boolean registered;
|
||||
private volatile boolean operationsAccepted;
|
||||
private volatile boolean quickSetupsAccepted;
|
||||
private volatile boolean proxyMethodV2Accepted;
|
||||
private volatile boolean votePartySetupsAccepted;
|
||||
private volatile boolean voteSitesSyncAccepted;
|
||||
private volatile boolean networkHealthAccepted;
|
||||
private volatile boolean rewardFilesAccepted;
|
||||
private volatile boolean inspectionsAccepted;
|
||||
private volatile boolean deploymentsAccepted;
|
||||
private volatile int inspectionFailures;
|
||||
private volatile long inspectionRetryAtNanos;
|
||||
private volatile int failures;
|
||||
private volatile ScheduledFuture<?> scheduled;
|
||||
private volatile ScheduledFuture<?> operationPolling;
|
||||
private volatile ScheduledFuture<?> inspectionPolling;
|
||||
private volatile ScheduledFuture<?> deploymentPolling;
|
||||
private volatile Future<?> activeReload;
|
||||
private volatile CompletableFuture<Void> activeOperation;
|
||||
|
||||
@@ -106,35 +127,269 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
inspectionExecutor = Executors.newSingleThreadScheduledExecutor(inspectionFactory);
|
||||
http = HttpClient.newBuilder().connectTimeout(Duration.ofMillis(settings.connectTimeoutMillis()))
|
||||
.followRedirects(HttpClient.Redirect.NEVER).build();
|
||||
configurations = new BackendConfigurationService(plugin.getDataFolder().toPath(), this::reloadConfiguration);
|
||||
configurations = new BackendConfigurationService(plugin.getDataFolder().toPath(),
|
||||
new BackendConfigurationService.ApplyAction() {
|
||||
@Override public void run(String fileName) throws Exception {
|
||||
reloadConfiguration(fileName, null);
|
||||
}
|
||||
|
||||
@Override public void runNamedReward(String fileName, String expectedContent) throws Exception {
|
||||
reloadConfiguration(fileName, expectedContent);
|
||||
}
|
||||
|
||||
@Override public void runNamedReward(String fileName, String expectedContent,
|
||||
BackendConfigurationService.NamedRewardGuard guard) throws Exception {
|
||||
reloadConfiguration(fileName, expectedContent, guard);
|
||||
}
|
||||
});
|
||||
inspections = new ControlInspectionService(plugin);
|
||||
directLocalDeploymentEndpoint = HostedControlManager.isDirectLocalEndpoint(
|
||||
settings.endpoint().toString(), hostedConfiguration);
|
||||
PluginDeploymentService prepared = null;
|
||||
allowInsecureHttpPluginDeployment = plugin.getConfigFile().getControlAllowInsecureHttpPluginDeployment();
|
||||
PluginDeploymentService.DeploymentEndpointPolicy deploymentPolicy = PluginDeploymentService.deploymentEndpointPolicy(
|
||||
settings.endpoint(), directLocalDeploymentEndpoint, allowInsecureHttpPluginDeployment);
|
||||
boolean deploymentEndpointAllowed = deploymentPolicy.allowed();
|
||||
if (!recovering && deploymentPolicy.initializationMessage() != null) {
|
||||
plugin.getLogger().warning("[Control] " + deploymentPolicy.initializationMessage());
|
||||
}
|
||||
if (!recovering && deploymentEndpointAllowed) {
|
||||
try {
|
||||
prepared = PluginDeploymentService.backend(plugin.getServer().getUpdateFolderFile().toPath(),
|
||||
plugin.getLoadedPluginJarFile().toPath());
|
||||
} catch (Exception failure) {
|
||||
plugin.getLogger().warning("[Control] Plugin deployment staging is unavailable; capability not advertised");
|
||||
}
|
||||
}
|
||||
deployments = prepared;
|
||||
}
|
||||
|
||||
private void reloadConfiguration(String fileName, String expectedContent) throws Exception {
|
||||
reloadConfiguration(fileName, expectedContent, null, false);
|
||||
}
|
||||
|
||||
private void reloadConfiguration(String fileName, String expectedContent,
|
||||
BackendConfigurationService.NamedRewardGuard guard) throws Exception {
|
||||
reloadConfiguration(fileName, expectedContent, guard, false);
|
||||
}
|
||||
|
||||
/** Reuses the split restart lifecycle while retaining proxy-method's narrow reload scope. */
|
||||
private void reloadConfiguration(String fileName, String expectedContent,
|
||||
BackendConfigurationService.NamedRewardGuard guard, boolean proxyMethodOnly) throws Exception {
|
||||
finishPendingBackendProxyRollback();
|
||||
long validationDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(29);
|
||||
AtomicBoolean preparationAbandoned = new AtomicBoolean();
|
||||
AtomicInteger preparationState = new AtomicInteger();
|
||||
CountDownLatch preparationSettled = new CountDownLatch(1);
|
||||
CountDownLatch rollbackAbortSettled = new CountDownLatch(1);
|
||||
AtomicReference<VotingPluginMain.BackendProxyRestart> preparedRestart = new AtomicReference<>();
|
||||
Future<VotingPluginMain.BackendProxyRestart> preparation;
|
||||
synchronized (operationLifecycle) {
|
||||
if (closed) throw new IllegalStateException("Bukkit Control connector is stopping");
|
||||
preparation = plugin.getServer().getScheduler().callSyncMethod(plugin, () -> {
|
||||
try {
|
||||
if (!preparationState.compareAndSet(0, 1)) return null;
|
||||
if (guard != null) guard.verify();
|
||||
if (!proxyMethodOnly) plugin.reloadFromControl();
|
||||
if (guard != null) guard.verify();
|
||||
if (BackendConfigurationService.managedRewardFile(fileName)) {
|
||||
verifyNamedRewardLoaded(fileName, expectedContent);
|
||||
}
|
||||
VotingPluginMain.BackendProxyRestart prepared;
|
||||
try {
|
||||
if (!"BungeeSettings.yml".equals(fileName)) prepared = null;
|
||||
else if (proxyMethodOnly) prepared = plugin.prepareBackendProxyMethodRestartFromControl();
|
||||
else prepared = plugin.prepareBackendProxyHandlerRestart();
|
||||
} catch (VotingPluginMain.BackendProxyRestartPreparationException failure) {
|
||||
preparedRestart.set(failure.restart());
|
||||
throw failure;
|
||||
}
|
||||
preparedRestart.set(prepared);
|
||||
return prepared;
|
||||
} finally {
|
||||
try {
|
||||
VotingPluginMain.BackendProxyRestart prepared = preparedRestart.get();
|
||||
if (preparationAbandoned.get()) {
|
||||
// The Control worker performs transport restoration after this
|
||||
// Bukkit callback has marked the staged handler unavailable.
|
||||
if (prepared != null && prepared.requiresWorkerRollback()) {
|
||||
plugin.requestBackendProxyHandlerRestartAbandonment(prepared);
|
||||
} else if (prepared != null) {
|
||||
try {
|
||||
plugin.abortBackendProxyHandlerRestart(prepared);
|
||||
} finally { rollbackAbortSettled.countDown(); }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
preparationState.set(2);
|
||||
preparationSettled.countDown();
|
||||
}
|
||||
}
|
||||
});
|
||||
activeReload = preparation;
|
||||
}
|
||||
VotingPluginMain.BackendProxyRestart restart = null;
|
||||
Future<?> publication = null;
|
||||
try {
|
||||
restart = preparation.get(remaining(validationDeadline), TimeUnit.NANOSECONDS);
|
||||
if (restart == null) return;
|
||||
// Network enrollment/readiness is deliberately awaited on this Control worker,
|
||||
// never on Bukkit's primary thread.
|
||||
plugin.validateBackendProxyHandlerRestart(restart, validationDeadline);
|
||||
VotingPluginMain.BackendProxyRestart prepared = restart;
|
||||
publication = plugin.getServer().getScheduler().callSyncMethod(plugin, () -> {
|
||||
plugin.completeBackendProxyHandlerRestart(prepared);
|
||||
return null;
|
||||
});
|
||||
synchronized (operationLifecycle) {
|
||||
if (closed) publication.cancel(true);
|
||||
activeReload = publication;
|
||||
}
|
||||
publication.get(remaining(validationDeadline), TimeUnit.NANOSECONDS);
|
||||
} catch (Exception failure) {
|
||||
// Timed-out Bukkit work must not remain queued ahead of configuration rollback.
|
||||
preparationAbandoned.set(true);
|
||||
boolean abandonedBeforePreparation = preparationState.compareAndSet(0, 3);
|
||||
preparation.cancel(false);
|
||||
boolean publicationCommitted = publication != null && restart != null
|
||||
&& !plugin.requestBackendProxyHandlerRestartAbandonment(restart);
|
||||
if (publication != null) publication.cancel(false);
|
||||
// A publication that won the synchronized commit race is the successful
|
||||
// runtime state; rolling its YAML back would create a split-brain config.
|
||||
if (publicationCommitted) return;
|
||||
PendingBackendProxyRollback pendingRollback = null;
|
||||
if (restart == null && !abandonedBeforePreparation) {
|
||||
pendingRollback = new PendingBackendProxyRollback(preparationSettled, rollbackAbortSettled, preparedRestart);
|
||||
pendingBackendProxyRollback.set(pendingRollback);
|
||||
try {
|
||||
if (!preparationSettled.await(40, TimeUnit.SECONDS))
|
||||
throw new java.util.concurrent.TimeoutException(
|
||||
"Bukkit configuration preparation did not settle during rollback");
|
||||
} catch (Exception preparationFailure) {
|
||||
failure.addSuppressed(preparationFailure);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
if (restart == null) restart = preparedRestart.get();
|
||||
if (restart != null) {
|
||||
if (pendingRollback == null) {
|
||||
pendingRollback = new PendingBackendProxyRollback(
|
||||
preparationSettled, rollbackAbortSettled, preparedRestart);
|
||||
pendingBackendProxyRollback.set(pendingRollback);
|
||||
}
|
||||
VotingPluginMain.BackendProxyRestart prepared = restart;
|
||||
try {
|
||||
if (prepared.requiresWorkerRollback()) {
|
||||
// This worker owns the exclusive socket/MQTT teardown and restoration.
|
||||
// Bukkit state was already fenced by requestBackendProxyHandlerRestartAbandonment.
|
||||
plugin.abortBackendProxyHandlerRestart(prepared);
|
||||
rollbackAbortSettled.countDown();
|
||||
} else {
|
||||
Future<?> abort = plugin.getServer().getScheduler().callSyncMethod(plugin, () -> {
|
||||
try { plugin.abortBackendProxyHandlerRestart(prepared); }
|
||||
finally { rollbackAbortSettled.countDown(); }
|
||||
return null;
|
||||
});
|
||||
abort.get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
finishPendingBackendProxyRollback();
|
||||
} catch (Exception cleanupFailure) {
|
||||
rollbackAbortSettled.countDown();
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
if (pendingRollback != null && restart == null) {
|
||||
rollbackAbortSettled.countDown();
|
||||
pendingBackendProxyRollback.compareAndSet(pendingRollback, null);
|
||||
}
|
||||
throw failure;
|
||||
} finally {
|
||||
synchronized (operationLifecycle) {
|
||||
if (activeReload == preparation || activeReload == publication) activeReload = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void reloadConfiguration(String fileName) throws Exception {
|
||||
reloadOnServerThread(() -> {
|
||||
plugin.reloadFromControl();
|
||||
if ("BungeeSettings.yml".equals(fileName)) plugin.restartBackendProxyHandler();
|
||||
});
|
||||
reloadConfiguration(fileName, null);
|
||||
}
|
||||
|
||||
private void verifyNamedRewardLoaded(String fileName, String expectedContent) {
|
||||
String name = fileName.substring("Rewards/".length(), fileName.length() - ".yml".length());
|
||||
// getReward(name) can synthesize an on-demand Reward even when reload rejected the file.
|
||||
if (!plugin.getRewardHandler().rewardExist(name)) {
|
||||
throw new IllegalStateException("Named reward did not load after configuration reload");
|
||||
}
|
||||
var reward = plugin.getRewardHandler().getReward(name);
|
||||
if (reward == null || reward.getFile() == null) {
|
||||
throw new IllegalStateException("Named reward did not load after configuration reload");
|
||||
}
|
||||
try {
|
||||
// Compare loaded values with the proposal read from the pinned directory handle;
|
||||
// reopening Rewards here could validate a substituted directory instead.
|
||||
if (expectedContent == null) throw new IllegalStateException("Named reward verification source is unavailable");
|
||||
Path expected = dataDirectory.resolve(fileName).toRealPath(LinkOption.NOFOLLOW_LINKS);
|
||||
Path actual = reward.getFile().toPath().toRealPath(LinkOption.NOFOLLOW_LINKS);
|
||||
if (!expected.equals(actual)) {
|
||||
throw new IllegalStateException("Named reward did not load after configuration reload");
|
||||
}
|
||||
YamlConfiguration source = new YamlConfiguration();
|
||||
source.loadFromString(expectedContent);
|
||||
if (!sameConfigurationValues(source, reward.getConfig().getFileData())) {
|
||||
throw new IllegalStateException("Named reward content did not become active after reload");
|
||||
}
|
||||
} catch (IOException | InvalidConfigurationException unavailable) {
|
||||
throw new IllegalStateException("Named reward could not be verified after configuration reload", unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean sameConfigurationValues(ConfigurationSection source, ConfigurationSection loaded) {
|
||||
if (loaded == null) return false;
|
||||
Set<String> paths = new java.util.HashSet<>(source.getKeys(true));
|
||||
paths.addAll(loaded.getKeys(true));
|
||||
for (String path : paths) {
|
||||
boolean sourceSection = source.isConfigurationSection(path);
|
||||
boolean loadedSection = loaded.isConfigurationSection(path);
|
||||
if (sourceSection != loadedSection) return false;
|
||||
if (!sourceSection && !java.util.Objects.equals(source.get(path), loaded.get(path))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void reloadProxyMethod(String ignored) throws Exception {
|
||||
reloadOnServerThread(plugin::reloadBackendProxyMethodFromControl);
|
||||
// Proxy-method APPLY can select HTTP or Redis. Reuse the BungeeSettings
|
||||
// split lifecycle so Bukkit only prepares/publishes while validation and
|
||||
// bounded transport handoff waits remain on this connector worker.
|
||||
reloadConfiguration("BungeeSettings.yml", null, null, true);
|
||||
}
|
||||
|
||||
private void reloadOnServerThread(Runnable action) throws Exception {
|
||||
Future<?> reload;
|
||||
synchronized (operationLifecycle) {
|
||||
if (closed) throw new IllegalStateException("Bukkit Control connector is stopping");
|
||||
reload = plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { action.run(); return null; });
|
||||
activeReload = reload;
|
||||
}
|
||||
try {
|
||||
reload.get(30, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
synchronized (operationLifecycle) {
|
||||
if (activeReload == reload) activeReload = null;
|
||||
}
|
||||
private void finishPendingBackendProxyRollback() throws Exception {
|
||||
PendingBackendProxyRollback pending = pendingBackendProxyRollback.get();
|
||||
if (pending == null) return;
|
||||
if (!pending.preparationSettled().await(40, TimeUnit.SECONDS))
|
||||
throw new java.util.concurrent.TimeoutException(
|
||||
"Previous Bukkit configuration preparation is still rolling back");
|
||||
VotingPluginMain.BackendProxyRestart restart = pending.preparedRestart().get();
|
||||
if (restart != null) {
|
||||
if (!pending.rollbackAbortSettled().await(40, TimeUnit.SECONDS))
|
||||
throw new java.util.concurrent.TimeoutException(
|
||||
"Previous Bukkit configuration abort is still pending");
|
||||
// Keep credential-journal I/O off Bukkit's primary thread and finish it
|
||||
// before the configuration service starts its automatic backup reload.
|
||||
plugin.awaitBackendProxyHandlerRollback(restart,
|
||||
System.nanoTime() + TimeUnit.SECONDS.toNanos(40));
|
||||
}
|
||||
pendingBackendProxyRollback.compareAndSet(pending, null);
|
||||
}
|
||||
|
||||
private record PendingBackendProxyRollback(CountDownLatch preparationSettled,
|
||||
CountDownLatch rollbackAbortSettled,
|
||||
AtomicReference<VotingPluginMain.BackendProxyRestart> preparedRestart) { }
|
||||
|
||||
private static long remaining(long deadlineNanos) throws java.util.concurrent.TimeoutException {
|
||||
long remaining = deadlineNanos - System.nanoTime();
|
||||
if (remaining <= 0L) throw new java.util.concurrent.TimeoutException("Bukkit configuration reload timed out");
|
||||
return remaining;
|
||||
}
|
||||
|
||||
public static BackendControlConnector create(VotingPluginMain plugin) throws IOException {
|
||||
@@ -190,6 +445,76 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS);
|
||||
inspectionPolling = inspectionExecutor.scheduleWithFixedDelay(this::pollInspections,
|
||||
OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS);
|
||||
if (deployments != null) {
|
||||
deploymentPolling = executor.scheduleWithFixedDelay(this::pollDeployments,
|
||||
OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
/** Deployment is a separate leased lane; its large I/O never runs on Bukkit's primary thread. */
|
||||
private void pollDeployments() {
|
||||
if (closed || deployments == null || !registered || failures != 0 || !deploymentsAccepted
|
||||
|| !running.compareAndSet(false, true)) return;
|
||||
CompletableFuture<Void> operation = new CompletableFuture<>();
|
||||
synchronized (operationLifecycle) {
|
||||
if (closed) {
|
||||
running.set(false);
|
||||
return;
|
||||
}
|
||||
activeOperation = operation;
|
||||
}
|
||||
try {
|
||||
claimAndDeploy();
|
||||
} catch (Exception failure) {
|
||||
registered = false;
|
||||
failures = Math.min(30, failures + 1);
|
||||
if (failures == 1 || failures % 10 == 0) {
|
||||
plugin.getLogger().warning("[Control] Bukkit deployment polling unavailable; VotingPlugin remains active");
|
||||
}
|
||||
ScheduledFuture<?> heartbeat = scheduled;
|
||||
if (heartbeat != null) heartbeat.cancel(false);
|
||||
if (!closed) schedule(Math.min(TimeUnit.MINUTES.toMillis(5),
|
||||
1000L << Math.min(failures - 1, 8)));
|
||||
} finally {
|
||||
operation.complete(null);
|
||||
synchronized (operationLifecycle) {
|
||||
if (activeOperation == operation) activeOperation = null;
|
||||
}
|
||||
running.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void claimAndDeploy() throws Exception {
|
||||
JsonObject body = new JsonObject();
|
||||
body.addProperty("sessionId", sessionId.toString());
|
||||
Response response = send("POST", "/api/v1/nodes/" + settings.nodeId() + "/deployments", body);
|
||||
if (response.status() == 204 || closed) return;
|
||||
JsonObject claimed = requireObject(response, 200);
|
||||
PluginDeploymentService.Task task = deploymentTask(claimed);
|
||||
PluginDeploymentService.Result result = deployments.deploy(task, settings.endpoint(), directLocalDeploymentEndpoint, allowInsecureHttpPluginDeployment,
|
||||
settings.nodeId(), sessionId, credential, http, Duration.ofMillis(settings.requestTimeoutMillis()),
|
||||
() -> !closed);
|
||||
if (closed) return;
|
||||
JsonObject submitted = new JsonObject();
|
||||
submitted.addProperty("sessionId", sessionId.toString());
|
||||
submitted.addProperty("success", result.success());
|
||||
submitted.addProperty("code", result.code());
|
||||
submitted.addProperty("message", boundedResultMessage(result.message()));
|
||||
submitted.addProperty("attemptId", task.attemptId().toString());
|
||||
requireObject(send("POST", "/api/v1/nodes/" + settings.nodeId() + "/deployments/" + task.deploymentId()
|
||||
+ "/result", submitted), 200);
|
||||
}
|
||||
|
||||
static PluginDeploymentService.Task deploymentTask(JsonObject task) {
|
||||
try {
|
||||
if (task == null || task.size() != DEPLOYMENT_TASK_FIELDS.size()
|
||||
|| !DEPLOYMENT_TASK_FIELDS.containsAll(task.keySet())) throw new IllegalArgumentException();
|
||||
return new PluginDeploymentService.Task(UUID.fromString(string(task, "deploymentId")),
|
||||
string(task, "artifactId"), string(task, "sha256"), Long.parseLong(string(task, "size")),
|
||||
UUID.fromString(string(task, "attemptId")));
|
||||
} catch (RuntimeException failure) {
|
||||
throw new IllegalArgumentException("deployment task is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
/** Polls the separately negotiated read-only lane on the connector worker. */
|
||||
@@ -235,8 +560,17 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
+ "/result", submitted), 200);
|
||||
}
|
||||
|
||||
private InspectionTaskResult executeInspection(JsonObject query) {
|
||||
InspectionTaskResult executeInspection(JsonObject query) {
|
||||
try {
|
||||
if (query != null && query.has("kind") && query.get("kind").isJsonPrimitive()
|
||||
&& "network-health".equals(query.get("kind").getAsString())
|
||||
&& !networkHealthAccepted) {
|
||||
return InspectionTaskResult.failure("UNAVAILABLE", "Network health was not negotiated");
|
||||
}
|
||||
if (ControlInspectionService.rewardFileInventoryQuery(query) && !rewardFilesAccepted) {
|
||||
return InspectionTaskResult.failure("UNAVAILABLE",
|
||||
"Named reward files were not negotiated");
|
||||
}
|
||||
return InspectionTaskResult.success(inspections.inspect(query));
|
||||
} catch (ControlInspectionService.ResultTooLargeException failure) {
|
||||
return InspectionTaskResult.failure("RESULT_TOO_LARGE", failure.getMessage());
|
||||
@@ -305,9 +639,16 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
}
|
||||
operationsAccepted = negotiatedCapability(node, "config.files.v1", operationsAccepted);
|
||||
quickSetupsAccepted = negotiatedCapability(node, "config.quick-setup.v1", quickSetupsAccepted);
|
||||
proxyMethodV2Accepted = negotiatedCapability(node, "config.proxy-method.v2", proxyMethodV2Accepted);
|
||||
votePartySetupsAccepted = negotiatedCapability(node, "config.quick-setup.v2", votePartySetupsAccepted);
|
||||
voteSitesSyncAccepted = negotiatedCapability(node, "config.vote-sites-sync.v1", voteSitesSyncAccepted);
|
||||
rewardFilesAccepted = configurations.supportsNamedRewardFiles()
|
||||
&& negotiatedCapability(node, "config.reward-files.v1", rewardFilesAccepted);
|
||||
networkHealthAccepted = negotiatedCapability(node, NETWORK_HEALTH_CAPABILITY, networkHealthAccepted);
|
||||
boolean inspectionsWereAccepted = inspectionsAccepted;
|
||||
inspectionsAccepted = negotiatedCapability(node, "data.inspect.v1", inspectionsAccepted);
|
||||
deploymentsAccepted = deployments != null
|
||||
&& negotiatedCapability(node, PluginDeploymentService.CAPABILITY, deploymentsAccepted);
|
||||
if (inspectionsAccepted && !inspectionsWereAccepted) {
|
||||
inspectionFailures = 0;
|
||||
inspectionRetryAtNanos = 0;
|
||||
@@ -356,8 +697,12 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
// A registration must explicitly establish required capabilities. Heartbeats may omit the unchanged set.
|
||||
operationsAccepted = false;
|
||||
quickSetupsAccepted = false;
|
||||
votePartySetupsAccepted = false;
|
||||
voteSitesSyncAccepted = false;
|
||||
rewardFilesAccepted = false;
|
||||
networkHealthAccepted = false;
|
||||
inspectionsAccepted = false;
|
||||
deploymentsAccepted = false;
|
||||
JsonObject body = sessionBody();
|
||||
body.addProperty("nodeId", settings.nodeId());
|
||||
body.addProperty("displayName", settings.nodeId());
|
||||
@@ -368,13 +713,13 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
.map(installed -> installed.getDescription().getName()).filter(name -> name != null && !name.isBlank())
|
||||
.distinct().sorted(String.CASE_INSENSITIVE_ORDER).limit(128).forEach(detectedPlugins::add);
|
||||
body.add("detectedPlugins", detectedPlugins);
|
||||
addCapabilities(body);
|
||||
addCapabilities(body, configurations.supportsNamedRewardFiles(), deployments != null);
|
||||
return requireObject(send("POST", "/api/v1/nodes/register", body), 200, 201);
|
||||
}
|
||||
|
||||
private JsonObject heartbeat() throws Exception {
|
||||
JsonObject body = sessionBody();
|
||||
addCapabilities(body);
|
||||
addCapabilities(body, configurations.supportsNamedRewardFiles(), deployments != null);
|
||||
Response response = send("PUT", "/api/v1/nodes/" + settings.nodeId() + "/heartbeat", body);
|
||||
if (response.status() == 404) {
|
||||
registered = false;
|
||||
@@ -573,8 +918,18 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
JsonObject configuration = result.getAsJsonObject("configuration");
|
||||
String domain = string(configuration, "domain");
|
||||
if ("file".equals(domain)) {
|
||||
BackendConfigurationService.Document installed = configurations.read(string(configuration, "fileName"));
|
||||
if (!revision.equals(installed.revision())) return null;
|
||||
String fileName = string(configuration, "fileName");
|
||||
BackendConfigurationService.Document installed;
|
||||
try {
|
||||
installed = configurations.readForRecovery(fileName, revision);
|
||||
} catch (IOException unavailable) {
|
||||
// A missing or case-ambiguous named reward cannot be confirmed after
|
||||
// restart. Abort only that intent; unrelated I/O remains retryable.
|
||||
if (BackendConfigurationService.managedRewardFile(fileName)
|
||||
&& BackendConfigurationService.namedRewardCannotBeConfirmed(unavailable)) return null;
|
||||
throw unavailable;
|
||||
}
|
||||
if (installed == null) return null;
|
||||
result = result.deepCopy();
|
||||
result.getAsJsonObject("configuration").addProperty("content", installed.content());
|
||||
result.addProperty("attemptId", attemptId);
|
||||
@@ -608,15 +963,15 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
String domain = string(configuration, "domain");
|
||||
if ("file".equals(domain)) return executeFile(operationId, type, configuration, task);
|
||||
if ("quick-setup".equals(domain)) {
|
||||
if (!quickSetupsAccepted) return TaskResult.failure("UNSUPPORTED_TASK", "Quick setups were not negotiated");
|
||||
return executeQuick(operationId, type, configuration, task);
|
||||
}
|
||||
return TaskResult.failure("UNSUPPORTED_TASK", "Configuration domain is unsupported");
|
||||
} catch (BackendConfigurationService.StaleRevisionException e) {
|
||||
return TaskResult.failure("STALE_REVISION", "Configuration changed after preview");
|
||||
} catch (BackendConfigurationService.ApplyFailureException e) {
|
||||
logConfigurationFailure("reload", e);
|
||||
return TaskResult.failure("RELOAD_FAILED", reloadFailureMessage(e), e.rolledBack());
|
||||
logConfigurationFailure(e.reloadAttempted() ? "reload" : "write", e);
|
||||
return TaskResult.failure(e.reloadAttempted() ? "RELOAD_FAILED" : "WRITE_FAILED",
|
||||
applyFailureMessage(e), e.rolledBack());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return TaskResult.failure("VALIDATION_ERROR", e.getMessage());
|
||||
} catch (Exception e) {
|
||||
@@ -625,9 +980,9 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
}
|
||||
|
||||
private TaskResult operationFailure(String type, Throwable failure) {
|
||||
String code = operationFailureCode(type);
|
||||
String action = "READ".equals(type) ? "read" : "PREVIEW".equals(type) ? "preview" : "apply";
|
||||
logConfigurationFailure(action, failure);
|
||||
String code = operationFailureCode(type, failure);
|
||||
return TaskResult.failure(code, operationFailureMessage(type, failure));
|
||||
}
|
||||
|
||||
@@ -635,25 +990,83 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
plugin.getLogger().log(Level.WARNING, "[Control] Configuration " + action + " failed", failure);
|
||||
}
|
||||
|
||||
static String operationFailureMessage(String type, Throwable ignored) {
|
||||
if ("READ".equals(type)) return "Configuration read failed; see the backend log";
|
||||
if ("PREVIEW".equals(type)) return "Configuration preview failed; see the backend log";
|
||||
return "Configuration apply failed; see the backend log";
|
||||
static String operationFailureMessage(String type, Throwable failure) {
|
||||
if ("READ".equals(type)) {
|
||||
if (hasCause(failure, java.nio.file.NoSuchFileException.class)) {
|
||||
return "The managed configuration file does not exist on this backend";
|
||||
}
|
||||
if (hasCause(failure, java.nio.file.AccessDeniedException.class)
|
||||
|| hasCause(failure, SecurityException.class)) {
|
||||
return "The managed configuration file is not readable by the backend";
|
||||
}
|
||||
BackendConfigurationService.ConfigurationReadException readFailure = cause(
|
||||
failure, BackendConfigurationService.ConfigurationReadException.class);
|
||||
if (readFailure != null) return switch (readFailure.failure()) {
|
||||
case TOO_LARGE -> "The managed configuration file exceeds the 512 KiB limit";
|
||||
case INVALID_ENCODING -> "The managed configuration file is not valid UTF-8";
|
||||
case UNSAFE -> "The managed configuration path is not a safe regular file";
|
||||
};
|
||||
return "Configuration file is unavailable or unreadable";
|
||||
}
|
||||
if ("PREVIEW".equals(type)) return "The backend could not prepare a configuration preview";
|
||||
return "The backend could not apply the managed configuration";
|
||||
}
|
||||
|
||||
static String reloadFailureMessage(Throwable ignored) {
|
||||
return "Configuration reload failed; see the backend log";
|
||||
static String applyFailureMessage(BackendConfigurationService.ApplyFailureException failure) {
|
||||
if (!failure.reloadAttempted()) {
|
||||
return failure.rolledBack() ? "Configuration write failed; the previous file was restored"
|
||||
: "Configuration write failed before runtime reload";
|
||||
}
|
||||
String reason = hasCause(failure, java.util.concurrent.TimeoutException.class)
|
||||
|| hasCause(failure, java.net.SocketTimeoutException.class)
|
||||
? "the configured transport did not become ready before its deadline"
|
||||
: hasCause(failure, java.net.ConnectException.class)
|
||||
? "the configured transport endpoint was unavailable"
|
||||
: "the backend runtime rejected the new configuration";
|
||||
return failure.rolledBack()
|
||||
? "Runtime reload failed because " + reason + "; the previous file was restored"
|
||||
: "Runtime reload failed because " + reason + "; automatic rollback did not complete";
|
||||
}
|
||||
|
||||
static String operationFailureCode(String type) {
|
||||
return operationFailureCode(type, null);
|
||||
}
|
||||
|
||||
static String operationFailureCode(String type, Throwable failure) {
|
||||
if ("READ".equals(type) && hasCause(failure, java.nio.file.NoSuchFileException.class)) {
|
||||
return "CONFIGURATION_MISSING";
|
||||
}
|
||||
if ("READ".equals(type) && (hasCause(failure, java.nio.file.AccessDeniedException.class)
|
||||
|| hasCause(failure, SecurityException.class))) return "CONFIGURATION_UNREADABLE";
|
||||
BackendConfigurationService.ConfigurationReadException readFailure = cause(
|
||||
failure, BackendConfigurationService.ConfigurationReadException.class);
|
||||
if ("READ".equals(type) && readFailure != null) return switch (readFailure.failure()) {
|
||||
case TOO_LARGE -> "CONFIGURATION_TOO_LARGE";
|
||||
case INVALID_ENCODING -> "CONFIGURATION_INVALID_ENCODING";
|
||||
case UNSAFE -> "CONFIGURATION_UNSAFE";
|
||||
};
|
||||
if ("READ".equals(type)) return "READ_FAILED";
|
||||
if ("PREVIEW".equals(type)) return "PREVIEW_FAILED";
|
||||
return "APPLY_FAILED";
|
||||
}
|
||||
|
||||
private static boolean hasCause(Throwable failure, Class<? extends Throwable> type) {
|
||||
return cause(failure, type) != null;
|
||||
}
|
||||
|
||||
private static <T extends Throwable> T cause(Throwable failure, Class<T> type) {
|
||||
for (Throwable current = failure; current != null; current = current.getCause()) {
|
||||
if (type.isInstance(current)) return type.cast(current);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private TaskResult executeFile(UUID operationId, String type, JsonObject configuration, JsonObject task)
|
||||
throws IOException {
|
||||
String fileName = string(configuration, "fileName");
|
||||
if (BackendConfigurationService.managedRewardFile(fileName) && !rewardFilesAccepted) {
|
||||
return TaskResult.failure("UNSUPPORTED_TASK", "Named reward files were not negotiated");
|
||||
}
|
||||
if ("READ".equals(type)) {
|
||||
BackendConfigurationService.Document document = configurations.read(fileName);
|
||||
return TaskResult.file(document, List.of(), false, false, false);
|
||||
@@ -681,13 +1094,20 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
private TaskResult executeQuick(UUID operationId, String type, JsonObject configuration, JsonObject task)
|
||||
throws IOException {
|
||||
String preset = string(configuration, "preset");
|
||||
if (!quickSetupCapabilityAccepted(preset, quickSetupsAccepted, voteSitesSyncAccepted)) {
|
||||
return TaskResult.failure("UNSUPPORTED_TASK", "VoteSites sync was not negotiated");
|
||||
}
|
||||
Map<String, String> options = options(configuration.getAsJsonObject("options"));
|
||||
if ("proxy-method".equals(preset)
|
||||
&& !proxyMethodApplyCapabilityAccepted(options.getOrDefault("method", "PLUGINMESSAGING"),
|
||||
proxyMethodV2Accepted)) {
|
||||
return TaskResult.failure("UNSUPPORTED_TASK", "The HTTP proxy method capability was not negotiated");
|
||||
}
|
||||
if (!quickSetupCapabilityAccepted(preset, quickSetupsAccepted, votePartySetupsAccepted,
|
||||
voteSitesSyncAccepted, options)) {
|
||||
return TaskResult.failure("UNSUPPORTED_TASK", "The required quick setup capability was not negotiated");
|
||||
}
|
||||
if ("READ".equals(type)) {
|
||||
BackendConfigurationService.QuickState state = configurations.readQuickSetup(preset, options);
|
||||
return TaskResult.quick(preset, state.options(), state.revision(), List.of(), false);
|
||||
return TaskResult.quick(preset, resultQuickReadOptions(preset, state.options(), options),
|
||||
state.revision(), List.of(), false);
|
||||
}
|
||||
if ("PREVIEW".equals(type)) {
|
||||
BackendConfigurationService.QuickPreview preview = configurations.previewQuickSetup(preset, options);
|
||||
@@ -730,10 +1150,25 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
}
|
||||
|
||||
static boolean quickSetupCapabilityAccepted(String preset, boolean quickSetupsAccepted,
|
||||
boolean voteSitesSyncAccepted) {
|
||||
boolean votePartySetupsAccepted, boolean voteSitesSyncAccepted) {
|
||||
if ("vote-party".equals(preset)) return quickSetupsAccepted && votePartySetupsAccepted;
|
||||
return quickSetupsAccepted && (!"sync-vote-sites".equals(preset) || voteSitesSyncAccepted);
|
||||
}
|
||||
|
||||
static boolean quickSetupCapabilityAccepted(String preset, boolean quickSetupsAccepted,
|
||||
boolean votePartySetupsAccepted, boolean voteSitesSyncAccepted, Map<String, String> options) {
|
||||
if ("vote-party".equals(preset)) {
|
||||
return options != null && options.containsKey("enabled")
|
||||
? quickSetupsAccepted && votePartySetupsAccepted : quickSetupsAccepted;
|
||||
}
|
||||
return quickSetupCapabilityAccepted(preset, quickSetupsAccepted, votePartySetupsAccepted,
|
||||
voteSitesSyncAccepted);
|
||||
}
|
||||
|
||||
static boolean proxyMethodApplyCapabilityAccepted(String method, boolean proxyMethodV2Accepted) {
|
||||
return !"HTTP".equalsIgnoreCase(method) || proxyMethodV2Accepted;
|
||||
}
|
||||
|
||||
private Response send(String method, String path, JsonObject body) throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder(settings.endpoint().resolve(path))
|
||||
.timeout(Duration.ofMillis(settings.requestTimeoutMillis())).header("Content-Type", "application/json")
|
||||
@@ -762,8 +1197,14 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
}
|
||||
|
||||
static void addCapabilities(JsonObject body) {
|
||||
addCapabilities(body, false, false);
|
||||
}
|
||||
|
||||
static void addCapabilities(JsonObject body, boolean rewardFilesSupported, boolean deploymentReady) {
|
||||
JsonArray capabilities = new JsonArray();
|
||||
CAPABILITIES.stream().sorted().forEach(capabilities::add);
|
||||
CAPABILITIES.stream().sorted().filter(capability -> rewardFilesSupported
|
||||
|| !"config.reward-files.v1".equals(capability)).forEach(capabilities::add);
|
||||
if (deploymentReady) capabilities.add(PluginDeploymentService.CAPABILITY);
|
||||
body.add("capabilities", capabilities);
|
||||
JsonArray required = new JsonArray();
|
||||
required.add("config.files.v1");
|
||||
@@ -811,6 +1252,16 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
return options == null ? Map.of() : Map.copyOf(options);
|
||||
}
|
||||
|
||||
static Map<String, String> resultQuickReadOptions(String preset, Map<String, String> state,
|
||||
Map<String, String> requestOptions) {
|
||||
if (!"vote-party".equals(preset) || requestOptions != null && requestOptions.containsKey("enabled")) {
|
||||
return resultQuickOptions(preset, state);
|
||||
}
|
||||
Map<String, String> legacy = new LinkedHashMap<>(state);
|
||||
legacy.remove("enabled");
|
||||
return Map.copyOf(legacy);
|
||||
}
|
||||
|
||||
private static int bounded(int value, int min, int max, String name) {
|
||||
if (value < min || value > max) throw new IllegalArgumentException("Control.Backend." + name + " is invalid");
|
||||
return value;
|
||||
@@ -831,6 +1282,9 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
if (polling != null) polling.cancel(false);
|
||||
ScheduledFuture<?> inspection = inspectionPolling;
|
||||
if (inspection != null) inspection.cancel(false);
|
||||
ScheduledFuture<?> deployment = deploymentPolling;
|
||||
if (deployment != null) deployment.cancel(false);
|
||||
if (deployments != null) deployments.cancel();
|
||||
inspectionExecutor.shutdownNow();
|
||||
if (reload != null && Bukkit.isPrimaryThread()) reload.cancel(false);
|
||||
awaitShutdown(executor, operation);
|
||||
@@ -972,7 +1426,7 @@ public final class BackendControlConnector implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private record InspectionTaskResult(boolean success, String code, String message, JsonObject data) {
|
||||
record InspectionTaskResult(boolean success, String code, String message, JsonObject data) {
|
||||
private JsonObject json() {
|
||||
JsonObject body = new JsonObject();
|
||||
body.addProperty("success", success);
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.bencodez.votingplugin.control;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/** Authenticates proxy-mediated enrollment over shared, unbound transports. */
|
||||
public final class ControlEnrollmentAuthenticator {
|
||||
private static final String ALGORITHM = "HmacSHA256";
|
||||
private static final String REQUEST_DOMAIN = "votingplugin-control-enrollment-request-v1";
|
||||
private static final String RESULT_DOMAIN = "votingplugin-control-enrollment-result-v1";
|
||||
private final byte[] key;
|
||||
|
||||
private ControlEnrollmentAuthenticator(byte[] key) {
|
||||
this.key = key.clone();
|
||||
}
|
||||
|
||||
public static ControlEnrollmentAuthenticator load(Path keyFile) throws IOException {
|
||||
if (!Files.isRegularFile(keyFile)) {
|
||||
throw new IOException("Control enrollment requires a shared secretkey.key");
|
||||
}
|
||||
try {
|
||||
byte[] decoded = Base64.getDecoder().decode(Files.readString(keyFile, StandardCharsets.US_ASCII).trim());
|
||||
if (decoded.length < 16) throw new IOException("Control enrollment key is too short");
|
||||
return new ControlEnrollmentAuthenticator(decoded);
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
throw new IOException("Control enrollment key is invalid", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
public String signRequest(String nodeId, UUID requestId, String endpoint, String verifier, String challenge) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(ALGORITHM);
|
||||
mac.init(new SecretKeySpec(key, ALGORITHM));
|
||||
update(mac, REQUEST_DOMAIN);
|
||||
update(mac, nodeId);
|
||||
update(mac, requestId == null ? "" : requestId.toString());
|
||||
update(mac, endpoint);
|
||||
update(mac, verifier);
|
||||
update(mac, challenge);
|
||||
return java.util.HexFormat.of().formatHex(mac.doFinal());
|
||||
} catch (GeneralSecurityException impossible) {
|
||||
throw new IllegalStateException("HMAC-SHA256 is unavailable", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
public String signResult(String nodeId, UUID requestId, boolean success, String challenge) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance(ALGORITHM);
|
||||
mac.init(new SecretKeySpec(key, ALGORITHM));
|
||||
update(mac, RESULT_DOMAIN);
|
||||
update(mac, nodeId);
|
||||
update(mac, requestId == null ? "" : requestId.toString());
|
||||
update(mac, Boolean.toString(success));
|
||||
update(mac, challenge);
|
||||
return java.util.HexFormat.of().formatHex(mac.doFinal());
|
||||
} catch (GeneralSecurityException impossible) {
|
||||
throw new IllegalStateException("HMAC-SHA256 is unavailable", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean verifiesRequest(String authenticator, String nodeId, UUID requestId, String endpoint, String verifier,
|
||||
String challenge) {
|
||||
if (authenticator == null || !authenticator.matches("[0-9a-f]{64}")) return false;
|
||||
byte[] expected = signRequest(nodeId, requestId, endpoint, verifier, challenge)
|
||||
.getBytes(StandardCharsets.US_ASCII);
|
||||
return MessageDigest.isEqual(expected, authenticator.getBytes(StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
public boolean verifiesResult(String authenticator, String nodeId, UUID requestId, boolean success,
|
||||
String challenge) {
|
||||
if (authenticator == null || !authenticator.matches("[0-9a-f]{64}")) return false;
|
||||
byte[] expected = signResult(nodeId, requestId, success, challenge).getBytes(StandardCharsets.US_ASCII);
|
||||
return MessageDigest.isEqual(expected, authenticator.getBytes(StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
private static void update(Mac mac, String value) {
|
||||
byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8);
|
||||
mac.update((byte) (bytes.length >>> 24));
|
||||
mac.update((byte) (bytes.length >>> 16));
|
||||
mac.update((byte) (bytes.length >>> 8));
|
||||
mac.update((byte) bytes.length);
|
||||
mac.update(bytes);
|
||||
}
|
||||
}
|
||||
+139
-1
@@ -1,6 +1,7 @@
|
||||
package com.bencodez.votingplugin.control;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
@@ -54,7 +55,7 @@ public final class ControlInspectionService {
|
||||
private static final Pattern SERVICE_NAME = Pattern.compile("[^\\p{Cntrl}]{1,64}");
|
||||
private static final Set<String> KINDS = Set.of("overview", "vote-site-health", "player",
|
||||
"vote-log-summary", "vote-log-search", "vote-trace", "vote-site-resolution",
|
||||
"reward-simulation", "diagnostics");
|
||||
"reward-simulation", "reward-file-inventory", "diagnostics", "network-health");
|
||||
|
||||
private final VotingPluginMain plugin;
|
||||
|
||||
@@ -83,7 +84,9 @@ public final class ControlInspectionService {
|
||||
case "vote-trace" -> voteTrace(filters);
|
||||
case "vote-site-resolution" -> voteSiteResolution(filters);
|
||||
case "reward-simulation" -> rewardSimulation(filters);
|
||||
case "reward-file-inventory" -> rewardFileInventory(filters);
|
||||
case "diagnostics" -> diagnostics(filters);
|
||||
case "network-health" -> networkHealth(filters);
|
||||
default -> throw new IllegalArgumentException("inspection kind is unsupported");
|
||||
};
|
||||
JsonObject envelope = new JsonObject();
|
||||
@@ -95,6 +98,24 @@ public final class ControlInspectionService {
|
||||
return envelope;
|
||||
}
|
||||
|
||||
static boolean rewardFileInventoryQuery(JsonObject query) {
|
||||
return query != null && query.has("kind") && query.get("kind").isJsonPrimitive()
|
||||
&& "reward-file-inventory".equals(query.get("kind").getAsString());
|
||||
}
|
||||
|
||||
private JsonObject rewardFileInventory(JsonObject filters) {
|
||||
rejectUnknown(filters, Set.of(), "reward file inventory filters");
|
||||
try {
|
||||
JsonArray files = new JsonArray();
|
||||
for (String name : BackendConfigurationService.rewardFileInventory(plugin.getDataFolder().toPath())) files.add(name);
|
||||
JsonObject result = new JsonObject();
|
||||
result.add("files", files);
|
||||
return result;
|
||||
} catch (IOException failure) {
|
||||
throw new InspectionUnavailableException("named reward files are unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
private JsonObject overview(JsonObject filters) {
|
||||
rejectUnknown(filters, Set.of(), "overview filters");
|
||||
VoteLogMysqlTable voteLog = plugin.getVoteLogMysqlTable();
|
||||
@@ -109,6 +130,9 @@ public final class ControlInspectionService {
|
||||
result.addProperty("enabledVoteSites", loadedVoteSites().stream().filter(VoteSite::isEnabled).count());
|
||||
result.addProperty("autoCreateVoteSites", plugin.getConfigFile().isAutoCreateVoteSites());
|
||||
result.addProperty("processRewards", plugin.getConfigFile().getData().getBoolean("ProcessRewards", true));
|
||||
result.addProperty("allowUnjoined", plugin.getConfigFile().isAllowUnjoined());
|
||||
result.addProperty("extraAllSitesCheck", plugin.getConfigFile().isExtraAllSitesCheck());
|
||||
result.addProperty("monthDateTotals", plugin.getConfigFile().isStoreMonthTotalsWithDate());
|
||||
result.addProperty("dataStorage", safe(plugin.getConfigFile().getData().getString("DataStorage", ""), 32));
|
||||
result.addProperty("voteLoggingEnabled", voteLoggingEnabled);
|
||||
result.addProperty("voteLogAvailable", voteLogAvailable);
|
||||
@@ -472,6 +496,120 @@ public final class ControlInspectionService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded, read-only configuration/runtime facts for Network Doctor. This
|
||||
* deliberately omits values which are not available through a stable API;
|
||||
* Control must treat omitted fields as UNKNOWN rather than inferred healthy.
|
||||
*/
|
||||
private JsonObject networkHealth(JsonObject filters) {
|
||||
rejectUnknown(filters, Set.of(), "network health filters");
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("schemaVersion", 1);
|
||||
result.addProperty("role", "BACKEND");
|
||||
result.addProperty("serverName", safe(plugin.getBungeeSettings().getServer(), 160));
|
||||
if (plugin.getBackendProxyHandler() != null && plugin.getBackendProxyHandler().getMethod() != null)
|
||||
result.addProperty("activeMethod", plugin.getBackendProxyHandler().getMethod().name());
|
||||
result.addProperty("configuredMethod", networkMethod(plugin.getBungeeSettings().getBungeeMethod()));
|
||||
result.addProperty("proxyMode", plugin.getBungeeSettings().isUseBungeecoord());
|
||||
result.addProperty("pluginMessageChannel", safe(plugin.getBungeeSettings().getPluginMessagingChannel(), 160));
|
||||
result.addProperty("encryption", plugin.getBungeeSettings().isCommunicationEncryption());
|
||||
String auth = plugin.getBungeeSettings().getSharedTransportAuthentication();
|
||||
try { result.addProperty("sharedAuthentication", com.bencodez.votingplugin.proxy.security.SharedTransportEnvelopeAuthenticator.Mode.parse(auth).name()); }
|
||||
catch (IllegalArgumentException invalid) { result.addProperty("sharedAuthentication", "INVALID"); }
|
||||
result.addProperty("triggerVotifierEvent", plugin.getBungeeSettings().isTriggerVotifierEvent());
|
||||
result.addProperty("votifierProviderPresent", plugin.isVotifierLoaded());
|
||||
OptionalVotifierDiagnostics.add(result, java.util.Arrays.asList(plugin.getServer().getPluginManager().getPlugins()));
|
||||
result.addProperty("configurationHealthy", !plugin.isYmlError());
|
||||
NetworkHealthConfigFacts.addBackend(result, plugin.getConfigFile().getData());
|
||||
NetworkHealthConfigFacts.addSpecialRewards(result, plugin.getSpecialRewardsConfig().getData());
|
||||
NetworkHealthConfigFacts.addGlobal(result, plugin.getBungeeSettings().getData());
|
||||
result.addProperty("processRewards", plugin.getConfigFile().getData().getBoolean("ProcessRewards", true));
|
||||
result.addProperty("autoCreateVoteSites", plugin.getConfigFile().isAutoCreateVoteSites());
|
||||
result.addProperty("voteLoggingEnabled", plugin.getConfigFile().isVoteLoggingEnabled());
|
||||
result.addProperty("voteLogReadable", plugin.getConfigFile().isVoteLoggingEnabled()
|
||||
&& plugin.getVoteLogMysqlTable() != null && plugin.getVoteLogMysqlTable().isReadable());
|
||||
result.addProperty("globalDataEnabled", plugin.getBungeeSettings().isGloblalDataEnabled());
|
||||
result.addProperty("globalDataUseMainMysql", plugin.getBungeeSettings().isGloblalDataUseMainMySQL());
|
||||
result.addProperty("perServerRewards", plugin.getBungeeSettings().isPerServerRewards());
|
||||
result.addProperty("perServerPoints", plugin.getBungeeSettings().isPerServerPoints());
|
||||
result.addProperty("giveExtraAllSitesRewards", plugin.getBungeeSettings().isGiveExtraAllSitesRewards());
|
||||
result.addProperty("transportProbeState", "UNKNOWN");
|
||||
if (plugin.getBackendProxyHandler() != null) {
|
||||
String fingerprint = plugin.getBackendProxyHandler().diagnosticSharedKeyFingerprint();
|
||||
if (fingerprint != null) result.addProperty("sharedKeyFingerprint", fingerprint);
|
||||
}
|
||||
if (plugin.getBackendProxyHandler() != null && plugin.getBackendProxyHandler().getMqttHandler() != null)
|
||||
result.addProperty("transportInitialized", plugin.getBackendProxyHandler().getMqttHandler().isConnected());
|
||||
if (plugin.getStorageType() != null) result.addProperty("databaseType", plugin.getStorageType().name());
|
||||
if (plugin.getStorageType() == com.bencodez.advancedcore.api.user.UserStorage.MYSQL)
|
||||
NetworkHealthStorageFacts.add(result, plugin.getMysql() == null || plugin.getMysql().getMysql() == null ? null : plugin.getMysql().getMysql().getConnectionManager());
|
||||
if ("REDIS".equals(networkMethod(plugin.getBungeeSettings().getBungeeMethod()))) {
|
||||
NetworkHealthConfigFacts.addString(result, plugin.getBungeeSettings().getData(), "transportNamespace", "Redis.Prefix", 160);
|
||||
result.addProperty("redisSsl", plugin.getBungeeSettings().isRedisSsl());
|
||||
}
|
||||
if ("MQTT".equals(networkMethod(plugin.getBungeeSettings().getBungeeMethod()))) {
|
||||
NetworkHealthConfigFacts.addString(result, plugin.getBungeeSettings().getData(), "transportNamespace", "MQTT.Prefix", 160);
|
||||
NetworkHealthConfigFacts.addString(result, plugin.getBungeeSettings().getData(), "mqttClientId", "MQTT.ClientID", 160);
|
||||
}
|
||||
NetworkHealthConfigFacts.addString(result, plugin.getConfigFile().getData(), "databasePrefix", "Database.Prefix", 160);
|
||||
JsonArray sites = new JsonArray();
|
||||
boolean siteComplete = allConfiguredVoteSiteNames().size() <= MAX_ROWS;
|
||||
ConfigurationSection root = plugin.getConfigVoteSites().getData().getConfigurationSection("VoteSites");
|
||||
if (root != null) {
|
||||
for (String name : allConfiguredVoteSiteNames().stream().limit(MAX_ROWS).toList()) {
|
||||
ConfigurationSection site = root.getConfigurationSection(name);
|
||||
if (site == null) continue;
|
||||
JsonObject item = new JsonObject();
|
||||
if (name.length() > 80 || name.codePoints().anyMatch(Character::isISOControl)) { siteComplete = false; continue; }
|
||||
item.addProperty("name", name);
|
||||
String service = site.getString("ServiceSite", "");
|
||||
if (service.length() <= 160 && service.codePoints().noneMatch(Character::isISOControl)) item.addProperty("serviceSite", service);
|
||||
else siteComplete = false;
|
||||
if (!site.contains("Enabled") || site.isBoolean("Enabled")) item.addProperty("enabled", plugin.getConfigVoteSites().getVoteSiteEnabled(name));
|
||||
else { siteComplete = false; NetworkHealthConfigFacts.invalid(result, "VoteSites.Enabled"); }
|
||||
if (site.isBoolean("AutoCreated")) item.addProperty("autoCreated", site.getBoolean("AutoCreated"));
|
||||
item.addProperty("hasRewards", hasRewardConfiguration(site));
|
||||
try {
|
||||
NetworkHealthConfigFacts.addDelay(item, site.get("VoteDelay"), site.get("VoteDelayMin"),
|
||||
plugin.getConfigVoteSites().getVoteDelay(name));
|
||||
} catch (IllegalArgumentException failure) { item.addProperty("delayValid", false); }
|
||||
String url = site.getString("VoteURL", "");
|
||||
item.addProperty("voteUrlState", url.isBlank() ? "BLANK"
|
||||
: url.toLowerCase(Locale.ROOT).contains("example") ? "EXAMPLE" : validVoteUrl(url) ? "VALID" : "INVALID");
|
||||
sites.add(item);
|
||||
}
|
||||
}
|
||||
result.add("voteSites", sites);
|
||||
result.addProperty("voteSitesComplete", siteComplete);
|
||||
NetworkHealthConfigFacts.addObservedServices(result, plugin.getServerData().getServiceSitesReadOnly());
|
||||
// A bounded prefix cannot prove the full inventory; omit it unless every
|
||||
// discovered name fits the complete bounded representation.
|
||||
List<String> rawPluginNames = java.util.Arrays.stream(plugin.getServer().getPluginManager().getPlugins())
|
||||
.map(Plugin::getName).toList();
|
||||
List<String> pluginNames = rawPluginNames.stream().filter(name -> name != null && !name.isBlank()).distinct()
|
||||
.sorted(String.CASE_INSENSITIVE_ORDER.thenComparing(Comparator.naturalOrder())).limit(MAX_ROWS + 1).toList();
|
||||
if (rawPluginNames.size() <= MAX_ROWS && pluginNames.size() == rawPluginNames.size()) {
|
||||
JsonArray plugins = new JsonArray();
|
||||
pluginNames.stream().filter(name -> name.length() <= 80 && name.codePoints().noneMatch(Character::isISOControl))
|
||||
.forEach(name -> plugins.add(name));
|
||||
if (plugins.size() == pluginNames.size()) result.add("detectedPlugins", plugins);
|
||||
}
|
||||
if (result.has("invalidConfigurationFields")) result.addProperty("configurationHealthy", false);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static boolean validVoteUrl(String value) {
|
||||
try { java.net.URI uri = java.net.URI.create(value); return ("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme())) && uri.getHost() != null; }
|
||||
catch (IllegalArgumentException failure) { return false; }
|
||||
}
|
||||
|
||||
private static String networkMethod(String value) {
|
||||
if (value == null) return "INVALID";
|
||||
String normalized = value.trim().toUpperCase(Locale.ROOT);
|
||||
return Set.of("PLUGINMESSAGING", "HTTP", "REDIS", "MQTT", "SOCKETS", "MYSQL").contains(normalized)
|
||||
? normalized : "INVALID";
|
||||
}
|
||||
|
||||
private List<String> allConfiguredVoteSiteNames() {
|
||||
ConfigurationSection root = plugin.getConfigVoteSites().getData().getConfigurationSection("VoteSites");
|
||||
if (root == null) return List.of();
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.bencodez.votingplugin.control;
|
||||
|
||||
import org.bukkit.configuration.file.FileConfiguration;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
/** Small fixed allow-list of non-secret config facts for Network Doctor. */
|
||||
final class NetworkHealthConfigFacts {
|
||||
private NetworkHealthConfigFacts() { }
|
||||
|
||||
static void addBackend(JsonObject out, FileConfiguration config) {
|
||||
addBoolean(out, config, "onlineMode", "OnlineMode");
|
||||
addString(out, config, "bedrockPlayerPrefix", "BedrockPlayerPrefix", 80);
|
||||
addInt(out, config, "timeHourOffset", "TimeHourOffSet");
|
||||
addBoolean(out, config, "monthDateTotals", "UseMonthDateTotalsAsPrimaryTotal");
|
||||
addBoolean(out, config, "extraAllSitesCheck", "ExtraAllSitesCheck");
|
||||
addBoolean(out, config, "allowUnjoined", "AllowUnjoined");
|
||||
addInt(out, config, "maxVotesPerDay", "MaxAmountOfVotesPerDay");
|
||||
}
|
||||
|
||||
static void addSpecialRewards(JsonObject out, FileConfiguration config) {
|
||||
addBoolean(out, config, "resetMilestonesMonthly", "ResetMilestonesMonthly");
|
||||
addBoolean(out, config, "votePartyEnabled", "VoteParty.Enabled");
|
||||
addInt(out, config, "votePartyVotesRequired", "VoteParty.VotesRequired");
|
||||
}
|
||||
|
||||
static void addGlobal(JsonObject out, FileConfiguration config) {
|
||||
addString(out, config, "globalDataPrefix", "GlobalData.Prefix", 80);
|
||||
addBoolean(out, config, "globalDataUseMainMysql", "GlobalData.UseMainMySQL");
|
||||
}
|
||||
|
||||
static void addDelay(JsonObject out, Object rawDelay, Object rawMinutes, com.bencodez.simpleapi.time.ParsedDuration parsed) {
|
||||
boolean valid = rawDelay == null || rawDelay instanceof String || rawDelay instanceof Number;
|
||||
valid &= rawMinutes == null || rawMinutes instanceof Number;
|
||||
valid &= !(rawDelay instanceof Number n) || Double.isFinite(n.doubleValue()) && n.doubleValue() >= 0;
|
||||
valid &= !(rawMinutes instanceof Number n) || Double.isFinite(n.doubleValue()) && n.doubleValue() >= 0;
|
||||
if (rawDelay instanceof String text && (parsed == null || parsed.isEmpty()))
|
||||
valid &= text.trim().matches("(?i)0+(?:\\.0+)?(?:\\s*(?:ms|s|m|h|d|w))?");
|
||||
out.addProperty("delayValid", valid && parsed != null);
|
||||
if (valid && parsed != null) {
|
||||
long millis = parsed.getMillis();
|
||||
long hours = millis / 3_600_000L + (millis % 3_600_000L == 0 ? 0 : 1);
|
||||
if (hours >= 0 && hours <= 1_000_000L) out.addProperty("delayHours", hours);
|
||||
}
|
||||
}
|
||||
|
||||
static void addObservedServices(JsonObject out, Iterable<String> observations) {
|
||||
java.util.Set<String> names = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER);
|
||||
if (observations == null) return;
|
||||
for (String name : observations) {
|
||||
if (name == null || name.isBlank() || name.length() > 80 || name.codePoints().anyMatch(Character::isISOControl)) return;
|
||||
names.add(name);
|
||||
if (names.size() > 100) return;
|
||||
}
|
||||
com.google.gson.JsonArray values = new com.google.gson.JsonArray();
|
||||
names.forEach(values::add); out.add("detectedServices", values);
|
||||
}
|
||||
|
||||
private static void addBoolean(JsonObject out, FileConfiguration c, String field, String path) {
|
||||
if (c.isBoolean(path)) out.addProperty(field, c.getBoolean(path));
|
||||
else if (c.contains(path)) invalid(out, path);
|
||||
}
|
||||
private static void addInt(JsonObject out, FileConfiguration c, String field, String path) {
|
||||
Object value = c.get(path);
|
||||
if (value instanceof Integer || value instanceof Long) {
|
||||
long count = ((Number)value).longValue();
|
||||
if (count >= -1_000_000_000L && count <= 1_000_000_000L
|
||||
&& ("timeHourOffset".equals(field) || count >= 0)) out.addProperty(field, count);
|
||||
else invalid(out, path);
|
||||
} else if (c.contains(path)) invalid(out, path);
|
||||
}
|
||||
static void addString(JsonObject out, FileConfiguration c, String field, String path, int max) {
|
||||
if (!c.isString(path)) { if (c.contains(path)) invalid(out, path); return; }
|
||||
String value = c.getString(path);
|
||||
if (value != null && value.length() <= max && value.chars().noneMatch(Character::isISOControl)) {
|
||||
out.addProperty(field, value);
|
||||
} else invalid(out, path);
|
||||
}
|
||||
static void invalid(JsonObject out, String path) {
|
||||
com.google.gson.JsonArray errors = out.has("invalidConfigurationFields") ? out.getAsJsonArray("invalidConfigurationFields") : new com.google.gson.JsonArray();
|
||||
if (errors.size() < 100 && errors.asList().stream().noneMatch(v -> v.getAsString().equals(path))) errors.add(path);
|
||||
out.add("invalidConfigurationFields", errors);
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.bencodez.votingplugin.control;
|
||||
|
||||
import com.bencodez.simpleapi.sql.mysql.ConnectionManager;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/** Existing pool state only: never acquires a connection or initializes storage. */
|
||||
public final class NetworkHealthStorageFacts {
|
||||
private NetworkHealthStorageFacts() { }
|
||||
public static void add(JsonObject out, ConnectionManager manager) {
|
||||
if (manager == null) { out.addProperty("databaseInitialized", false); return; }
|
||||
out.addProperty("databaseInitialized", !manager.isClosed());
|
||||
if (manager.getDbType() != null) {
|
||||
out.addProperty("databaseType", manager.getDbType().name());
|
||||
String override = manager.getMysqlDriver();
|
||||
boolean present;
|
||||
if (override != null && !override.isBlank()) present = driver(override);
|
||||
else present = switch (manager.getDbType()) {
|
||||
case MYSQL -> driver("com.mysql.cj.jdbc.Driver");
|
||||
case MARIADB -> driver("org.mariadb.jdbc.Driver") || manager.isMariadbFallbackToMysqlDriver() && driver("com.mysql.cj.jdbc.Driver");
|
||||
case POSTGRESQL -> driver("org.postgresql.Driver");
|
||||
};
|
||||
out.addProperty("jdbcDriverAvailable", !manager.isClosed() || present);
|
||||
try {
|
||||
String identity = "VotingPlugin-storage-v1\0" + (manager.getDbType().isMySqlFamily() ? "MYSQL" : manager.getDbType().name()) + "\0" + manager.getHost()
|
||||
+ "\0" + manager.getPort() + "\0" + manager.getDatabase();
|
||||
out.addProperty("storageFingerprint", HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(identity.getBytes(StandardCharsets.UTF_8))));
|
||||
} catch (java.security.NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); }
|
||||
}
|
||||
}
|
||||
private static boolean driver(String name) {
|
||||
try { Class.forName(name, false, NetworkHealthStorageFacts.class.getClassLoader()); return true; }
|
||||
catch (ClassNotFoundException | LinkageError unavailable) { return false; }
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package com.bencodez.votingplugin.control;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.util.*;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/** Optional names-only API: unsupported/partial snapshots never prove empty forwarding. */
|
||||
public final class OptionalVotifierDiagnostics {
|
||||
private OptionalVotifierDiagnostics() { }
|
||||
public static void add(JsonObject out, Iterable<?> providers) {
|
||||
List<JsonObject> observations = new ArrayList<>();
|
||||
int count = 0; boolean inventoryComplete = true;
|
||||
for (Object candidate : providers) {
|
||||
if (++count > 128) { inventoryComplete = false; break; }
|
||||
if (candidate == null || !candidate.getClass().getName().toLowerCase(Locale.ROOT).contains("votifier")) continue;
|
||||
JsonObject facts = new JsonObject();
|
||||
try { read(facts, candidate); }
|
||||
catch (ReflectiveOperationException | RuntimeException | LinkageError ignored) {
|
||||
facts = new JsonObject(); // no exception messages or partial facts cross this boundary
|
||||
}
|
||||
observations.add(facts);
|
||||
}
|
||||
if (observations.isEmpty()) return;
|
||||
for (String field : List.of("votifierProviderPresent", "votifierListenerInitialized")) {
|
||||
boolean positive = observations.stream().anyMatch(f -> f.has(field) && f.get(field).getAsBoolean());
|
||||
boolean negative = inventoryComplete && observations.stream().allMatch(f -> f.has(field) && !f.get(field).getAsBoolean());
|
||||
if (positive || negative) out.addProperty(field, positive);
|
||||
}
|
||||
boolean enabled = false, stateKnown = inventoryComplete, namesKnown = inventoryComplete;
|
||||
LinkedHashSet<String> names = new LinkedHashSet<>();
|
||||
for (JsonObject facts : observations) {
|
||||
boolean complete = facts.has("votifierForwardingKnown") && facts.get("votifierForwardingKnown").getAsBoolean()
|
||||
&& facts.has("forwardingDestinations");
|
||||
namesKnown &= complete;
|
||||
if (facts.has("votifierForwardingEnabled")) enabled |= facts.get("votifierForwardingEnabled").getAsBoolean();
|
||||
else if (complete) enabled |= !facts.getAsJsonArray("forwardingDestinations").isEmpty();
|
||||
else stateKnown = false;
|
||||
if (facts.has("forwardingDestinations")) for (var name : facts.getAsJsonArray("forwardingDestinations")) {
|
||||
if (names.size() < 100 || names.contains(name.getAsString())) names.add(name.getAsString());
|
||||
else namesKnown = false;
|
||||
}
|
||||
}
|
||||
if (enabled || stateKnown) out.addProperty("votifierForwardingEnabled", enabled);
|
||||
out.addProperty("votifierForwardingKnown", namesKnown);
|
||||
JsonArray values = new JsonArray(); names.forEach(values::add); out.add("forwardingDestinations", values);
|
||||
}
|
||||
private static void read(JsonObject facts, Object candidate) throws ReflectiveOperationException {
|
||||
if (addNuVotifierForwarding(facts, candidate)) return;
|
||||
Object snapshot = candidate.getClass().getMethod("getNetworkHealthSnapshot").invoke(candidate);
|
||||
if (snapshot == null) return;
|
||||
addBoolean(facts, "votifierProviderPresent", snapshot, "getProviderPresent");
|
||||
addBoolean(facts, "votifierListenerInitialized", snapshot, "getListenerInitialized");
|
||||
addBoolean(facts, "votifierForwardingKnown", snapshot, "getForwardingKnown");
|
||||
Object raw = snapshot.getClass().getMethod("getForwardingDestinations").invoke(snapshot);
|
||||
JsonArray names = new JsonArray(); boolean complete = raw instanceof Iterable<?>;
|
||||
if (raw instanceof Iterable<?> values) {
|
||||
Set<String> seen = new HashSet<>(); int examined = 0;
|
||||
for (Object value : values) {
|
||||
if (++examined > 100) { complete = false; break; }
|
||||
if (!(value instanceof String name) || name.isBlank() || name.length() > 80
|
||||
|| name.codePoints().anyMatch(Character::isISOControl) || !seen.add(name)) { complete = false; continue; }
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
if (!complete) facts.addProperty("votifierForwardingKnown", false);
|
||||
facts.add("forwardingDestinations", names);
|
||||
}
|
||||
|
||||
/** Reads only NuVotifier's observed private field shape; it never invokes forwarding code. */
|
||||
private static boolean addNuVotifierForwarding(JsonObject out, Object candidate)
|
||||
throws ReflectiveOperationException {
|
||||
String type = candidate.getClass().getName();
|
||||
if (!(type.equals("com.vexsoftware.votifier.bungee.NuVotifier")
|
||||
|| type.equals("com.vexsoftware.votifier.velocity.VotifierPlugin"))) return false;
|
||||
var field = candidate.getClass().getDeclaredField("forwardingMethod");
|
||||
field.setAccessible(true);
|
||||
Object source = field.get(candidate);
|
||||
if (source == null) return true; // startup failure, pending setup, or disabled: unknown
|
||||
String sourceType = source.getClass().getName();
|
||||
if (sourceType.equals("com.vexsoftware.votifier.bungee.PluginMessagingForwardingSource")
|
||||
|| sourceType.equals("com.vexsoftware.votifier.bungee.OnlineForwardPluginMessagingForwardingSource")
|
||||
|| sourceType.equals("com.vexsoftware.votifier.velocity.PluginMessagingForwardingSource")
|
||||
|| sourceType.equals("com.vexsoftware.votifier.velocity.OnlineForwardPluginMessagingForwardingSource")) {
|
||||
Class<?> base = source.getClass().getSuperclass();
|
||||
if (!base.getName().equals("com.vexsoftware.votifier.support.forwarding.AbstractPluginMessagingForwardingSource")) return true;
|
||||
var filterField = base.getDeclaredField("serverFilter"); filterField.setAccessible(true);
|
||||
Object filter = filterField.get(source);
|
||||
var pluginField = base.getDeclaredField("plugin"); pluginField.setAccessible(true);
|
||||
Object plugin = pluginField.get(source);
|
||||
if (filter == null || plugin != candidate || !filter.getClass().getName().equals(
|
||||
"com.vexsoftware.votifier.support.forwarding.ServerFilter")) return true;
|
||||
Object rawServers = Class.forName("com.vexsoftware.votifier.platform.ProxyVotifierPlugin", false,
|
||||
candidate.getClass().getClassLoader()).getMethod("getAllBackendServers").invoke(plugin);
|
||||
if (!(rawServers instanceof Iterable<?> servers)) return true;
|
||||
JsonArray names = new JsonArray(); Set<String> seen = new HashSet<>(); int examined = 0;
|
||||
Method allowed = filter.getClass().getMethod("isAllowed", String.class);
|
||||
Method serverName = Class.forName("com.vexsoftware.votifier.platform.BackendServer", false,
|
||||
plugin.getClass().getClassLoader()).getMethod("getName");
|
||||
for (Object server : servers) {
|
||||
if (++examined > 100 || server == null) return true;
|
||||
Object rawName = serverName.invoke(server);
|
||||
if (!(rawName instanceof String name) || name.isBlank() || name.length() > 80
|
||||
|| name.codePoints().anyMatch(Character::isISOControl) || !seen.add(name)) return true;
|
||||
if (Boolean.TRUE.equals(allowed.invoke(filter, name))) names.add(name);
|
||||
}
|
||||
// Online forwarding deliberately bypasses serverFilter for its fallback path.
|
||||
if (sourceType.endsWith(".OnlineForwardPluginMessagingForwardingSource")) {
|
||||
var fallbackField = source.getClass().getDeclaredField("fallbackServer");
|
||||
fallbackField.setAccessible(true);
|
||||
Object rawFallback = fallbackField.get(source);
|
||||
if (rawFallback != null) {
|
||||
if (!(rawFallback instanceof String fallback) || fallback.isBlank() || fallback.length() > 80
|
||||
|| fallback.codePoints().anyMatch(Character::isISOControl)) return true;
|
||||
Object resolved = Class.forName("com.vexsoftware.votifier.platform.ProxyVotifierPlugin", false,
|
||||
candidate.getClass().getClassLoader()).getMethod("getServer", String.class).invoke(plugin, fallback);
|
||||
if (!(resolved instanceof Optional<?> target)) return true;
|
||||
if (target.isPresent()) {
|
||||
Object rawName = serverName.invoke(target.get());
|
||||
if (!(rawName instanceof String name) || !seen.contains(name)) return true;
|
||||
boolean listed = false;
|
||||
for (var value : names) listed |= value.getAsString().equals(name);
|
||||
if (!listed) names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.addProperty("votifierForwardingEnabled", names.size() > 0);
|
||||
out.addProperty("votifierForwardingKnown", true);
|
||||
out.add("forwardingDestinations", names);
|
||||
return true;
|
||||
}
|
||||
if (!sourceType.equals("com.vexsoftware.votifier.support.forwarding.proxy.ProxyForwardingVoteSource")) return true;
|
||||
var targets = source.getClass().getDeclaredField("backendServers");
|
||||
targets.setAccessible(true);
|
||||
Object raw = targets.get(source);
|
||||
if (!(raw instanceof Iterable<?> values)) return true;
|
||||
JsonArray names = new JsonArray(); Set<String> seen = new HashSet<>(); int examined = 0;
|
||||
for (Object value : values) {
|
||||
if (++examined > 100 || value == null || !value.getClass().getName().equals(
|
||||
"com.vexsoftware.votifier.support.forwarding.proxy.ProxyForwardingVoteSource$BackendServer")) return true;
|
||||
var name = value.getClass().getDeclaredField("name"); name.setAccessible(true);
|
||||
Object rawName = name.get(value);
|
||||
if (!(rawName instanceof String text) || text.isBlank() || text.length() > 80
|
||||
|| text.codePoints().anyMatch(Character::isISOControl) || !seen.add(text)) return true;
|
||||
names.add(text);
|
||||
}
|
||||
out.addProperty("votifierForwardingEnabled", !names.isEmpty());
|
||||
out.addProperty("votifierForwardingKnown", true);
|
||||
out.add("forwardingDestinations", names);
|
||||
return true;
|
||||
}
|
||||
private static void addBoolean(JsonObject out, String field, Object snapshot, String getter) throws ReflectiveOperationException {
|
||||
Object value = snapshot.getClass().getMethod(getter).invoke(snapshot);
|
||||
if (value instanceof Boolean fact) out.addProperty(field, fact);
|
||||
}
|
||||
}
|
||||
+585
@@ -0,0 +1,585 @@
|
||||
package com.bencodez.votingplugin.control;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
import com.bencodez.votingplugin.util.DurableFiles;
|
||||
|
||||
/**
|
||||
* Bounded, pull-only staging for the optional {@code plugin.deploy.v1} capability.
|
||||
* It deliberately never reloads a plugin or starts a server. The small on-disk
|
||||
* marker makes a leased task idempotent after a lost result acknowledgement.
|
||||
*/
|
||||
public final class PluginDeploymentService {
|
||||
public static final String CAPABILITY = "plugin.deploy.v1";
|
||||
public static final long MAX_ARTIFACT_BYTES = 64L * 1024L * 1024L;
|
||||
private static final int BUFFER_BYTES = 32 * 1024;
|
||||
private static final int MAX_JAR_ENTRIES = 10_000;
|
||||
private static final long MAX_INSPECTED_UNCOMPRESSED_BYTES = 256L * 1024L * 1024L;
|
||||
private static final int MAX_PLUGIN_YML_BYTES = 64 * 1024;
|
||||
private static final String MARKER = ".control-deployment";
|
||||
private static final ScheduledExecutorService BODY_DEADLINE_EXECUTOR = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread thread = new Thread(r, "VotingPlugin-control-artifact-deadline");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
|
||||
private final Path target;
|
||||
private final Path root;
|
||||
private final Path marker;
|
||||
private final Path installedBackendJar;
|
||||
private final boolean replaceExisting;
|
||||
private final AtomicBoolean staging = new AtomicBoolean();
|
||||
private final AtomicReference<InputStream> activeResponse = new AtomicReference<>();
|
||||
|
||||
private PluginDeploymentService(Path target, Path installedBackendJar, boolean replaceExisting) throws IOException {
|
||||
this.target = target.toAbsolutePath().normalize();
|
||||
this.installedBackendJar = installedBackendJar == null ? null : installedBackendJar.toAbsolutePath().normalize();
|
||||
this.replaceExisting = replaceExisting;
|
||||
Path parent = this.target.getParent();
|
||||
if (parent == null) throw new IOException("deployment target has no parent");
|
||||
Files.createDirectories(parent);
|
||||
this.root = parent.toRealPath(LinkOption.NOFOLLOW_LINKS);
|
||||
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(root)) {
|
||||
throw new IOException("deployment staging directory is unsafe");
|
||||
}
|
||||
if (!this.target.startsWith(root) || Files.isSymbolicLink(this.target)) {
|
||||
throw new IOException("deployment target escapes staging directory");
|
||||
}
|
||||
if (Files.exists(this.target, LinkOption.NOFOLLOW_LINKS)
|
||||
&& !Files.isRegularFile(this.target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("deployment target is not a regular file");
|
||||
}
|
||||
this.marker = root.resolve(this.target.getFileName() + MARKER);
|
||||
}
|
||||
|
||||
/** Bukkit consumes an update only when its file name matches the currently loaded plugin JAR. */
|
||||
public static PluginDeploymentService backend(Path updateDirectory, Path currentPluginJar) throws IOException {
|
||||
if (updateDirectory == null) throw new IOException("Bukkit update folder is unavailable");
|
||||
if (currentPluginJar == null || currentPluginJar.getFileName() == null
|
||||
|| !currentPluginJar.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar")) {
|
||||
throw new IOException("Bukkit plugin JAR is unavailable");
|
||||
}
|
||||
Path target = updateDirectory.resolve(currentPluginJar.getFileName().toString()).toAbsolutePath().normalize();
|
||||
Path installed = currentPluginJar.toAbsolutePath().normalize();
|
||||
// An empty/disabled Bukkit update folder can resolve to the loaded JAR's
|
||||
// directory. Staging there would overwrite a file that Bukkit currently has open.
|
||||
if (target.equals(installed) || Files.exists(target, LinkOption.NOFOLLOW_LINKS)
|
||||
&& Files.exists(installed, LinkOption.NOFOLLOW_LINKS) && Files.isSameFile(target, installed)) {
|
||||
throw new IOException("Bukkit update folder resolves to the loaded plugin JAR");
|
||||
}
|
||||
return new PluginDeploymentService(target, currentPluginJar, false);
|
||||
}
|
||||
|
||||
/** Proxies atomically replace their discovered plugin JAR only after a durable backup. */
|
||||
public static PluginDeploymentService proxy(Path currentPluginJar) throws IOException {
|
||||
if (currentPluginJar == null || !currentPluginJar.getFileName().toString().endsWith(".jar")) {
|
||||
throw new IOException("proxy plugin JAR is unavailable");
|
||||
}
|
||||
if (!Files.isRegularFile(currentPluginJar, LinkOption.NOFOLLOW_LINKS)
|
||||
|| Files.isSymbolicLink(currentPluginJar)) throw new IOException("proxy plugin JAR is unsafe");
|
||||
return new PluginDeploymentService(currentPluginJar, null, true);
|
||||
}
|
||||
|
||||
public boolean isStaging() { return staging.get(); }
|
||||
|
||||
/** Unblocks an in-progress response read during connector shutdown. */
|
||||
public void cancel() {
|
||||
InputStream response = activeResponse.getAndSet(null);
|
||||
if (response != null) {
|
||||
try { response.close(); } catch (IOException ignored) { /* Shutdown is already in progress. */ }
|
||||
}
|
||||
}
|
||||
|
||||
public Result deploy(Task task, URI endpoint, boolean directLocalHosted, String nodeId, UUID sessionId,
|
||||
String credential, HttpClient http, Duration timeout, BooleanSupplier active) {
|
||||
return deploy(task, endpoint, directLocalHosted, false, nodeId, sessionId, credential, http, timeout, active);
|
||||
}
|
||||
|
||||
public Result deploy(Task task, URI endpoint, boolean directLocalHosted, boolean allowInsecurePrivateHttp,
|
||||
String nodeId, UUID sessionId, String credential, HttpClient http, Duration timeout, BooleanSupplier active) {
|
||||
if (!staging.compareAndSet(false, true)) return Result.failure("DEPLOYMENT_FAILED", "Another deployment is still staging");
|
||||
try {
|
||||
validate(task);
|
||||
if (!deploymentEndpointAllowed(endpoint, directLocalHosted, allowInsecurePrivateHttp)) {
|
||||
return Result.failure("INSECURE_ENDPOINT",
|
||||
"Plugin staging requires HTTPS, local HTTP, or explicit opt-in for literal private-network HTTP");
|
||||
}
|
||||
if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before download");
|
||||
if (alreadyStaged(task)) return Result.restartRequired();
|
||||
URI artifact = endpoint.resolve("/api/v1/nodes/" + nodeId + "/deployments/" + task.deploymentId()
|
||||
+ "/artifact");
|
||||
HttpRequest request = HttpRequest.newBuilder(artifact).timeout(timeout)
|
||||
.header("Authorization", "Bearer " + credential)
|
||||
.header("X-Node-Session", sessionId.toString())
|
||||
.header("X-Deployment-Attempt", task.attemptId().toString()).GET().build();
|
||||
HttpResponse<InputStream> response = http.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||
InputStream body = response.body();
|
||||
activeResponse.set(body);
|
||||
ScheduledFuture<?> bodyDeadline = BODY_DEADLINE_EXECUTOR.schedule(() -> {
|
||||
try { body.close(); } catch (IOException ignored) { /* The staging operation reports the timeout. */ }
|
||||
}, timeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
try {
|
||||
if (response.statusCode() != 200) {
|
||||
body.close();
|
||||
return Result.failure("DOWNLOAD_FAILED", "Artifact download was rejected");
|
||||
}
|
||||
String contentLength = response.headers().firstValue("Content-Length").orElse(null);
|
||||
if (contentLength != null && (!contentLength.matches("[0-9]{1,9}")
|
||||
|| Long.parseLong(contentLength) != task.size())) {
|
||||
body.close();
|
||||
return Result.failure("SIZE_MISMATCH", "Artifact size did not match the deployment task");
|
||||
}
|
||||
if (!active.getAsBoolean()) {
|
||||
body.close();
|
||||
return Result.failure("CANCELLED", "Deployment was cancelled before download");
|
||||
}
|
||||
try (body) {
|
||||
return stage(task, body, active);
|
||||
} catch (IOException failure) {
|
||||
if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before staging");
|
||||
return Result.failure("STAGING_FAILED", "Artifact could not be verified or staged on this node");
|
||||
}
|
||||
} finally {
|
||||
bodyDeadline.cancel(false);
|
||||
activeResponse.compareAndSet(body, null);
|
||||
}
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
return Result.failure("CANCELLED", "Deployment download was interrupted");
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
return Result.failure("DOWNLOAD_FAILED", "Artifact download failed");
|
||||
} finally {
|
||||
staging.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Package-visible for deterministic artifact-validation tests. */
|
||||
Result stage(Task task, InputStream body, BooleanSupplier active) throws IOException {
|
||||
validate(task);
|
||||
if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before staging");
|
||||
if (alreadyStaged(task)) return Result.restartRequired();
|
||||
activeResponse.compareAndSet(null, body);
|
||||
Path temporary = Files.createTempFile(root, target.getFileName().toString() + ".", ".download");
|
||||
Activation activation = null;
|
||||
try {
|
||||
MessageDigest digest = sha256();
|
||||
long written = copyExact(body, temporary, task.size(), digest, active);
|
||||
force(temporary);
|
||||
if (written != task.size()) return Result.failure("SIZE_MISMATCH", "Artifact size did not match the deployment task");
|
||||
if (!HexFormat.of().formatHex(digest.digest()).equals(task.sha256())) {
|
||||
return Result.failure("HASH_MISMATCH", "Artifact digest did not match the deployment task");
|
||||
}
|
||||
inspectJar(temporary);
|
||||
if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before staging");
|
||||
activation = new Activation();
|
||||
prepareProxyBackup();
|
||||
writeMarker(task, activation);
|
||||
activate(temporary, activation);
|
||||
activation.discard();
|
||||
return Result.restartRequired();
|
||||
} catch (CancelledDeploymentException failure) {
|
||||
return Result.failure("CANCELLED", "Deployment was cancelled before staging");
|
||||
} catch (InvalidArtifactException failure) {
|
||||
return Result.failure("INVALID_ARTIFACT", "Artifact is not a bounded VotingPlugin JAR");
|
||||
} catch (IOException failure) {
|
||||
if (activation != null) {
|
||||
try { activation.rollback(); }
|
||||
catch (IOException rollbackFailure) { failure.addSuppressed(rollbackFailure); }
|
||||
}
|
||||
if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before staging");
|
||||
throw failure;
|
||||
} finally {
|
||||
activeResponse.compareAndSet(body, null);
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private long copyExact(InputStream input, Path temporary, long expected, MessageDigest digest,
|
||||
BooleanSupplier active) throws IOException {
|
||||
long total = 0;
|
||||
byte[] buffer = new byte[BUFFER_BYTES];
|
||||
try (var output = Files.newOutputStream(temporary, StandardOpenOption.TRUNCATE_EXISTING)) {
|
||||
for (;;) {
|
||||
if (!active.getAsBoolean()) throw new CancelledDeploymentException();
|
||||
int read = input.read(buffer);
|
||||
if (read == -1) break;
|
||||
total += read;
|
||||
if (total > expected || total > MAX_ARTIFACT_BYTES) throw new InvalidArtifactException();
|
||||
digest.update(buffer, 0, read);
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private void inspectJar(Path artifact) throws IOException {
|
||||
try (JarFile jar = new JarFile(artifact.toFile(), false)) {
|
||||
int entries = 0;
|
||||
long uncompressed = 0;
|
||||
JarEntry pluginYml = null;
|
||||
var iterator = jar.entries();
|
||||
while (iterator.hasMoreElements()) {
|
||||
JarEntry entry = iterator.nextElement();
|
||||
if (++entries > MAX_JAR_ENTRIES || unsafeEntry(entry.getName())) throw new InvalidArtifactException();
|
||||
long size = entry.getSize();
|
||||
long compressed = entry.getCompressedSize();
|
||||
if (size < 0 || compressed < 0 || compressed > 0 && size > compressed * 200L
|
||||
|| (uncompressed += size) > MAX_INSPECTED_UNCOMPRESSED_BYTES) {
|
||||
throw new InvalidArtifactException();
|
||||
}
|
||||
if ("plugin.yml".equals(entry.getName())) {
|
||||
if (pluginYml != null || size > MAX_PLUGIN_YML_BYTES) throw new InvalidArtifactException();
|
||||
pluginYml = entry;
|
||||
}
|
||||
}
|
||||
if (pluginYml == null || !isVotingPluginYml(jar, pluginYml)) throw new InvalidArtifactException();
|
||||
} catch (java.util.zip.ZipException failure) {
|
||||
throw new InvalidArtifactException();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean unsafeEntry(String name) {
|
||||
if (name == null || name.isEmpty() || name.length() > 512 || name.startsWith("/")
|
||||
|| name.startsWith("\\") || name.contains("\\") || name.indexOf('\0') >= 0) return true;
|
||||
for (String component : name.split("/", -1)) {
|
||||
if (component.equals(".") || component.equals("..")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isVotingPluginYml(JarFile jar, JarEntry entry) throws IOException {
|
||||
byte[] bytes;
|
||||
try (InputStream input = jar.getInputStream(entry)) { bytes = input.readNBytes(MAX_PLUGIN_YML_BYTES + 1); }
|
||||
if (bytes.length > MAX_PLUGIN_YML_BYTES) return false;
|
||||
String yml;
|
||||
try {
|
||||
yml = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT).decode(java.nio.ByteBuffer.wrap(bytes)).toString();
|
||||
} catch (java.nio.charset.CharacterCodingException failure) {
|
||||
return false;
|
||||
}
|
||||
boolean found = false;
|
||||
for (String line : yml.split("\\R")) {
|
||||
if (!line.startsWith("name:")) continue;
|
||||
if (found) return false;
|
||||
found = true;
|
||||
String value = line.substring(5).trim();
|
||||
int comment = value.indexOf('#');
|
||||
if (comment >= 0) value = value.substring(0, comment).trim();
|
||||
if (!"VotingPlugin".equals(unquote(value))) return false;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private static String unquote(String value) {
|
||||
return value.length() >= 2 && ((value.startsWith("\"") && value.endsWith("\""))
|
||||
|| (value.startsWith("'") && value.endsWith("'"))) ? value.substring(1, value.length() - 1) : value;
|
||||
}
|
||||
|
||||
private void prepareProxyBackup() throws IOException {
|
||||
if (replaceExisting) {
|
||||
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) {
|
||||
throw new IOException("current proxy plugin JAR is unsafe");
|
||||
}
|
||||
Path backup = root.resolve(target.getFileName() + ".control-backup");
|
||||
if (Files.exists(backup, LinkOption.NOFOLLOW_LINKS)
|
||||
&& (!Files.isRegularFile(backup, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(backup))) {
|
||||
throw new IOException("proxy plugin backup is unsafe");
|
||||
}
|
||||
Path backupTemp = Files.createTempFile(root, target.getFileName().toString() + ".", ".backup");
|
||||
try {
|
||||
Files.copy(target, backupTemp, StandardCopyOption.REPLACE_EXISTING);
|
||||
force(backupTemp);
|
||||
move(backupTemp, backup);
|
||||
forceDirectory(root);
|
||||
} finally { Files.deleteIfExists(backupTemp); }
|
||||
}
|
||||
}
|
||||
|
||||
private void activate(Path temporary, Activation activation) throws IOException {
|
||||
move(temporary, target);
|
||||
activation.published = true;
|
||||
force(target);
|
||||
forceDirectory(root);
|
||||
}
|
||||
|
||||
/** Holds a private copy until the durable idempotency marker has been published. */
|
||||
private final class Activation {
|
||||
private final Path previous;
|
||||
private boolean published;
|
||||
private boolean markerPublished;
|
||||
|
||||
private Activation() throws IOException {
|
||||
if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
previous = null;
|
||||
return;
|
||||
}
|
||||
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) {
|
||||
throw new IOException("deployment target is unsafe");
|
||||
}
|
||||
previous = Files.createTempFile(root, target.getFileName().toString() + ".", ".rollback");
|
||||
Files.copy(target, previous, StandardCopyOption.REPLACE_EXISTING);
|
||||
force(previous);
|
||||
}
|
||||
|
||||
private void rollback() throws IOException {
|
||||
if (markerPublished) DurableFiles.deleteIfExists(marker);
|
||||
if (!published) {
|
||||
discard();
|
||||
return;
|
||||
}
|
||||
if (previous == null) {
|
||||
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) {
|
||||
throw new IOException("deployed target cannot be safely removed");
|
||||
}
|
||||
Files.delete(target);
|
||||
} else {
|
||||
move(previous, target);
|
||||
force(target);
|
||||
}
|
||||
forceDirectory(root);
|
||||
discard();
|
||||
}
|
||||
|
||||
private void discard() {
|
||||
if (previous == null) return;
|
||||
try { Files.deleteIfExists(previous); }
|
||||
catch (IOException ignored) { /* A private stale rollback copy is safer than a false deployment result. */ }
|
||||
}
|
||||
}
|
||||
|
||||
private void writeMarker(Task task) throws IOException {
|
||||
writeMarker(task, null);
|
||||
}
|
||||
|
||||
private void writeMarker(Task task, Activation activation) throws IOException {
|
||||
Path temporary = Files.createTempFile(root, target.getFileName().toString() + ".", ".marker");
|
||||
try {
|
||||
Files.writeString(temporary, task.deploymentId() + "\n" + task.sha256() + "\n" + task.size() + "\n",
|
||||
StandardCharsets.US_ASCII, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
force(temporary);
|
||||
move(temporary, marker);
|
||||
if (activation != null) activation.markerPublished = true;
|
||||
forceDirectory(root);
|
||||
} finally { Files.deleteIfExists(temporary); }
|
||||
}
|
||||
|
||||
private boolean alreadyStaged(Task task) throws IOException {
|
||||
if (!Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(marker)
|
||||
|| Files.size(marker) > 256) return false;
|
||||
if (replaceExisting) {
|
||||
Path backup = root.resolve(target.getFileName() + ".control-backup");
|
||||
if (!Files.isRegularFile(backup, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(backup)) return false;
|
||||
}
|
||||
String[] fields = Files.readString(marker, StandardCharsets.US_ASCII).split("\\R", -1);
|
||||
// A lost acknowledgement can cause Control to issue a new deployment ID
|
||||
// for the same verified artifact after reconnect or restart. The marker
|
||||
// only proves a completed prior stage; the target is checked below by
|
||||
// exact size, digest and plugin identity before acknowledging the retry.
|
||||
if (fields.length < 3 || !task.sha256().equals(fields[1])
|
||||
|| !Long.toString(task.size()).equals(fields[2])) return false;
|
||||
if (!replaceExisting && !Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
|
||||
// Bukkit removes the staged update JAR after consuming it on restart. A
|
||||
// missing staging file alone is not proof: it may have been deleted or
|
||||
// quarantined. Confirm the installed backend JAR is the exact artifact.
|
||||
return installedBackendJar != null && fileMatches(installedBackendJar, task);
|
||||
}
|
||||
return targetMatches(task);
|
||||
}
|
||||
|
||||
private boolean targetMatches(Task task) throws IOException {
|
||||
return fileMatches(target, task);
|
||||
}
|
||||
|
||||
private boolean fileMatches(Path candidate, Task task) throws IOException {
|
||||
if (!Files.isRegularFile(candidate, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(candidate)
|
||||
|| Files.size(candidate) != task.size()) return false;
|
||||
MessageDigest digest = sha256();
|
||||
try (InputStream input = Files.newInputStream(candidate, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
|
||||
byte[] bytes = new byte[BUFFER_BYTES];
|
||||
for (int read; (read = input.read(bytes)) != -1;) digest.update(bytes, 0, read);
|
||||
}
|
||||
if (!task.sha256().equals(HexFormat.of().formatHex(digest.digest()))) return false;
|
||||
try {
|
||||
inspectJar(candidate);
|
||||
return true;
|
||||
} catch (InvalidArtifactException invalid) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static void move(Path source, Path destination) throws IOException {
|
||||
// The proxy target is executable on its next startup. A copy/delete fallback
|
||||
// can leave it missing or partial after a crash, even with a durable backup.
|
||||
Files.move(source, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
private static void force(Path file) throws IOException {
|
||||
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) { channel.force(true); }
|
||||
}
|
||||
|
||||
private static void forceDirectory(Path directory) throws IOException {
|
||||
DurableFiles.forceDirectory(directory);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the configured Control transport can carry a deployment request.
|
||||
*
|
||||
* <p>HTTP remains supported for literal loopback. Non-loopback link-local and private network
|
||||
* addresses require an explicit insecure-deployment opt-in unless direct same-node hosting is proven.
|
||||
* The overload also permits {@code localhost} when direct local hosting
|
||||
* is confirmed. Public addresses and other hostnames require HTTPS. Callers warn
|
||||
* operators because HTTPS is strongly recommended whenever traffic leaves the
|
||||
* local process.</p>
|
||||
*/
|
||||
public static boolean deploymentEndpointAllowed(URI endpoint) {
|
||||
return deploymentEndpointAllowed(endpoint, false, false);
|
||||
}
|
||||
|
||||
public static boolean deploymentEndpointAllowed(URI endpoint, boolean directLocalHosted) {
|
||||
return deploymentEndpointAllowed(endpoint, directLocalHosted, false);
|
||||
}
|
||||
|
||||
public static boolean deploymentEndpointAllowed(URI endpoint, boolean directLocalHosted,
|
||||
boolean allowInsecurePrivateHttp) {
|
||||
return deploymentEndpointPolicy(endpoint, directLocalHosted, allowInsecurePrivateHttp).allowed();
|
||||
}
|
||||
|
||||
/** No DNS-based private-host relaxation; local-host proof never authorizes arbitrary hostnames. */
|
||||
public static DeploymentEndpointPolicy deploymentEndpointPolicy(URI endpoint, boolean directLocalHosted,
|
||||
boolean allowInsecurePrivateHttp) {
|
||||
if (endpoint == null) return DeploymentEndpointPolicy.UNSUPPORTED;
|
||||
if ("https".equalsIgnoreCase(endpoint.getScheme())) return DeploymentEndpointPolicy.HTTPS;
|
||||
if (!usesUnencryptedHttp(endpoint)) return DeploymentEndpointPolicy.UNSUPPORTED;
|
||||
String host = endpoint.getHost();
|
||||
InetAddress localAddress = localNetworkAddress(host);
|
||||
if (localAddress != null) {
|
||||
if (localAddress.isLoopbackAddress() || directLocalHosted) return DeploymentEndpointPolicy.LOCAL_HTTP;
|
||||
return allowInsecurePrivateHttp ? DeploymentEndpointPolicy.PRIVATE_HTTP_OPT_IN
|
||||
: DeploymentEndpointPolicy.PRIVATE_HTTP_DISABLED;
|
||||
}
|
||||
if (directLocalHosted && "localhost".equalsIgnoreCase(host)) return DeploymentEndpointPolicy.LOCAL_HTTP;
|
||||
return DeploymentEndpointPolicy.UNSUPPORTED;
|
||||
}
|
||||
|
||||
public enum DeploymentEndpointPolicy {
|
||||
HTTPS(true), LOCAL_HTTP(true), PRIVATE_HTTP_OPT_IN(true), PRIVATE_HTTP_DISABLED(false), UNSUPPORTED(false);
|
||||
|
||||
private final boolean allowed;
|
||||
DeploymentEndpointPolicy(boolean allowed) { this.allowed = allowed; }
|
||||
public boolean allowed() { return allowed; }
|
||||
public String initializationMessage() {
|
||||
return switch (this) {
|
||||
case PRIVATE_HTTP_DISABLED -> "Plugin deployment over private-network HTTP is disabled. "
|
||||
+ "Use HTTPS or explicitly enable Control.AllowInsecureHttpPluginDeployment.";
|
||||
case PRIVATE_HTTP_OPT_IN -> "WARNING: Executable plugin deployment and node credentials cross an "
|
||||
+ "unauthenticated plaintext HTTP connection. Deployment metadata and artifacts can both be "
|
||||
+ "substituted by a network attacker. HTTPS is strongly recommended.";
|
||||
case UNSUPPORTED -> "Plugin deployment requires HTTPS, local HTTP, or opted-in literal private-network HTTP; "
|
||||
+ "public HTTP and arbitrary HTTP hostnames are prohibited.";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Retained for credential transport callers; use {@link #deploymentEndpointAllowed(URI, boolean)} only for deployment staging. */
|
||||
@Deprecated
|
||||
public static boolean credentialEndpointAllowed(URI endpoint, boolean directLocalHosted) {
|
||||
if (endpoint == null) return false;
|
||||
if ("https".equalsIgnoreCase(endpoint.getScheme())) return true;
|
||||
return directLocalHosted && "http".equalsIgnoreCase(endpoint.getScheme())
|
||||
&& isLoopbackHost(endpoint.getHost());
|
||||
}
|
||||
|
||||
public static boolean usesUnencryptedHttp(URI endpoint) {
|
||||
return endpoint != null && "http".equalsIgnoreCase(endpoint.getScheme());
|
||||
}
|
||||
|
||||
private static InetAddress localNetworkAddress(String host) {
|
||||
if (host == null || host.isBlank()) return null;
|
||||
String literal = host;
|
||||
if (literal.length() >= 2 && literal.charAt(0) == '[' && literal.charAt(literal.length() - 1) == ']') {
|
||||
literal = literal.substring(1, literal.length() - 1);
|
||||
}
|
||||
int zone = literal.indexOf('%');
|
||||
if (zone >= 0) literal = literal.substring(0, zone);
|
||||
if (!(literal.indexOf(':') >= 0 || literal.matches("[0-9]{1,3}(\\.[0-9]{1,3}){3}"))) return null;
|
||||
try {
|
||||
InetAddress address = InetAddress.getByName(literal);
|
||||
byte[] bytes = address.getAddress();
|
||||
boolean uniqueLocalV6 = bytes.length == 16 && (bytes[0] & 0xfe) == 0xfc;
|
||||
return address.isLoopbackAddress() || address.isSiteLocalAddress()
|
||||
|| address.isLinkLocalAddress() || uniqueLocalV6 ? address : null;
|
||||
} catch (Exception invalid) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isLoopbackHost(String host) {
|
||||
if (host == null) return false;
|
||||
String normalized = host;
|
||||
if (normalized.length() >= 2 && normalized.charAt(0) == '['
|
||||
&& normalized.charAt(normalized.length() - 1) == ']') {
|
||||
normalized = normalized.substring(1, normalized.length() - 1);
|
||||
}
|
||||
if ("localhost".equalsIgnoreCase(normalized) || "::1".equalsIgnoreCase(normalized)
|
||||
|| "0:0:0:0:0:0:0:1".equalsIgnoreCase(normalized)) return true;
|
||||
String[] octets = normalized.split("\\.", -1);
|
||||
if (octets.length != 4 || !"127".equals(octets[0])) return false;
|
||||
for (int index = 1; index < octets.length; index++) {
|
||||
if (!octets[index].matches("[0-9]{1,3}") || Integer.parseInt(octets[index]) > 255) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try { return MessageDigest.getInstance("SHA-256"); }
|
||||
catch (java.security.NoSuchAlgorithmException failure) { throw new IllegalStateException("SHA-256 is unavailable", failure); }
|
||||
}
|
||||
|
||||
private static void validate(Task task) {
|
||||
if (task == null || task.deploymentId() == null || task.attemptId() == null || task.artifactId() == null
|
||||
|| !task.artifactId().matches("[A-Za-z0-9][A-Za-z0-9._-]{0,127}") || task.sha256() == null
|
||||
|| !task.sha256().matches("[0-9a-fA-F]{64}") || task.size() < 1 || task.size() > MAX_ARTIFACT_BYTES) {
|
||||
throw new IllegalArgumentException("deployment task is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
public record Task(UUID deploymentId, String artifactId, String sha256, long size, UUID attemptId) {
|
||||
public Task { if (sha256 != null) sha256 = sha256.toLowerCase(Locale.ROOT); }
|
||||
}
|
||||
public record Result(boolean success, String code, String message) {
|
||||
static Result restartRequired() { return new Result(true, "RESTART_REQUIRED", "Plugin update staged; restart is required"); }
|
||||
static Result failure(String code, String message) { return new Result(false, code, message); }
|
||||
}
|
||||
private static final class InvalidArtifactException extends IOException { private static final long serialVersionUID = 1L; }
|
||||
private static final class CancelledDeploymentException extends IOException { private static final long serialVersionUID = 1L; }
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.bencodez.votingplugin.core.maintenance;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Cumulative active-runtime clock for the owner-facing auto-create setup reminder.
|
||||
* This class performs no persistence or notification. One owned worker must
|
||||
* serialize the entire propose, persist, commit, then notify sequence, including
|
||||
* reload and shutdown checkpoints. Method synchronization alone does not serialize
|
||||
* the external persistence step. Only notify after a successful commit.
|
||||
*
|
||||
* <p>Reconstruct this state from its durable snapshot and a fresh monotonic clock
|
||||
* reading on startup; never persist the process-local clock anchor. Samples must
|
||||
* be less than {@link Long#MAX_VALUE} nanoseconds apart (the normal nanoTime elapsed
|
||||
* time contract). Negative clock readings and signed clock wrap are supported.
|
||||
*/
|
||||
public final class AutoCreateVoteSitesReminderState {
|
||||
|
||||
public static final long NEVER_REMINDED = -1;
|
||||
/** The first reminder is emitted on the first active checkpoint. */
|
||||
public static final long DEFAULT_THRESHOLD_MILLIS = 0;
|
||||
/** Subsequent reminders are bounded to one every 48 active hours. */
|
||||
public static final long DEFAULT_REPEAT_MILLIS = TimeUnit.HOURS.toMillis(48);
|
||||
public static final long MAX_INTERVAL_MILLIS = Long.MAX_VALUE / 1_000_000;
|
||||
private static final long NANOS_PER_MILLI = 1_000_000;
|
||||
|
||||
/** Immutable durable data; a reminder marker must refer to accrued runtime. */
|
||||
public record Snapshot(long totalRuntimeMillis, long lastReminderRuntimeMillis) {
|
||||
public Snapshot {
|
||||
if (totalRuntimeMillis < 0 || lastReminderRuntimeMillis < NEVER_REMINDED
|
||||
|| lastReminderRuntimeMillis > totalRuntimeMillis) {
|
||||
throw new IllegalArgumentException("Invalid reminder runtime snapshot");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tentative checkpoint, valid only until this state commits any candidate.
|
||||
* Persist {@link #snapshot()} before committing; discard on a failed write and
|
||||
* propose again. Creating or discarding candidates changes no committed state.
|
||||
*/
|
||||
public static final class Candidate {
|
||||
private final Object epoch;
|
||||
private final Snapshot snapshot;
|
||||
private final long checkpointNanos;
|
||||
private final boolean reminderDue;
|
||||
|
||||
private Candidate(Object epoch, Snapshot snapshot, long checkpointNanos, boolean reminderDue) {
|
||||
this.epoch = epoch;
|
||||
this.snapshot = snapshot;
|
||||
this.checkpointNanos = checkpointNanos;
|
||||
this.reminderDue = reminderDue;
|
||||
}
|
||||
|
||||
public Snapshot snapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public boolean reminderDue() {
|
||||
return reminderDue;
|
||||
}
|
||||
}
|
||||
|
||||
private Snapshot snapshot;
|
||||
private long checkpointNanos;
|
||||
private Object epoch = new Object();
|
||||
|
||||
public AutoCreateVoteSitesReminderState(Snapshot snapshot, long nowNanos) {
|
||||
this.snapshot = Objects.requireNonNull(snapshot, "snapshot");
|
||||
this.checkpointNanos = nowNanos;
|
||||
}
|
||||
|
||||
public synchronized Snapshot snapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accrue runtime regardless of either flag. Both flags are required to propose a
|
||||
* reminder, at the inclusive threshold or repeat boundary. Settings are sampled
|
||||
* for each proposal; changing them does not reset accumulated runtime or markers.
|
||||
* Backward samples accrue nothing and leave the clock anchor unchanged.
|
||||
*/
|
||||
public synchronized Candidate propose(long nowNanos, boolean reminderEnabled, boolean autoCreateEnabled,
|
||||
long thresholdMillis, long repeatMillis) {
|
||||
validateInterval(thresholdMillis);
|
||||
validateInterval(repeatMillis);
|
||||
if (repeatMillis == 0) {
|
||||
throw new IllegalArgumentException("Reminder repeat interval must be positive");
|
||||
}
|
||||
long elapsedNanos = nowNanos - checkpointNanos;
|
||||
long elapsedMillis = elapsedNanos > 0 ? elapsedNanos / NANOS_PER_MILLI : 0;
|
||||
long total = saturatingAdd(snapshot.totalRuntimeMillis(), elapsedMillis);
|
||||
long lastReminder = snapshot.lastReminderRuntimeMillis();
|
||||
boolean due = reminderEnabled && autoCreateEnabled && total >= thresholdMillis
|
||||
&& (lastReminder == NEVER_REMINDED || total - lastReminder >= repeatMillis);
|
||||
Snapshot next = new Snapshot(total, due ? total : lastReminder);
|
||||
// Consume only whole milliseconds so frequent checkpoints do not lose time.
|
||||
long nextCheckpoint = checkpointNanos + elapsedMillis * NANOS_PER_MILLI;
|
||||
return new Candidate(epoch, next, nextCheckpoint, due);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a checkpoint only after its durable write succeeded. A successful
|
||||
* commit invalidates every other candidate from that epoch, including this one.
|
||||
* Foreign, stale, or duplicate candidates are rejected without changing state.
|
||||
*/
|
||||
public synchronized void commit(Candidate candidate) {
|
||||
Objects.requireNonNull(candidate, "candidate");
|
||||
if (candidate.epoch != epoch) {
|
||||
throw new IllegalArgumentException("Candidate is not current for this reminder state");
|
||||
}
|
||||
snapshot = candidate.snapshot;
|
||||
checkpointNanos = candidate.checkpointNanos;
|
||||
epoch = new Object();
|
||||
}
|
||||
|
||||
private static void validateInterval(long millis) {
|
||||
if (millis < 0 || millis > MAX_INTERVAL_MILLIS) {
|
||||
throw new IllegalArgumentException("Reminder interval is outside the supported range");
|
||||
}
|
||||
}
|
||||
|
||||
private static long saturatingAdd(long total, long elapsed) {
|
||||
return elapsed > Long.MAX_VALUE - total ? Long.MAX_VALUE : total + elapsed;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.bencodez.votingplugin.core.maintenance;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** Bounded, one-use acknowledgement of an identical maintenance request. Not a database lock. */
|
||||
public final class MaintenanceConfirmation {
|
||||
public enum Result { REQUESTED, CONFIRMED, FULL }
|
||||
private static final int MAX_ACTORS = 128;
|
||||
private static final long TTL_NANOS = TimeUnit.SECONDS.toNanos(30);
|
||||
private final Map<String, Pending> pending = new HashMap<>();
|
||||
private final LongSupplier clock;
|
||||
|
||||
public MaintenanceConfirmation() { this(System::nanoTime); }
|
||||
public MaintenanceConfirmation(LongSupplier clock) { this.clock = clock; }
|
||||
|
||||
/** A different operation replaces the actor's previous prompt. Expired prompts cannot confirm. */
|
||||
public synchronized Result request(String actor, String operation) {
|
||||
long now = clock.getAsLong();
|
||||
pending.entrySet().removeIf(entry -> now - entry.getValue().createdAt() >= TTL_NANOS);
|
||||
Pending previous = pending.remove(actor);
|
||||
if (previous != null && previous.operation().equals(operation)) return Result.CONFIRMED;
|
||||
if (pending.size() >= MAX_ACTORS) return Result.FULL;
|
||||
pending.put(actor, new Pending(operation, now));
|
||||
return Result.REQUESTED;
|
||||
}
|
||||
|
||||
public static String prompt(String command) {
|
||||
return "No changes made. Before continuing, stop vote/reward ingress and ALL backend servers sharing this database "
|
||||
+ "from writing, and verify any external reward effects. Repeat " + command
|
||||
+ " within 30 seconds to confirm. This acknowledges your precautions; it does not lock other servers.";
|
||||
}
|
||||
private record Pending(String operation, long createdAt) { }
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package com.bencodez.votingplugin.core.session;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Ephemeral, observation-only progress. Never invokes vote or reward processing. */
|
||||
public final class GuidedVoteSession {
|
||||
public enum Status { AWAITING, RECEIVED, UNAVAILABLE, SKIPPED }
|
||||
public record Site(String key, String name, String url, boolean eligible, long lastVote) { }
|
||||
public record Entry(Site site, Status status) { }
|
||||
public record View(List<Entry> entries, int index, boolean finished) {
|
||||
public Entry current() { return entries.isEmpty() ? null : entries.get(index); }
|
||||
public long received() { return entries.stream().filter(e -> e.status() == Status.RECEIVED).count(); }
|
||||
public boolean complete() { return !entries.isEmpty() && entries.stream().allMatch(e -> e.status() == Status.RECEIVED); }
|
||||
}
|
||||
private final long started;
|
||||
private final long observationOrder;
|
||||
private final Map<String, Site> candidates = new LinkedHashMap<>();
|
||||
private boolean candidatesBound;
|
||||
private final Map<String, Site> sites = new LinkedHashMap<>();
|
||||
private final Map<String, Long> received = new LinkedHashMap<>();
|
||||
private final java.util.Set<String> skipped = new java.util.HashSet<>();
|
||||
private final Map<UUID, String> occurrences = new LinkedHashMap<>();
|
||||
private final Map<UUID, ProxyVoteSequence.Fence> proxyFences = new LinkedHashMap<>();
|
||||
private final Map<UUID, String> pendingProxy = new LinkedHashMap<>();
|
||||
private boolean initialized;
|
||||
private boolean finished;
|
||||
private int index;
|
||||
|
||||
public GuidedVoteSession(long started) { this(started, VoteObservationSequence.next()); }
|
||||
public GuidedVoteSession(long started, long observationOrder) { this.started = started; this.observationOrder = observationOrder; }
|
||||
public long started() { return started; }
|
||||
|
||||
/** Bind visible identities on the player context before asynchronous eligibility sampling. */
|
||||
public synchronized void bindCandidates(List<Site> available) {
|
||||
if (candidatesBound) return;
|
||||
for (Site site : available) if (candidates.size() < 100) candidates.put(site.key(), site);
|
||||
candidatesBound = true;
|
||||
}
|
||||
public synchronized void refresh(List<Site> available) {
|
||||
bindCandidates(available);
|
||||
if (!initialized) {
|
||||
// A cooldown timestamp alone does not establish original ingress order.
|
||||
for (Site site : available) if (candidates.containsKey(site.key()) && (site.eligible() || received.containsKey(site.key())) && sites.size() < 100) sites.put(site.key(), site);
|
||||
// A temporarily hidden/removed site can be absent from the worker sample.
|
||||
// Keep its confirmed identity so visibility restoration can recover progress.
|
||||
for (Site candidate : candidates.values()) if (received.containsKey(candidate.key()))
|
||||
sites.putIfAbsent(candidate.key(), candidate);
|
||||
initialized = true;
|
||||
}
|
||||
Map<String, Site> current = new LinkedHashMap<>();
|
||||
for (Site site : available) current.put(site.key(), site);
|
||||
sites.replaceAll((key, old) -> {
|
||||
Site fresh = current.get(key);
|
||||
return fresh == null ? new Site(key, old.name(), old.url(), false, old.lastVote()) : fresh;
|
||||
});
|
||||
}
|
||||
|
||||
public synchronized boolean candidate(String key) { return candidates.containsKey(key); }
|
||||
/** The accepted pipeline supplies stable IDs and its authoritative occurrence time. */
|
||||
public synchronized void accepted(String site, UUID occurrence, long time, boolean real, boolean cancelled) {
|
||||
if (real && !cancelled && occurrence != null && time > started && candidates.containsKey(site)
|
||||
&& !received.containsKey(site) && !occurrences.containsKey(occurrence)) {
|
||||
received.put(site, time);
|
||||
occurrences.put(occurrence, site);
|
||||
// A credited vote may make the first asynchronous eligibility sample unavailable.
|
||||
// Its identity was already bound, so a later notification can restore that entry.
|
||||
if (initialized) sites.putIfAbsent(site, candidates.get(site));
|
||||
}
|
||||
}
|
||||
/** Fresh local ingress uses backend-local order; proxy occurrences require their source fence. */
|
||||
public synchronized void acceptedObserved(String site, UUID occurrence, long order) {
|
||||
if (order > observationOrder && occurrence != null && candidates.containsKey(site)
|
||||
&& !received.containsKey(site) && !occurrences.containsKey(occurrence)) {
|
||||
received.put(site, order);
|
||||
occurrences.put(occurrence, site);
|
||||
if (initialized) sites.putIfAbsent(site, candidates.get(site));
|
||||
}
|
||||
}
|
||||
/** A negotiated source fence is fixed for the entire session, not advanced by status checks. */
|
||||
public synchronized void bindProxyFence(ProxyVoteSequence.Fence fence) {
|
||||
if (!proxyFences.containsKey(fence.incarnation()) && proxyFences.size() < 32) {
|
||||
proxyFences.put(fence.incarnation(), fence);
|
||||
var pending = new ArrayList<>(pendingProxy.entrySet());
|
||||
for (var receipt : pending) acceptedProxy(receipt.getValue(), receipt.getKey());
|
||||
}
|
||||
}
|
||||
public synchronized boolean hasProxyFence() { return !proxyFences.isEmpty(); }
|
||||
/** The normal credited pipeline supplies the original canonical identity, including delayed votes. */
|
||||
public synchronized void acceptedProxy(String site, UUID occurrence) {
|
||||
if (occurrence == null || occurrence.version() != 8 || occurrence.variant() != 2
|
||||
|| !candidates.containsKey(site) || received.containsKey(site) || occurrences.containsKey(occurrence)) return;
|
||||
UUID source = new UUID(occurrence.getMostSignificantBits(), 0x8000000000000000L);
|
||||
ProxyVoteSequence.Fence fence = proxyFences.get(source);
|
||||
if (fence == null) {
|
||||
// Bounded provisional receipts cover a new occurrence overtaking its fence reply.
|
||||
if (pendingProxy.size() < 400) pendingProxy.putIfAbsent(occurrence, site);
|
||||
return;
|
||||
}
|
||||
pendingProxy.remove(occurrence);
|
||||
if (!fence.newer(occurrence)) return;
|
||||
received.put(site, occurrence.getLeastSignificantBits() & 0x3fffffffffffffffL);
|
||||
occurrences.put(occurrence, site);
|
||||
if (initialized) sites.putIfAbsent(site, candidates.get(site));
|
||||
}
|
||||
/**
|
||||
* Select the next eligible site for automatic guidance after the current site
|
||||
* can no longer be the active target. Explicit navigation remains owned by
|
||||
* {@link #view(String)}; this method only advances from a received,
|
||||
* unavailable, or skipped current entry.
|
||||
*
|
||||
* @return whether the active index changed
|
||||
*/
|
||||
public synchronized boolean selectNextAwaiting() {
|
||||
if (finished || sites.isEmpty()) return false;
|
||||
int start = Math.floorMod(index, sites.size());
|
||||
String current = new ArrayList<>(sites.keySet()).get(start);
|
||||
Status currentStatus = status(sites.get(current));
|
||||
if (currentStatus == Status.AWAITING) return false;
|
||||
List<Site> ordered = new ArrayList<>(sites.values());
|
||||
for (int offset = 1; offset <= ordered.size(); offset++) {
|
||||
int candidateIndex = (start + offset) % ordered.size();
|
||||
Site candidate = ordered.get(candidateIndex);
|
||||
if (status(candidate) == Status.AWAITING && candidate.eligible() && !skipped.contains(candidate.key())) {
|
||||
if (candidateIndex == index) return false;
|
||||
index = candidateIndex;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Status status(Site site) {
|
||||
if (received.containsKey(site.key())) return Status.RECEIVED;
|
||||
if (!site.eligible()) return Status.UNAVAILABLE;
|
||||
return skipped.contains(site.key()) ? Status.SKIPPED : Status.AWAITING;
|
||||
}
|
||||
public synchronized View view(String action) {
|
||||
if (!sites.isEmpty()) {
|
||||
String current = new ArrayList<>(sites.keySet()).get(index);
|
||||
if (action.equals("skip")) { skipped.add(current); index = (index + 1) % sites.size(); }
|
||||
if (action.equals("next")) index = (index + 1) % sites.size();
|
||||
if (action.equals("previous")) index = Math.floorMod(index - 1, sites.size());
|
||||
}
|
||||
if (action.equals("finish")) finished = true;
|
||||
List<Entry> entries = new ArrayList<>();
|
||||
for (Site site : sites.values()) {
|
||||
entries.add(new Entry(site, status(site)));
|
||||
}
|
||||
return new View(List.copyOf(entries), index, finished);
|
||||
}
|
||||
public static String httpUrl(String input) {
|
||||
if (input == null || input.length() > 2048) return null;
|
||||
try {
|
||||
String destination = input.trim();
|
||||
if (destination.startsWith("[")) {
|
||||
var wrapper = java.util.regex.Pattern.compile("(?i)\\[Text=\"[^\"\\r\\n]*\",\\s*url=\"([^\"\\r\\n]*)\"\\]").matcher(destination);
|
||||
if (!wrapper.matches()) return null;
|
||||
destination = wrapper.group(1);
|
||||
}
|
||||
URI uri = URI.create(destination);
|
||||
return ("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))
|
||||
&& uri.getHost() != null && uri.getUserInfo() == null ? uri.toASCIIString() : null;
|
||||
} catch (IllegalArgumentException invalid) { return null; }
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.bencodez.votingplugin.core.session;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Original proxy ingress order, carried by the existing opaque canonical vote UUID.
|
||||
* UUIDv8: random 60-bit source incarnation and a monotonically increasing 62-bit sequence.
|
||||
* The process/classloader owns one source across runtime replacement; a restart creates
|
||||
* another. Never assign a new ID when forwarding, retrying or replaying an occurrence. */
|
||||
public final class ProxyVoteSequence {
|
||||
private static final long MAX_SEQUENCE = 0x3fffffffffffffffL;
|
||||
private static final ProxyVoteSequence LIVE = new ProxyVoteSequence(UUID.randomUUID());
|
||||
private final long source;
|
||||
private long sequence;
|
||||
|
||||
public ProxyVoteSequence(UUID incarnation) {
|
||||
source = (incarnation.getMostSignificantBits() & 0xffffffffffff0fffL) | 0x8000L;
|
||||
}
|
||||
public static UUID nextVoteId() { return LIVE.next(); }
|
||||
public static Fence currentFence() { return LIVE.fence(); }
|
||||
public synchronized UUID next() {
|
||||
if (sequence == MAX_SEQUENCE) throw new IllegalStateException("Proxy vote sequence exhausted");
|
||||
return new UUID(source, 0x8000000000000000L | ++sequence);
|
||||
}
|
||||
public synchronized Fence fence() { return new Fence(new UUID(source, 0x8000000000000000L), sequence); }
|
||||
|
||||
public record Fence(UUID incarnation, long watermark) {
|
||||
public Fence {
|
||||
if (incarnation == null || incarnation.version() != 8 || incarnation.variant() != 2
|
||||
|| incarnation.getLeastSignificantBits() != 0x8000000000000000L
|
||||
|| watermark < 0 || watermark > MAX_SEQUENCE)
|
||||
throw new IllegalArgumentException("Invalid proxy occurrence fence");
|
||||
}
|
||||
public boolean newer(UUID occurrence) {
|
||||
return occurrence != null && occurrence.version() == 8 && occurrence.variant() == 2
|
||||
&& occurrence.getMostSignificantBits() == incarnation.getMostSignificantBits()
|
||||
&& (occurrence.getLeastSignificantBits() & MAX_SEQUENCE) > watermark;
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.bencodez.votingplugin.core.session;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/** Process-local total ordering shared by guide creation and vote ingress; never a remote timestamp. */
|
||||
public final class VoteObservationSequence {
|
||||
private static final AtomicLong SEQUENCE = new AtomicLong();
|
||||
private VoteObservationSequence() { }
|
||||
public static long next() { return SEQUENCE.incrementAndGet(); }
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.bencodez.votingplugin.core.vote;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
/** Applies the accepted vote's totals and points in Bukkit's existing order. */
|
||||
public final class SharedVoteAccounting {
|
||||
private SharedVoteAccounting() {
|
||||
}
|
||||
|
||||
public static void apply(SharedVoteInput input, SharedVotePolicy policy, BooleanSupplier currentlyOnline,
|
||||
Runnable addTotal, Runnable addTotalDaily, Runnable addTotalWeekly, Runnable addPoints) {
|
||||
Objects.requireNonNull(input, "input");
|
||||
Objects.requireNonNull(policy, "policy");
|
||||
Objects.requireNonNull(currentlyOnline, "currentlyOnline");
|
||||
Objects.requireNonNull(addTotal, "addTotal");
|
||||
Objects.requireNonNull(addTotalDaily, "addTotalDaily");
|
||||
Objects.requireNonNull(addTotalWeekly, "addTotalWeekly");
|
||||
Objects.requireNonNull(addPoints, "addPoints");
|
||||
if (!policy.shouldAwardConfiguredPoints(input)) return;
|
||||
if (policy.shouldCountTotals(input, currentlyOnline)) {
|
||||
addTotal.run();
|
||||
addTotalDaily.run();
|
||||
addTotalWeekly.run();
|
||||
}
|
||||
addPoints.run();
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.bencodez.votingplugin.core.vote;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
/** The existing Bukkit decision to deliver a vote now or queue it for login. */
|
||||
public final class SharedVoteDelivery {
|
||||
private SharedVoteDelivery() {
|
||||
}
|
||||
|
||||
public static boolean shouldDeliverNow(BooleanSupplier proxyVote, BooleanSupplier currentlyOnline,
|
||||
BooleanSupplier giveOfflineRewards, BooleanSupplier processRewards) {
|
||||
Objects.requireNonNull(proxyVote, "proxyVote");
|
||||
Objects.requireNonNull(currentlyOnline, "currentlyOnline");
|
||||
Objects.requireNonNull(giveOfflineRewards, "giveOfflineRewards");
|
||||
Objects.requireNonNull(processRewards, "processRewards");
|
||||
return ((currentlyOnline.getAsBoolean() || giveOfflineRewards.getAsBoolean())
|
||||
&& processRewards.getAsBoolean()) || proxyVote.getAsBoolean();
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.bencodez.votingplugin.core.vote;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
public record SharedVoteIdentity(UUID uuid, String playerName, boolean online) {
|
||||
public SharedVoteIdentity {
|
||||
Objects.requireNonNull(uuid, "uuid");
|
||||
Objects.requireNonNull(playerName, "playerName");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.bencodez.votingplugin.core.vote;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* An already-accepted vote entering the shared processing path. Native ingress,
|
||||
* proxy/global duplicate filtering, service-site validation and security checks
|
||||
* remain upstream and are intentionally not reimplemented here. An accepted
|
||||
* Bukkit event with an explicit vote site may carry a missing service name or
|
||||
* a negative timestamp; this record preserves those values for its adapter.
|
||||
* Identity validation remains at the ingress boundary.
|
||||
*/
|
||||
public record SharedVoteInput(UUID voteId, String playerName, String serviceSite, long voteTime,
|
||||
boolean realVote, boolean addTotals, boolean proxyVote, boolean forceProxyRouting, boolean wasOnline) {
|
||||
public SharedVoteInput {
|
||||
Objects.requireNonNull(voteId, "voteId");
|
||||
Objects.requireNonNull(playerName, "playerName");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility constructor for callers that historically had one proxy bit.
|
||||
* Native adapters should use the full constructor and map isBungee() and
|
||||
* isForceBungee() independently.
|
||||
*/
|
||||
public SharedVoteInput(UUID voteId, String playerName, String serviceSite, long voteTime,
|
||||
boolean realVote, boolean addTotals, boolean proxyVote, boolean wasOnline) {
|
||||
this(voteId, playerName, serviceSite, voteTime, realVote, addTotals,
|
||||
proxyVote, proxyVote, wasOnline);
|
||||
}
|
||||
|
||||
/** PlayerVoteEvent uses zero as "now"; callers choose when to normalize it. */
|
||||
public SharedVoteInput normalizedVoteTime(long nowEpochMillis) {
|
||||
if (voteTime != 0) return this;
|
||||
if (nowEpochMillis <= 0) throw new IllegalArgumentException("normalized vote time must be positive");
|
||||
return new SharedVoteInput(voteId, playerName, serviceSite, nowEpochMillis,
|
||||
realVote, addTotals, proxyVote, forceProxyRouting, wasOnline);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.bencodez.votingplugin.core.vote;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
/**
|
||||
* Platform-neutral subset of the existing vote-processing configuration.
|
||||
* Native adapters should populate it from the current VotingPlugin config.
|
||||
*/
|
||||
public record SharedVotePolicy(boolean countFakeVotes, boolean addTotals,
|
||||
boolean addTotalsOffline, boolean processRewards, boolean giveOfflineRewards) {
|
||||
|
||||
boolean shouldApplyConfiguredVoteMutation(SharedVoteInput input) {
|
||||
return shouldApplyConfiguredVoteMutation(input.realVote(), input.addTotals());
|
||||
}
|
||||
|
||||
/** The accepted Bukkit vote's existing fake-vote and add-totals gate. */
|
||||
public boolean shouldApplyConfiguredVoteMutation(boolean realVote, boolean voteAddsTotals) {
|
||||
return voteAddsTotals && (realVote || countFakeVotes);
|
||||
}
|
||||
|
||||
boolean shouldCountTotals(SharedVoteInput input, boolean online) {
|
||||
return shouldCountTotals(input.realVote(), input.addTotals(), online);
|
||||
}
|
||||
|
||||
boolean shouldCountTotals(SharedVoteInput input, BooleanSupplier currentlyOnline) {
|
||||
return shouldCountTotals(input.realVote(), input.addTotals(), currentlyOnline);
|
||||
}
|
||||
|
||||
/** Online means the user's current state, including for a proxy-origin vote. */
|
||||
public boolean shouldCountTotals(boolean realVote, boolean voteAddsTotals, boolean online) {
|
||||
return shouldCountTotals(realVote, voteAddsTotals, () -> online);
|
||||
}
|
||||
|
||||
/** Check online state only if the configuration actually requires it. */
|
||||
public boolean shouldCountTotals(boolean realVote, boolean voteAddsTotals, BooleanSupplier currentlyOnline) {
|
||||
Objects.requireNonNull(currentlyOnline, "currentlyOnline");
|
||||
return shouldApplyConfiguredVoteMutation(realVote, voteAddsTotals)
|
||||
&& addTotals && (addTotalsOffline || currentlyOnline.getAsBoolean());
|
||||
}
|
||||
|
||||
boolean shouldAwardConfiguredPoints(SharedVoteInput input) {
|
||||
// Existing Bukkit behavior awards configured vote points when the accepted
|
||||
// vote is countable even if Config.AddTotals itself is disabled.
|
||||
return shouldApplyConfiguredVoteMutation(input.realVote(), input.addTotals());
|
||||
}
|
||||
|
||||
boolean shouldExecuteRewardsNow(SharedVoteInput input, boolean online) {
|
||||
// Proxy votes preserve the existing force-processing behavior. For native
|
||||
// votes, ProcessRewards and per-site offline eligibility remain authoritative.
|
||||
return SharedVoteDelivery.shouldDeliverNow(input::proxyVote, () -> online,
|
||||
() -> giveOfflineRewards, () -> processRewards);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 501 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user