Compare commits

...
Author SHA1 Message Date
BenCodez add80a7425 Clarify local MEX workspace reads 2026-11-09 22:12:36 -06:00
BenCodez 114c69c4ff Remove migrated MEX project memory 2026-11-09 20:26:19 -06:00
BenCodez e75ce256ec refactor(tests): improve VoteStreaksMenuTest structure and readability
- Enhanced readability by organizing imports and formatting code.
- Refactored test methods for better clarity and consistency.
- Ensured proper mocking and assertions in inventory-related tests.
2026-11-08 19:59:16 -06:00
Ben d1d5891b7f Merge pull request #1741 from BenCodez/vp-work/point-addition-publication-phase2-20261101
Preserve point placeholder fences across callback and legacy arithmetic
2026-11-08 12:43:26 -06:00
Ben 548c974b69 Merge pull request #1699 from BenCodez/codex/date-vote-milestones
Add owner-configured DateVoteMilestones with durable occurrence accounting
2026-11-08 12:24:14 -06:00
Ben 293a01f352 Merge current master into DateVoteMilestones 2026-11-08 00:15:40 -06:00
Ben 2abd12466e Merge pull request #1749 from BenCodez/fix/voteparty-shared-mysql-cache-1748
fix(voteparty): wait for published shared MySQL cache before rewards
2026-11-06 17:17:56 -06:00
Ben 01ac6fa8f0 test(voteparty): verify voter-list reset is not deferred 2026-11-06 17:13:19 -06:00
Ben 9b850812f7 fix(voteparty): clear recipient list immediately while deferring only SQL counter reset 2026-11-06 17:13:08 -06:00
Ben 56eac13558 test(voteparty): account for both worker cache-readiness checks 2026-11-06 17:10:10 -06:00
Ben 8d378a0cb5 test(voteparty): ensure shared SQL reset follows queued reward eligibility 2026-11-06 17:06:36 -06:00
Ben 136c9ec31e test(voteparty): cover worker-only cache checks and inline scheduler fallbacks 2026-11-06 17:05:59 -06:00
Ben 558f9e1f58 fix(voteparty): order shared SQL counter reset after eligibility snapshots 2026-11-06 17:04:36 -06:00
Ben 4113acdea0 fix(voteparty): keep all shared-cache checks on storage worker 2026-11-06 17:04:19 -06:00
Ben 10ab18db8d test(voteparty): ensure async vote paths avoid off-thread Bukkit lookup 2026-11-06 16:51:31 -06:00
Ben 8d5b046fe2 fix(voteparty): avoid Bukkit owner lookup from storage workers 2026-11-06 16:51:20 -06:00
Ben 26ba7af075 test(voteparty): verify deferred eligibility and first-party metadata survive reset 2026-11-06 16:49:03 -06:00
Ben 8e9c5aa6d6 fix(voteparty): preserve per-user eligibility and party context across deferred rewards 2026-11-06 16:48:12 -06:00
Ben 0c4914b676 test(voteparty): cover shared MySQL cache readiness and reward dispatch 2026-11-06 16:43:15 -06:00
Ben 67806e473b fix(voteparty): defer shared MySQL rewards until cache is published 2026-11-06 16:42:12 -06:00
Ben e8fa948ecd Merge pull request #1737 from BenCodez/vp-work/dependency-consolidation-20261029
Update compatible VotingPlugin dependencies
2026-11-03 17:10:46 -06:00
Ben 4b83cc08bd Update test runner and provided Gson 2026-11-02 05:39:31 -06:00
Ben 6e26c9c6c2 Use explicit types in point mutation callbacks 2026-11-01 20:28:38 -06:00
Ben dd005146f3 Preserve point placeholder fences across arithmetic paths 2026-11-01 19:36:07 -06:00
Ben b212f5cabb Verify packaged Redis dependency compatibility 2026-10-31 22:51:31 -06:00
Ben e09fa0f039 Merge pull request #1740 from BenCodez/fix/shared-mysql-getpoints-loading-fallback-20261031
fix(api): prevent shared MySQL getPoints() loading exceptions
2026-10-31 12:23:46 -06:00
Ben 79bbdb1395 docs(api): explain getPoints loading fallback and confirmed debit semantics 2026-10-31 12:16:30 -06:00
Ben 51f0b5e4a9 test(api): cover cold, invalidated and in-flight shared point reads 2026-10-31 12:16:16 -06:00
Ben eb9fc8618b fix(api): share publication fence with direct points reads 2026-10-31 12:15:29 -06:00
Ben 86735060bc fix(api): fail closed on unavailable shared MySQL point snapshots 2026-10-31 12:15:21 -06:00
Ben f2eb2152fc Merge pull request #1739 from BenCodez/fix/shared-mysql-point-cache-6923bd0c
fix(points): reconcile shared MySQL point placeholder publication
2026-10-30 21:25:47 -06:00
Ben d2bc2feb8e fix(points): avoid repeated reloads of published snapshots 2026-10-30 21:12:17 -06:00
Ben b34229ced0 fix(points): reconcile shared MySQL point placeholder publication 2026-10-30 20:05:22 -06:00
Ben 9f56166708 Merge pull request #1738 from BenCodez/fix/cooldown-reward-identity-20261029
Preserve player identity across asynchronous cooldown rewards
2026-10-29 18:58:23 -06:00
Ben 7746fc52dc Retry cooldown identity refresh before reward dispatch 2026-10-29 18:50:03 -06:00
Ben ee33a291a6 test(cooldown): restore globals after partial fixture setup 2026-10-29 18:08:53 -06:00
Ben 66d1c858c7 fix(cooldown): retain deferred reward identity and failure context 2026-10-29 17:21:59 -06:00
Ben 2e8cb4bd5d Update compatible VotingPlugin dependencies 2026-10-28 23:05:49 -06:00
Ben 334a00ed77 Fix offline date milestone placeholder identity 2026-10-28 19:31:07 -06:00
Ben fdcb9e6501 Integrate DateVoteMilestones with current master 2026-10-28 17:55:28 -06:00
Ben 2a1e4e19ce Merge pull request #1701 from BenCodez/fix/partial-startup-shutdown
Prevent shutdown exceptions after partial startup failures
2026-10-25 19:25:34 -06:00
Ben 73947ee4cc Retain shutdown sign publication ownership across retries
Reuse the accepted batch publication lease after failed I/O, coalesce pending retries, and retry display observation without repeating a confirmed write.
2026-10-25 18:08:27 -06:00
Ben dc127ce300 Keep shutdown sign publication within the bounded process drain
Capture sign state before asynchronous persistence, retain failed attempts for retry, and keep outstanding generation publication leases in the process-exit drain without blocking lifecycle cleanup.
2026-10-25 17:18:36 -06:00
Ben 1f27359df7 Merge current master into partial-startup cleanup
Retain independent Discord cleanup and display retirement, preserve shared server-data publication ownership, and cover cleanup failure and retry integration.
2026-10-25 15:39:29 -06:00
Ben c80e460640 Merge pull request #1717 from BenCodez/vp-work/top-voter-presence
Add optional checked-presence filtering for top-voter displays
2026-10-25 12:54:46 -06:00
Ben c3248a0a85 Merge pull request #1736 from BenCodez/fix/cold-offline-placeholder-cache
Populate cold offline placeholder entries in all-player modes
2026-10-25 11:17:20 -06:00
Ben 3db2ba2d2a Populate cold offline placeholder entries in all-player modes 2026-10-24 23:47:40 -06:00
Ben bfded64b30 Integrate startup shutdown ownership with current master 2026-10-24 22:33:22 -06:00
Ben 8c098ede6d Merge pull request #1733 from BenCodez/fix/folialib-official-repository
Resolve FoliaLib before the failing Velocity repository
2026-10-19 04:21:57 -06:00
Ben 6ff81d7919 fix(build): resolve FoliaLib from its official repository 2026-10-19 03:23:47 -06:00
Ben 0554c2b954 Merge pull request #1727 from BenCodez/fix/folialib-051-scheduler-fixtures
Fix FoliaLib 0.5.1 scheduler fixture compilation
2026-10-19 03:08:13 -06:00
Ben cfd4cf5cf0 test: support published and upgraded scheduler return types 2026-10-18 22:27:32 -06:00
Ben 84a8724ce2 test: align scheduler fixtures with FoliaLib 0.5.1 2026-10-18 22:10:31 -06:00
Ben 032c3834f3 Serialize Discord leaderboard edits and preserve queued refreshes 2026-10-17 23:27:52 -06:00
Ben bc33141f43 Complete top-voter presence display and recovery integration 2026-10-17 22:03:31 -06:00
Ben 8f637ef582 Complete date milestone owner controls and test-package integration 2026-10-17 21:54:34 -06:00
Ben 0c83e75a16 Merge pull request #1724 from BenCodez/docs/deprecate-uuid-only-lookups
Deprecate UUID-only user lookups with identity-aware API guidance
2026-10-16 20:14:54 -06:00
Ben 5081f95e63 Deprecate UUID-only user lookups without changing identity behavior 2026-10-16 19:52:01 -06:00
Ben 36bf96559e Merge pull request #1723 from BenCodez/fix/offline-uuid-compatibility
Resolve online player UUIDs to existing offline voting data
2026-10-16 18:34:26 -06:00
Ben 63c3c9aecf Fence offline identity refresh and login ownership 2026-10-16 18:27:33 -06:00
Ben 8acf825eaa Resolve live UUID lookups to existing offline voting identities 2026-10-16 18:14:50 -06:00
Ben e40bf57093 Merge pull request #1719 from BenCodez/refactor/standardize-test-packages
Standardize test packages
2026-10-16 13:10:08 -06:00
Ben 04a83ad54c Integrate current command, streak and dependency updates with top-voter displays 2026-10-16 12:55:27 -06:00
Ben 0c512eb8c1 Standardize test packages 2026-10-16 12:44:16 -06:00
Ben de9852a974 Merge pull request #1718 from BenCodez/maintenance/consolidated-dependency-refresh
Consolidate dependency updates and refresh Spigot API
2026-10-16 11:53:51 -06:00
BenCodez ad0c58d945 Refresh Spigot API and consolidate dependency updates 2026-10-16 11:19:55 -06:00
BenCodez dea8c3a8ba Add support for opening vote streaks menu via votegui
- Implemented command handling to open the vote streaks menu when the
"streaks" command is issued.
2026-10-16 10:57:51 -06:00
Ben f79e4c5d36 Merge pull request #1716 from BenCodez/feature/vote-streaks-menu
Add a read-only vote streak progression menu
2026-10-16 10:49:52 -06:00
Ben ca591ed39c Merge pull request #1708 from BenCodez/feature/configurable-vote-command
Make the player vote command configurable
2026-10-16 10:25:20 -06:00
Ben 8d3b35fa4f Restrict empty leaderboard skull clearing to confirmed display targets 2026-10-16 00:36:08 -06:00
Ben d3f7c43550 Preserve leaderboard refresh recovery and presence evidence 2026-10-15 22:55:43 -06:00
Ben cd531e323b Filter top-voter displays with bounded checked presence evidence 2026-10-15 21:49:44 -06:00
Ben 18f2fe92b2 Retain saturated Votifier receipts until overflow initialization 2026-10-15 18:36:07 -06:00
Ben 00617059fd Preserve date-event receipt ownership and integrate current lifecycle 2026-10-15 16:14:32 -06:00
BenCodez ccfe5f1cf1 Preserve custom vote streaks command precedence 2026-10-15 11:28:47 -06:00
BenCodez 2b59b83123 Merge master and preserve streak-menu cleanup 2026-10-15 10:56:21 -06:00
Ben e0c4a1c92f Fence server-data generations and retain timed-vote recovery across shutdown 2026-10-15 10:32:12 -06:00
Ben 1fbc1a1939 Merge pull request #1713 from BenCodez/fix/auto-create-site-setup-reminder
Warn when automatic vote-site creation remains enabled
2026-10-15 09:12:10 -06:00
BenCodez c6a099fdb1 Add a read-only vote streak progression menu 2026-10-15 09:02:42 -06:00
BenCodez 03bbdb4c93 Warn after startup while automatic vote-site creation remains enabled 2026-10-14 10:41:30 -06:00
Ben 8b1a03afa7 Merge pull request #1714 from BenCodez/fix/backend-replay-test-lifecycle
Fix proxy replay test dispatch after cancellation support
2026-10-14 09:34:20 -06:00
BenCodez 8048aa3995 Fix proxy replay fixture for cancellation-aware vote dispatch 2026-10-14 09:28:25 -06:00
Ben 74e5a490d8 Merge pull request #1702 from BenCodez/fix/player-vote-event-cancellation
Respect Bukkit cancellation for player vote events
2026-10-14 09:17:05 -06:00
BenCodez 4a2dae0e47 Use a brief automatic site setup reminder without extra settings 2026-10-14 08:44:22 -06:00
BenCodez 1ce14cc0ec Preserve reminder checkpoint ownership during recovery 2026-10-14 08:20:09 -06:00
BenCodez e2f326de2a Add owner reminder for automatic vote-site setup 2026-10-14 07:35:48 -06:00
Ben 071ba3edec Retain capture retirement while queue ownership is pending 2026-10-14 06:54:17 -06:00
Ben 4aa8f3cd85 Retain captured Votifier votes in bounded shutdown snapshots 2026-10-14 05:55:24 -06:00
Ben 3b4208c595 Bound timed-vote shutdown persistence and retain retryable recovery 2026-10-13 23:15:15 -06:00
Ben 013c9bb583 Align date milestones and add guarded event progress menus 2026-10-13 22:29:30 -06:00
Ben d7b5a89955 Preserve pending timed votes and late producers during cleanup 2026-10-13 22:03:46 -06:00
Ben 4cf01f556e Merge pull request #1712 from BenCodez/fix/deferred-http-reload-regression
Fix deferred HTTP reload race
2026-10-13 19:27:10 -06:00
BenCodez 11fc55cc24 Avoid redundant deferred HTTP runtime replacement 2026-10-13 19:18:01 -06:00
Ben 4724d57e5d Merge pull request #1711 from BenCodez/fix/restore-release-site-formatting
Restore release vote-site formatting and fix proxy service logs
2026-10-13 16:38:49 -06:00
BenCodez a9e9c282f5 Restore release vote-site formatting and proxy log punctuation 2026-10-13 16:21:16 -06:00
Ben 9a91be8565 Merge pull request #1710 from BenCodez/fix/proxy-corrupt-cache-identity-recovery
Fix malformed proxy cache identity recovery
2026-10-13 16:17:00 -06:00
Ben 2dc99a0b97 Validate timed recovery rows before cache rewrites 2026-10-13 16:04:03 -06:00
Ben c7f5b786bb Preserve opaque recovery rows and quarantine malformed timed entries 2026-10-13 15:30:58 -06:00
Ben 306854486b Preserve recovery journal keys and owner delivery progress 2026-10-13 09:36:30 -06:00
Ben 07b96c4885 Fix malformed proxy cache identity recovery 2026-10-13 09:04:03 -06:00
Ben 05b17aad11 Merge pull request #1709 from BenCodez/test/proxy-vote-delay-replay
Add proxy vote delay and replay regression coverage
2026-10-12 16:19:34 -06:00
BenCodez f0cc7f15ae Preserve date milestone receipts and file ownership across reloads 2026-10-12 11:48:55 -06:00
BenCodez 2236dea0ff Use deterministic proxy vote regression identities 2026-10-12 10:48:31 -06:00
BenCodez a2d88cb2cf Cover proxy vote delay and replay classification regressions 2026-10-12 00:16:38 -06:00
BenCodez 42398de090 Retain overflow receipts until vote processing confirms completion 2026-10-12 00:10:09 -06:00
BenCodez 0518038d91 Make player vote command registration configurable 2026-10-12 00:00:18 -06:00
BenCodez b02228e743 Continue timed vote drain after a cancelled entry 2026-10-11 22:20:03 -06:00
Ben 1240be93e4 Merge pull request #1707 from BenCodez/fix/proxy-plugin-message-pacing
Fix proxy plugin-message bursts and improve cached vote delivery reliability
2026-10-11 21:47:13 -06:00
BenCodez 63179e56ff Persist plugin-message vote-party delivery across shutdown 2026-10-11 21:34:21 -06:00
BenCodez ed0b7584e4 Preserve legacy UTF-8 vote cache prefixes 2026-10-11 20:52:35 -06:00
BenCodez 6bae1599ff Persist original cache owners when transport changes 2026-10-11 20:42:18 -06:00
BenCodez 9ef8566677 Preserve original vote ownership across transport changes 2026-10-11 20:25:35 -06:00
BenCodez aa9d49cc72 Preserve deferred updates and durable vote delivery ownership 2026-10-11 20:09:01 -06:00
BenCodez e34232319c Preserve original vote plans and cached completion 2026-10-11 17:23:45 -06:00
BenCodez dadbc5423f Preserve broadcast ownership through retry and restart 2026-10-11 16:39:09 -06:00
BenCodez bdd8320c8d Preserve proxy runtime compatibility and durable update retries 2026-10-11 15:53:37 -06:00
BenCodez 0c26a216dc Fix proxy message pacing and cached vote recovery 2026-10-11 15:32:05 -06:00
BenCodez cf51409f3f Retain bounded vote captures across failed overflow transfers 2026-10-11 13:58:53 -06:00
BenCodez 42f5819f1e Normalize added date milestone lines for diff checks 2026-10-11 12:35:02 -06:00
Ben 65f1ee3fb4 Merge pull request #1706 from BenCodez/revert/pr-1643-votesite-formatting-20261011
Render vote-site names safely without invisible formatting guards
2026-10-11 12:29:38 -06:00
BenCodez ca43cd2d33 Match configured service identities for legacy delay rejection rewards 2026-10-11 12:22:37 -06:00
BenCodez 67194213f9 Retain section-sign rejection for incoming service names 2026-10-11 12:14:53 -06:00
BenCodez 612f53f48d Keep configured legacy delay-rejection services rewardable 2026-10-11 12:07:50 -06:00
BenCodez ee0110d665 Preserve printable boundaries around external site display values 2026-10-11 12:01:14 -06:00
BenCodez 6aa5acb99d Preserve legacy vote recovery with printable display guards 2026-10-11 11:49:34 -06:00
BenCodez 1cda20842d Reject interpreter delimiters before vote reward admission 2026-10-11 11:37:55 -06:00
Ben c3d7d11310 Revert "Keep external vote service identifiers inert" (#1643)
Revert the player-facing WORD JOINER formatting guard introduced by
VotingPlugin #1643 and its display-only reward placeholder changes.

Preserve unrelated asynchronous reward work. Keep NeoForge's later-added
player-message isolation private to its own adapter, without relying on
the reverted common formatting utility or altering its reward behavior.

As requested, this restores the prior Bukkit external identifier
rendering/parsing, removing #1643's hardening on those paths.
2026-10-11 11:09:03 -06:00
BenCodez cf8def4c8a Validate shutdown against latest AdvancedCore initialization fix 2026-10-11 11:06:37 -06:00
BenCodez a9b4af5fc5 Retain timed votes across shutdown races and failed persistence 2026-10-11 10:58:49 -06:00
BenCodez a89bb5594e Build startup regressions against corrected SQL dependencies 2026-10-11 10:25:34 -06:00
Ben 3b1994c899 Merge pull request #1704 from BenCodez/docs/clarify-online-mode-uuid-comments
docs: clarify OnlineMode UUID configuration
2026-10-11 10:18:19 -06:00
Ben ce7d2f747c docs: clarify OnlineMode UUID handling and mixed networks 2026-10-11 10:09:39 -06:00
BenCodez fe9ab829ef Suppress backend proxy points when vote dispatch is cancelled 2026-10-10 23:53:30 -06:00
BenCodez 3b69f39f90 Respect Bukkit cancellation for player vote events 2026-10-10 23:43:55 -06:00
BenCodez 4409f8fdd9 Assert partial-startup shutdown emits no cleanup failure warnings 2026-10-10 23:25:48 -06:00
BenCodez 81993afd60 Prevent shutdown exceptions after partial startup failures 2026-10-10 23:09:46 -06:00
Ben e323bc1722 Merge pull request #1700 from BenCodez/fix/voteupdate-shared-cache-race-20261008
Fix proxy VoteUpdate shared-cache loading race
2026-10-09 22:11:52 -06:00
BenCodez d8891d5adc Verify asynchronous NameMC claims on real storage executors 2026-10-09 22:07:14 -06:00
BenCodez 2c9e29ba88 Confirm shared-storage maintenance by repeating existing commands 2026-10-09 21:43:08 -06:00
BenCodez 54c346cdf8 Preserve login presence and replay ownership across lifecycle changes 2026-10-09 20:58:56 -06:00
BenCodez 6838a081ad Await offline replay before login and proxy follow-up 2026-10-09 20:43:45 -06:00
BenCodez 0ae23e748c Preserve replay ordering and restore quiesced MySQL bulk maintenance 2026-10-09 20:19:37 -06:00
BenCodez 3e03f3e236 Fence offline replay and reject unsafe MySQL bulk clears 2026-10-09 19:58:39 -06:00
BenCodez c05a02d129 Protect unresolved offline reward batches from clear commands 2026-10-09 19:42:03 -06:00
Ben aaa3cc720c Fix Mockito stubbing in offline reward batch failure test 2026-10-09 19:20:39 -06:00
Ben b7fb1d80d5 Atomically commit offline reward batches and gate shared recovery 2026-10-09 17:37:41 -06:00
Ben dda9388e29 Add NameMC recovery and precise pre-reward retry boundary 2026-10-09 17:29:58 -06:00
Ben be66e9ea33 Add preview-confirmed recovery for pending offline reward batches 2026-10-09 17:25:25 -06:00
BenCodez b4aa30d283 Retain started Votifier receipts through shutdown cancellation 2026-10-09 17:21:41 -06:00
Ben e06211a6ec Initialize legacy offline replay pending state in test fixture 2026-10-09 17:16:16 -06:00
Ben 5d4cb06cce Fix mocked user-data wiring in offline reward regression tests 2026-10-09 17:15:44 -06:00
Ben 371f109a99 Preserve in-flight offline votes until async rewards commit 2026-10-09 17:11:53 -06:00
BenCodez d6d6dbc81d Ship an explicitly disabled date milestone example 2026-10-09 17:01:56 -06:00
BenCodez 63d9a58a57 Retain captured votes across cancelled executor admission 2026-10-09 17:01:56 -06:00
Ben 734f4b5952 Await proxy offline rewards and durably fence NameMC claims 2026-10-09 17:00:22 -06:00
Ben a1f780f9eb Fence VoteUpdate handoffs on lifecycle and dispatch NameMC rewards safely 2026-10-09 16:44:54 -06:00
BenCodez 26441c1378 Persist local vote occurrence identities across queue replay 2026-10-09 16:37:49 -06:00
Ben 103445a4dc Resolve proxy vote sites before entering shared storage worker 2026-10-09 16:35:08 -06:00
Ben e8af1e2287 Move NameMC like claim and reward flow to shared storage worker 2026-10-09 16:29:55 -06:00
Ben 3cd46e6d92 Retry canceled proxy VoteUpdate platform handoffs 2026-10-08 21:10:55 -06:00
Ben ebd15756d5 Retry proxy VoteUpdate when player owner scheduler rejects 2026-10-08 20:58:04 -06:00
Ben e6d0c30e93 Preserve offline-mode name lookup in proxy VoteUpdate 2026-10-08 20:47:38 -06:00
BenCodez 87545cc41f Capture startup Votifier votes before reward pipeline readiness 2026-10-08 20:47:22 -06:00
Ben ad652ebdb3 Retry VoteUpdate when site lookup fails before mutation 2026-10-08 20:36:06 -06:00
Ben cb34f055ed Fix proxy VoteUpdate shared-cache retirement race 2026-10-08 20:29:14 -06:00
BenCodez 1535e27606 Separate date occurrence provenance from normal vote processing time 2026-10-08 20:03:13 -06:00
BenCodez 897f386f81 Start recovered date vote replay after its consumer is ready 2026-10-08 02:32:04 -06:00
BenCodez 57ebcbc72d Gate local date ingress and validate ordered progress filters 2026-10-08 02:13:58 -06:00
BenCodez e2f5503cfe Preserve normal proxy effects and explicit date accounting identity 2026-10-08 01:53:25 -06:00
BenCodez 31f5f2eeeb Require canonical transport identity and preserve original date vote time 2026-10-08 01:21:16 -06:00
BenCodez ff41566f4e Retain canonical proxy envelopes when date accounting is incomplete 2026-10-08 01:02:57 -06:00
BenCodez bd3c7382e8 Open proxy ingress after accepted vote handlers initialize 2026-10-08 00:45:01 -06:00
BenCodez 073634b899 Reserve date milestone rewards individually before submission 2026-10-08 00:01:42 -06:00
BenCodez 1f787df070 Initialize date vote definitions before proxy ingress 2026-10-07 23:36:10 -06:00
BenCodez 3c29bd8674 Respect reward processing and preserve queued date milestone handles 2026-10-07 23:20:02 -06:00
BenCodez bca38814e9 Add durable owner-configured DateVoteMilestones 2026-10-07 22:56:25 -06:00
Ben e0c48ef8f7 Merge pull request #1696 from BenCodez/fix/interval-broadcast-name-snapshot
Avoid offline UUID lookups on interval broadcast main thread
2026-10-07 20:01:59 -06:00
Ben 84658b2651 Correct interval summary test expectations to match rendered format 2026-10-07 18:31:18 -06:00
Ben 5f64dafab0 Test missing names and interval rotation boundaries 2026-10-07 18:27:15 -06:00
Ben 2889d07f0c Atomically rotate interval name and site records 2026-10-07 18:27:04 -06:00
Ben 65f3e1de46 Test interval summary avoids main-thread offline UUID lookup 2026-10-07 17:05:45 -06:00
Ben 736bb10687 Avoid UUID offline-player resolution in synchronous interval broadcast 2026-10-07 17:05:36 -06:00
Ben 22c630b10c Merge pull request #1695 from BenCodez/fix/namemc-async-uuid-resolution
Keep NameMC like checking async and resolve UUID users safely
2026-10-07 16:24:51 -06:00
Ben ff4f5196d0 Remove duplicate Gson dependency blocking Maven build 2026-10-07 16:09:16 -06:00
Ben a927215431 Clarify platform-thread boundary for NameMC reward dispatch 2026-10-07 16:06:27 -06:00
Ben 8e27570c7a Keep NameMC scanning async and guard overlapping UUID checks 2026-10-07 16:06:16 -06:00
Ben efd1c93433 Fix NameMC like checker unresolved UUID lookup on main thread 2026-10-07 16:05:43 -06:00
Ben 834bcb84a5 Merge pull request #1689 from BenCodez/feat/network-doctor-health
Add bounded Network Doctor diagnostics and fix proxy settings task validation
2026-10-04 20:10:44 -06:00
BenCodez 4348ab8536 Read backend reward health facts from SpecialRewards config 2026-10-04 19:54:50 -06:00
BenCodez 7e766d4d15 Settle malformed proxy inspection queries with validation errors 2026-10-04 18:58:46 -06:00
BenCodez ed9970bcfa Omit incomplete Bukkit network health plugin inventories 2026-10-04 16:41:07 -06:00
BenCodez 4b5b32661e Echo bounded opaque inspection attempt IDs without UUID parsing 2026-10-04 16:27:24 -06:00
BenCodez 32362acfab Keep optional health diagnostics bounded and recovery safe 2026-10-04 16:08:57 -06:00
BenCodez 7ca6de5659 Account for NuVotifier online forwarding fallback destinations 2026-10-04 15:38:48 -06:00
BenCodez 8b2db047b6 Observe forwarding in existing NuVotifier without modifying it 2026-10-04 15:29:49 -06:00
BenCodez 3f920f7e7d Use real storage enum in backend diagnostic wire fixture 2026-10-04 14:54:46 -06:00
BenCodez 35d0d5694d Align diagnostics with runtime parsing and accept canonical proxy file tasks 2026-10-04 14:52:18 -06:00
BenCodez 07ee9367d8 Validate diagnostic evidence and scope proxy inventories to actual paths 2026-10-04 14:34:47 -06:00
BenCodez 63626db18a Report bounded capability-negotiated setup health and proxy inventory 2026-10-04 14:11:15 -06:00
Ben f1d7b0012f Merge pull request #1686 from BenCodez/codex/velocity-config-reload-preservation
Preserve Velocity configuration and runtime on failed reload
2026-10-01 20:43:21 -06:00
BenCodez 0406ff7b10 Abort invalid Velocity configuration reload before runtime changes 2026-10-01 20:12:00 -06:00
Ben 93df54109d Merge pull request #1687 from BenCodez/codex/restore-jar-size-budget
Make VotingPlugin JAR size an advisory build warning
2026-10-01 20:08:00 -06:00
BenCodez ff152f8ee8 Warn instead of failing builds above the JAR size target 2026-10-01 20:00:13 -06:00
Ben 376c2a9c3c Merge pull request #1650 from BenCodez/neoforge/proxy-delivery-20260927
Add proxy vote delivery to NeoForge
2026-10-01 19:33:05 -06:00
Ben 1cf5a6de4d Merge pull request #1649 from BenCodez/neoforge/initial-reward-replay-20260927
Add initial NeoForge reward replay
2026-10-01 19:32:52 -06:00
Ben 2f9746b035 Merge pull request #1685 from BenCodez/codex/storage-and-review-workflow-guidance
Document storage mutation and PR validation safeguards
2026-10-01 19:00:49 -06:00
BenCodez 164138e8d5 Require evidence that proposed changes are needed 2026-10-01 18:13:27 -06:00
BenCodez e8afe00d74 Document storage mutation and PR validation safeguards 2026-10-01 18:09:41 -06:00
Ben 5271f58ede Merge pull request #1683 from BenCodez/codex/velocity-auto-install-jdbc-driver
Automatically install missing JDBC drivers on Velocity
2026-10-01 18:09:39 -06:00
Ben 6ca699758f Merge pull request #1684 from BenCodez/codex/shared-points-snapshot-coherence
Keep shared point snapshots coherent after mutations
2026-10-01 18:06:19 -06:00
BenCodez fff806d031 Preserve ordered point debits on platform threads 2026-10-01 17:37:18 -06:00
BenCodez 8ce33156d8 Defer platform point mutations until worker snapshot publication 2026-10-01 17:24:46 -06:00
BenCodez 860f530ede Fence JDBC snapshot fixture before concurrent reads 2026-09-30 21:59:17 -06:00
BenCodez c47522cee6 Retire proxy outbox entries for durably pending NeoForge votes 2026-09-30 21:54:17 -06:00
BenCodez 792836c3be Refresh shared point snapshots before publishing mutation completion 2026-09-30 21:45:53 -06:00
BenCodez a4f46212c4 Use latest GitHub release for Velocity JDBC driver installation 2026-09-30 19:59:58 -06:00
BenCodez 4c66833bdb Verify approved JDBC driver hashes before installation 2026-09-30 16:38:49 -06:00
BenCodez e8092c0334 Install missing Velocity JDBC drivers automatically 2026-09-30 16:17:08 -06:00
Ben bae0462fe5 Merge pull request #1682 from BenCodez/codex/private-http-deployment-opt-in
Require opt-in for plugin deployment over private HTTP
2026-09-29 20:33:04 -06:00
BenCodez d87d6a2654 Require opt-in for plugin deployment over private HTTP 2026-09-29 20:23:16 -06:00
BenCodez 752ef3e4d5 Keep NeoForge replay package below the artifact size limit 2026-09-29 20:04:11 -06:00
BenCodez 89e60da857 Reject unsupported NeoForge proxy broadcast deliveries 2026-09-29 20:02:35 -06:00
BenCodez e67bfd908a Fence NeoForge external rewards before durable replay dispatch 2026-09-29 19:56:51 -06:00
Ben 5e86b79db4 Merge pull request #1681 from BenCodez/fix/vote-presets-threading-bounds
Harden vote preset loading and UI scheduling
2026-09-29 19:42:43 -06:00
Ben 1c01b14ad6 Harden vote preset loading and UI scheduling 2026-09-29 18:12:11 -06:00
Ben d3c69313a3 Merge pull request #1675 from BenCodez/codex/control-http-staging-20260929
Allow verified Control staging over HTTP
2026-09-29 17:50:35 -06:00
Ben 7794e5c701 Merge pull request #1661 from BenCodez/dependabot/maven/VotingPlugin/me.clip-placeholderapi-2.12.3
Bump me.clip:placeholderapi from 2.12.2 to 2.12.3 in /VotingPlugin
2026-09-29 17:48:06 -06:00
Ben 00d499d51d Merge pull request #1662 from BenCodez/dependabot/maven/VotingPlugin/com.discordsrv-discordsrv-1.30.5
Bump com.discordsrv:discordsrv from 1.30.4 to 1.30.5 in /VotingPlugin
2026-09-29 17:48:02 -06:00
Ben 692ea55cfa Merge pull request #1663 from BenCodez/dependabot/maven/VotingPlugin/org.apache.maven.plugins-maven-deploy-plugin-3.2.0
Bump org.apache.maven.plugins:maven-deploy-plugin from 3.1.4 to 3.2.0 in /VotingPlugin
2026-09-29 17:47:58 -06:00
Ben 5bfc4a8e47 Merge pull request #1664 from BenCodez/dependabot/maven/VotingPlugin/org.mockito-mockito-core-5.24.0
Bump org.mockito:mockito-core from 5.21.0 to 5.24.0 in /VotingPlugin
2026-09-29 17:47:54 -06:00
Ben 051c906a69 Merge pull request #1665 from BenCodez/dependabot/maven/VotingPlugin/org.apache.maven.plugins-maven-jar-plugin-3.5.1
Bump org.apache.maven.plugins:maven-jar-plugin from 3.5.0 to 3.5.1 in /VotingPlugin
2026-09-29 17:47:51 -06:00
BenCodez d7126fd5f3 Preserve credential endpoint eligibility contract 2026-09-29 16:52:01 -06:00
BenCodez 679ecd6b72 Document proven localhost deployment staging 2026-09-29 16:43:18 -06:00
BenCodez 426197f0c7 Preserve proven localhost deployment staging 2026-09-28 22:36:19 -06:00
Ben 81ae47f619 Merge pull request #1677 from BenCodez/tests/audit-vote-durability-regressions-20260928
Add vote durability regression coverage
2026-09-28 20:54:54 -06:00
Ben 33109b1998 Merge pull request #1679 from BenCodez/fix/eclipse-entity-task-result-resolution
Avoid direct dependency on shaded EntityTaskResult
2026-09-28 20:37:47 -06:00
Ben aace407031 Fix vote durability regression fixtures 2026-09-28 20:28:43 -06:00
BenCodez 9c8a939786 Limit HTTP staging to private network addresses 2026-09-28 20:27:39 -06:00
Ben 7985674249 Decouple background test from shaded EntityTaskResult 2026-09-28 20:26:01 -06:00
Ben dd5d22d520 Decouple tests from shaded EntityTaskResult 2026-09-28 20:25:54 -06:00
Ben 0a4b786a4f Decouple tests from shaded EntityTaskResult 2026-09-28 20:25:52 -06:00
Ben ae2c2af7be Decouple tests from shaded EntityTaskResult 2026-09-28 20:25:49 -06:00
Ben 3806fcd996 Decouple tests from shaded EntityTaskResult 2026-09-28 20:25:39 -06:00
Ben 97850e30cf Decouple tests from shaded EntityTaskResult 2026-09-28 20:25:37 -06:00
Ben 45e3e75352 Decouple tests from shaded EntityTaskResult 2026-09-28 20:25:34 -06:00
Ben d6005b61fc Merge pull request #1674 from BenCodez/fix/pluginmessaging-skip-empty-capability-probes
Skip plugin messaging status probes for empty backends
2026-09-28 20:25:32 -06:00
Ben 3bea8cabea Decouple tests from shaded EntityTaskResult 2026-09-28 20:25:13 -06:00
Ben 3cdf42d9ea Decouple tests from shaded EntityTaskResult 2026-09-28 20:25:10 -06:00
Ben e2c2a430aa Decouple tests from shaded EntityTaskResult 2026-09-28 20:25:08 -06:00
Ben bfc75f6a7d Decouple tests from shaded EntityTaskResult 2026-09-28 20:25:05 -06:00
Ben 864d2c708d Decouple tests from shaded EntityTaskResult 2026-09-28 20:25:02 -06:00
Ben 75ad5f02a8 Decouple tests from shaded EntityTaskResult 2026-09-28 20:25:00 -06:00
Ben 00c90c4cd9 Add shaded Folia result test compatibility helper 2026-09-28 20:24:57 -06:00
Ben b5518dbbf3 Avoid direct dependency on shaded EntityTaskResult 2026-09-28 20:24:55 -06:00
Ben 5fcd8fa8e7 Avoid direct dependency on shaded EntityTaskResult 2026-09-28 20:24:53 -06:00
Ben d14925bf36 Avoid direct dependency on shaded EntityTaskResult 2026-09-28 20:24:48 -06:00
Ben 7335f88f86 Test intentional offline vote failure semantics 2026-09-28 20:21:40 -06:00
Ben 8b65aa8803 Test queued vote timestamp replay 2026-09-28 20:21:38 -06:00
BenCodez d37dd98842 Allow verified Control staging over HTTP 2026-09-28 20:16:04 -06:00
BenCodez d22a611202 Update version to 7.2-SNAPSHOT and fix time setting in vote event
- Bump version in pom.xml to 7.2-SNAPSHOT
- Correctly set vote event time using vote's time
2026-09-28 20:15:16 -06:00
Ben 4dc1be1ac8 Test empty plugin messaging capability probes 2026-09-28 20:08:13 -06:00
Ben a38ed709e5 Skip plugin messaging probes for empty backends 2026-09-28 20:08:10 -06:00
Ben 52e13e58ab Merge pull request #1672 from BenCodez/agent/fix-service-site-primary-url-20260928
Fix primary service-site list URL
2026-09-28 19:51:41 -06:00
Ben d08e630964 Fix primary service-site list URL 2026-09-28 19:36:58 -06:00
Ben cbe7172a7c Merge pull request #1671 from BenCodez/agent/fix-development-default-guidance-20260928
Fix stale proxy configuration guidance
2026-09-28 19:27:52 -06:00
Ben 4cc6e734b0 Merge pull request #1668 from BenCodez/codex/fix-proxy-http-retention-and-runtime-safety
Fix retained HTTP recovery and fail closed on proxy startup failures
2026-09-28 19:24:48 -06:00
Ben e24321db85 Fix stale proxy setup guidance 2026-09-28 19:16:14 -06:00
BenCodez 43009ca556 Add a rescue journal for proxy shutdown 2026-09-28 19:14:56 -06:00
BenCodez 1e90a9f035 Retry failed durable proxy vote handoffs 2026-09-28 19:05:14 -06:00
BenCodez ab0bbe9108 Validate and replay emergency proxy votes 2026-09-28 18:54:35 -06:00
BenCodez 8c232ebc3a Accept full service identifiers in vote recovery 2026-09-28 18:45:16 -06:00
BenCodez f37e93f0d3 Preserve proxy votes across failed lifecycle persistence 2026-09-28 18:38:05 -06:00
Ben 07454ac591 Merge pull request #1670 from BenCodez/codex/harden-proxy-vote-lifecycle-agents
Harden proxy vote lifecycle agent guidance
2026-09-28 18:38:03 -06:00
Ben 76c22fece3 Clarify graceful exactly-once lifecycle guarantees 2026-09-28 18:30:46 -06:00
Ben c313053b3f Harden proxy vote lifecycle agent guidance 2026-09-28 18:27:52 -06:00
BenCodez 7a183ccc44 Close proxy vote admission and replay gaps 2026-09-28 18:20:18 -06:00
BenCodez d4144e04a3 Prevent proxy vote loss during reload shutdown races 2026-09-28 18:06:33 -06:00
BenCodez bcc44aad91 Restore proxy readiness after reload aborts 2026-09-28 17:35:20 -06:00
Ben be9466acf4 Merge pull request #1669 from BenCodez/docs/security-threat-model-20260928
docs: add VotingPlugin security threat model
2026-09-28 17:34:04 -06:00
Ben a4cc0e7947 docs: document delivery downgrade and retention semantics 2026-09-28 17:28:09 -06:00
BenCodez 0ef28454bd Complete proxy recovery and reload vote safety fixes 2026-09-28 17:18:16 -06:00
Ben 37ec4014ed docs: scope reliable delivery and shared-key roles 2026-09-28 17:10:40 -06:00
Ben 51e40354c0 docs: scope vote delivery guarantees accurately 2026-09-28 17:01:51 -06:00
Ben 6f23c992fa docs: clarify shared-key transport identity boundary 2026-09-28 16:54:30 -06:00
Ben 0d8e4bb576 Order proxy reload vote state checks safely 2026-09-28 16:46:29 -06:00
Ben 16f5b7e5fb Order proxy reload vote state checks safely 2026-09-28 16:46:27 -06:00
Ben 749bd12405 Complete Bungee reload state transition 2026-09-28 16:45:40 -06:00
Ben 9c69bbff7b Cover vote retry during proxy runtime replacement 2026-09-28 16:44:31 -06:00
Ben 5e7a9455e0 Retry Velocity votes across runtime reload windows 2026-09-28 16:44:10 -06:00
Ben ea7ee766f9 Retry Bungee votes across runtime reload windows 2026-09-28 16:44:08 -06:00
Ben 431541abb1 Keep Velocity vote receipt fenced through reload completion 2026-09-28 16:43:55 -06:00
Ben 93b83109fe Keep Bungee vote receipt fenced through reload completion 2026-09-28 16:43:52 -06:00
Ben 621c90f7f1 docs: point agents to security threat model 2026-09-28 16:38:54 -06:00
Ben ccfa346d5d docs: add repository security threat model 2026-09-28 16:38:51 -06:00
Ben 8f0ad32141 Stop proxy tasks when vote listener startup fails 2026-09-28 16:36:58 -06:00
Ben 94f869a1e4 Stop proxy tasks when vote listener startup fails 2026-09-28 16:36:55 -06:00
Ben b754035366 Keep proxy vote event tests on operational runtimes 2026-09-28 16:33:44 -06:00
Ben 2a5cdeb351 Cover Velocity startup and listener readiness failures 2026-09-28 16:33:42 -06:00
Ben ea201e6503 Cover Bungee startup and listener readiness failures 2026-09-28 16:33:40 -06:00
Ben fccba32e15 Cover retained HTTP fallback and parked queues 2026-09-28 16:33:06 -06:00
Ben 819e2a22fb Capture severe proxy diagnostics in tests 2026-09-28 16:33:03 -06:00
Ben 296b42bd4b Expose proxy runtime readiness to vote listeners 2026-09-28 16:32:06 -06:00
Ben 11e1fd27c1 Expose proxy runtime readiness to vote listeners 2026-09-28 16:32:03 -06:00
Ben 7130b85247 Block votes from failed Velocity replacement runtimes 2026-09-28 16:31:22 -06:00
Ben 4efd2c28d0 Block votes from failed Bungee replacement runtimes 2026-09-28 16:31:20 -06:00
Ben 6963ab9d84 Expose proxy listener readiness for focused tests 2026-09-28 16:31:17 -06:00
Ben e41fee0415 Expose proxy listener readiness for focused tests 2026-09-28 16:31:14 -06:00
Ben a815aeb4ff Fail closed on incomplete Velocity proxy startup 2026-09-28 16:30:51 -06:00
Ben f8420a35c0 Fail closed on incomplete Bungee proxy startup 2026-09-28 16:30:30 -06:00
Ben d153181106 Harden retained HTTP transport recovery 2026-09-28 16:28:58 -06:00
dependabot[bot] 0620eabe9a Bump org.apache.maven.plugins:maven-jar-plugin in /VotingPlugin
Bumps [org.apache.maven.plugins:maven-jar-plugin](https://github.com/apache/maven-jar-plugin) from 3.5.0 to 3.5.1.
- [Release notes](https://github.com/apache/maven-jar-plugin/releases)
- [Commits](https://github.com/apache/maven-jar-plugin/compare/maven-jar-plugin-3.5.0...maven-jar-plugin-3.5.1)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-jar-plugin
  dependency-version: 3.5.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-28 06:39:33 +00:00
dependabot[bot] 294c698fec Bump org.mockito:mockito-core from 5.21.0 to 5.24.0 in /VotingPlugin
Bumps [org.mockito:mockito-core](https://github.com/mockito/mockito) from 5.21.0 to 5.24.0.
- [Release notes](https://github.com/mockito/mockito/releases)
- [Commits](https://github.com/mockito/mockito/compare/v5.21.0...v5.24.0)

---
updated-dependencies:
- dependency-name: org.mockito:mockito-core
  dependency-version: 5.24.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-28 06:36:19 +00:00
dependabot[bot] 582436d470 Bump org.apache.maven.plugins:maven-deploy-plugin in /VotingPlugin
Bumps [org.apache.maven.plugins:maven-deploy-plugin](https://github.com/apache/maven-deploy-plugin) from 3.1.4 to 3.2.0.
- [Release notes](https://github.com/apache/maven-deploy-plugin/releases)
- [Commits](https://github.com/apache/maven-deploy-plugin/compare/maven-deploy-plugin-3.1.4...maven-deploy-plugin-3.2.0)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-deploy-plugin
  dependency-version: 3.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-28 06:35:49 +00:00
dependabot[bot] 90f44b30b1 Bump com.discordsrv:discordsrv from 1.30.4 to 1.30.5 in /VotingPlugin
Bumps com.discordsrv:discordsrv from 1.30.4 to 1.30.5.

---
updated-dependencies:
- dependency-name: com.discordsrv:discordsrv
  dependency-version: 1.30.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-28 06:35:22 +00:00
dependabot[bot] 6449df35ed Bump me.clip:placeholderapi from 2.12.2 to 2.12.3 in /VotingPlugin
Bumps me.clip:placeholderapi from 2.12.2 to 2.12.3.

---
updated-dependencies:
- dependency-name: me.clip:placeholderapi
  dependency-version: 2.12.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-28 06:33:52 +00:00
BenCodez d39aa973cd Refresh socket security on reload 2026-09-27 19:14:48 -06:00
BenCodez 1028d54841 Harden NeoForge player message replay 2026-09-27 19:09:57 -06:00
BenCodez f8ca055a68 Bound receipts and serialize NeoForge presence 2026-09-27 19:00:21 -06:00
BenCodez b39de254cf Merge remote-tracking branch 'origin/neoforge/initial-reward-replay-20260927' into neoforge/proxy-delivery-20260927 2026-09-27 18:56:41 -06:00
BenCodez 8610ecca51 Keep NeoForge message service text inert 2026-09-27 18:53:30 -06:00
Ben b6d2aeaa50 Merge pull request #1654 from BenCodez/codex/fix-http-handoff-sends-plaintext-votes
Encrypt queued messages during HTTP transport handoff
2026-09-27 18:51:44 -06:00
BenCodez 9cb0ead53a Negotiate authenticated NeoForge socket delivery 2026-09-27 18:45:51 -06:00
BenCodez 5c9b877595 Merge remote-tracking branch 'origin/neoforge/initial-reward-replay-20260927' into neoforge/proxy-delivery-20260927
# Conflicts:
#	VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeDeferredVoteStore.java
#	VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRewardConfiguration.java
#	VotingPlugin/src/test/java/com/bencodez/votingplugin/neoforge/NeoForgeRewardReplayServiceTest.java
2026-09-27 18:40:16 -06:00
BenCodez c59d088f41 Harden NeoForge deferred replay diagnostics 2026-09-27 18:38:45 -06:00
BenCodez 612290eb92 Recover NeoForge proxy delivery state 2026-09-27 18:24:49 -06:00
BenCodez e8d9d10368 Merge remote-tracking branch 'origin/neoforge/initial-reward-replay-20260927' into neoforge/proxy-delivery-20260927
# Conflicts:
#	VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgePlayerDirectory.java
#	VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRewardReplayService.java
#	VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeVotingPlugin.java
2026-09-27 18:16:06 -06:00
BenCodez be3bbffcd0 Harden NeoForge replay recovery 2026-09-27 18:11:49 -06:00
BenCodez 7daa22ecb4 Preserve live NeoForge release receipts 2026-09-27 17:56:56 -06:00
BenCodez 307c41f08a Merge remote-tracking branch 'origin/neoforge/initial-reward-replay-20260927' into neoforge/proxy-delivery-20260927
# Conflicts:
#	VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeNativeRewardActions.java
#	VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgePlayerDirectory.java
#	VotingPlugin/src/test/java/com/bencodez/votingplugin/neoforge/NeoForgeRewardReplayServiceTest.java
2026-09-27 17:53:14 -06:00
BenCodez 51d3bae929 Retain UUID-bound names for NeoForge replay 2026-09-27 17:50:57 -06:00
BenCodez b70709b9dd Keep transport handoff waits off shutdown thread 2026-09-27 16:43:07 -06:00
Ben 1e428d5d51 Merge pull request #1659 from BenCodez/fix/proxy-voteparty-historical-online-state
Preserve proxy vote-time online state for VoteParty
2026-09-27 16:42:16 -06:00
BenCodez b6c11e2d04 Merge remote-tracking branch 'origin/master' into fix/pr1659
# Conflicts:
#	VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java
2026-09-27 16:36:13 -06:00
BenCodez b224ad08c0 Keep all-server VoteParty locally gated 2026-09-27 16:33:14 -06:00
BenCodez 770eec4a33 Bound restored HTTP handoff recovery 2026-09-27 16:30:01 -06:00
BenCodez f504c7a279 Keep HTTP shutdown cleanup off Bukkit 2026-09-27 16:16:06 -06:00
BenCodez a6a265a5e3 Bound HTTP shutdown handoff adaptation 2026-09-27 16:04:00 -06:00
Ben 8099d16f0e Encrypt queued messages across HTTP handoff 2026-09-27 15:49:55 -06:00
Ben 0e09397a64 Merge pull request #1657 from BenCodez/fix/proxy-vote-delay-authorization
Fix proxy vote delay authorization across forwarded votes
2026-09-27 14:40:17 -06:00
Ben 2384b40ffd Test queued delivery routing independently 2026-09-27 14:33:29 -06:00
Ben bd31d90b02 Merge pull request #1653 from BenCodez/codex/fix-multi-proxy-redis-vulnerability
Refresh multi-proxy security on Control reload
2026-09-27 14:33:02 -06:00
Ben 8fbe8fd988 Refresh multi-proxy security on Control reload 2026-09-27 14:22:55 -06:00
Ben ec06b30a84 Merge pull request #1652 from BenCodez/codex/fix-delayed-redis-bridge-copy-vulnerability
Retain unmatched Redis bridge vote fences
2026-09-27 14:09:16 -06:00
Ben 181cb3f30f Add delayed all-server VoteParty regressions 2026-09-27 14:06:29 -06:00
Ben 921dd56e01 Preserve proxy vote-time online state for VoteParty 2026-09-27 14:06:26 -06:00
BenCodez f5068bda9b Expire unmatched Redis bridge fences 2026-09-27 14:01:30 -06:00
BenCodez 41609444fe Add proxy vote delivery to NeoForge 2026-09-27 13:58:31 -06:00
BenCodez cc862f74c5 Preserve NeoForge replay identity and order 2026-09-27 13:57:59 -06:00
Ben 8e5abea3b3 Test out-of-order queued vote authorization 2026-09-27 13:47:42 -06:00
Ben 92206476b9 Test explicit queued delivery wire marker 2026-09-27 13:47:40 -06:00
Ben 5cdc4fae2f Preserve out-of-order queued vote rewards 2026-09-27 13:46:55 -06:00
Ben 74b4841cd9 Use explicit queued delivery marker on backend 2026-09-27 13:46:53 -06:00
Ben f6b76f8d92 Mark durable cached vote deliveries explicitly 2026-09-27 13:46:50 -06:00
Ben 325675ad19 Separate queued delivery identity from delay validation 2026-09-27 13:46:47 -06:00
Ben f26e8e8ce1 Fix queued proxy vote regression expectation 2026-09-27 13:41:06 -06:00
Ben a7dcec11d3 Add proxy vote delay regression tests 2026-09-27 13:38:55 -06:00
Ben 227e19884b Fix proxy vote delay authorization 2026-09-27 13:38:52 -06:00
Ben 1afd9b9b3d Merge pull request #1651 from BenCodez/codex/fix-token-exposure-in-webhook-logging
Redact webhook request targets in failure logs
2026-09-27 13:34:29 -06:00
Ben d95bb733cc Merge pull request #1656 from BenCodez/codex/fix-vote-party-counting-vulnerability
Require local backend presence for proxy vote parties
2026-09-27 13:33:51 -06:00
Ben da0c740091 Require local presence for proxy vote parties 2026-09-27 13:19:04 -06:00
BenCodez 7be4c1c630 Sanitize malformed webhook failures 2026-09-27 13:01:27 -06:00
Ben d68cf6b42e Retain unmatched Redis bridge vote fences 2026-09-27 12:53:08 -06:00
Ben 7f1ed88574 Redact webhook request targets in logs 2026-09-27 12:52:21 -06:00
BenCodez 3657787c76 Create deferred vote indexes on replay worker 2026-09-27 12:38:59 -06:00
BenCodez ff2204b565 Fail closed on unsupported NeoForge replay state 2026-09-27 12:26:25 -06:00
BenCodez ec819df74f Persist uncertain NeoForge reward outcomes 2026-09-27 12:14:56 -06:00
BenCodez 27dc7be78a Preserve deferred vote accounting decisions 2026-09-27 11:59:05 -06:00
BenCodez b737cb03fe Index deferred NeoForge replay rows 2026-09-27 11:42:48 -06:00
BenCodez e333bd4d5c Merge remote-tracking branch 'origin/master' into neoforge/initial-reward-replay-20260927 2026-09-27 11:26:36 -06:00
BenCodez 4683b3c951 Fix NeoForge reward replay edge cases 2026-09-27 11:24:32 -06:00
BenCodez f76f9b8a5a Preserve retained vote context during NeoForge replay 2026-09-27 11:13:22 -06:00
Ben 296eb3e154 Merge pull request #1640 from BenCodez/security/vote-delay-stable-identity-20260926
Use durable vote identity for vote-delay processing
2026-09-27 11:12:49 -06:00
BenCodez f1177e4b14 Preserve unknown delay state across proxy forwarding 2026-09-27 11:05:21 -06:00
BenCodez b4ac57a828 Preserve unknown delay state in cached votes 2026-09-27 10:56:10 -06:00
BenCodez 222ab749a3 Handle delay validation saturation and config drift 2026-09-27 10:40:16 -06:00
BenCodez 1cf9b7cba3 Harden deferred reward replay failure handling 2026-09-27 10:32:46 -06:00
BenCodez 8fa44a5211 Merge master and retire completed legacy deliveries 2026-09-27 10:23:07 -06:00
BenCodez cb4fc50662 Fix NeoForge reward replay validation 2026-09-27 10:15:01 -06:00
BenCodez fb5d8a0fcd Merge remote-tracking branch 'origin/master' into security/vote-delay-stable-identity-20260926 2026-09-27 10:07:56 -06:00
Ben 39f2af47e1 Merge pull request #1646 from BenCodez/security/bounded-vote-delivery-state-20260926
Bound backend vote replay state
2026-09-27 10:06:48 -06:00
Ben c86d803057 Merge pull request #1638 from BenCodez/security/shared-transport-auth-20260926
Authenticate and optionally encrypt proxy transport messages
2026-09-27 10:02:26 -06:00
Ben d010719b0f Merge pull request #1643 from BenCodez/security/service-site-inert-20260926
Keep external vote service identifiers inert
2026-09-27 10:01:23 -06:00
Ben 60aa4d53ab Merge pull request #1642 from BenCodez/security/gui-authorization-20260926
Enforce consistent GUI authorization
2026-09-27 09:59:00 -06:00
BenCodez f2d8860b97 Add initial NeoForge reward replay 2026-09-27 09:55:40 -06:00
Ben ffb2773766 Merge pull request #1647 from BenCodez/codex/webhook-bounded-admission
Bound webhook admission and retries
2026-09-27 09:55:29 -06:00
Ben f79e392001 Merge pull request #1641 from BenCodez/security/workflow-hardening-20260926
Harden GitHub Actions permissions and inputs
2026-09-27 09:53:44 -06:00
BenCodez 0000118d6c Preserve service identifiers in reward actions 2026-09-27 08:31:45 -06:00
BenCodez bbe12cc4ff Preserve unknown proxy delay validation state 2026-09-27 07:54:51 -06:00
BenCodez deb6433113 Persist semantic HTTP deliveries before encryption 2026-09-26 23:31:20 -06:00
BenCodez 6566281c15 Reclaim expired vote reservations before saturation 2026-09-26 23:14:49 -06:00
BenCodez a3298b6029 Preserve delay validation across proxy forwarding 2026-09-26 23:07:00 -06:00
BenCodez 1f988459cb Guard service names at formatting boundaries 2026-09-26 22:46:20 -06:00
BenCodez 27d41a0984 Preserve legacy proxy vote identities 2026-09-26 22:40:06 -06:00
BenCodez 893fda2a9f Use durable receipt capacity for vote admission 2026-09-26 22:35:24 -06:00
BenCodez 6d55b77e41 Preserve safe encoded service identifiers 2026-09-26 22:25:19 -06:00
BenCodez 208609515c Reject stale VoteShop items after reload 2026-09-26 22:20:56 -06:00
BenCodez 24ecff6a52 Persist proxy vote delay decisions 2026-09-26 22:11:01 -06:00
BenCodez 3942f04c5d Redact webhook URL credentials in logs 2026-09-26 21:55:50 -06:00
BenCodez f9a6aab5e8 Avoid full replay-cache scans on duplicates 2026-09-26 21:54:34 -06:00
BenCodez 7cff82ffc2 Reject stale VoteShop category purchases 2026-09-26 21:52:51 -06:00
BenCodez 574fabbb4f Reject executable service formatting tokens 2026-09-26 21:51:51 -06:00
BenCodez 280d8186b4 Fix vote-delay delivery compatibility 2026-09-26 21:42:29 -06:00
BenCodez f76884bc80 Use durable vote identity for vote-delay processing 2026-09-26 21:33:11 -06:00
BenCodez 723ab02ce0 Verify release tags before Javadoc deployment 2026-09-26 21:17:57 -06:00
BenCodez 39e5a641a4 Recheck VoteShop category access at purchase time 2026-09-26 21:15:08 -06:00
BenCodez 3b078dab3e Reject ambiguous encoded placeholder delimiters 2026-09-26 21:11:42 -06:00
BenCodez 8b3a5adbb4 Preserve compatible mixed receipt journals 2026-09-26 21:08:15 -06:00
BenCodez 25bb1b4256 Log terminal webhook delivery failures 2026-09-26 21:05:27 -06:00
BenCodez 4be8c974c4 Apply communication encryption to HTTP envelopes 2026-09-26 20:35:11 -06:00
BenCodez e6091c4be5 Expire compatibility bridge fences after processing 2026-09-26 20:25:52 -06:00
BenCodez a6e4a08122 Keep compatibility bridge copies fenced 2026-09-26 20:18:25 -06:00
BenCodez 7a08f9b1ee Keep multi-proxy transport security generation stable 2026-09-26 20:10:41 -06:00
BenCodez f6c275a264 Deduplicate bridged legacy vote IDs 2026-09-26 19:56:34 -06:00
BenCodez 7ff0b0c2bc Preserve durable HTTP delivery across key changes 2026-09-26 19:37:43 -06:00
BenCodez 7fbf3360a9 Use one inbound transport security snapshot 2026-09-26 19:21:06 -06:00
BenCodez a15b2bce1b Retire reused Redis callbacks on proxy reload 2026-09-26 19:01:39 -06:00
BenCodez 94c08cce32 Apply shared backend security atomically 2026-09-26 18:47:15 -06:00
BenCodez 18db8cfcac Preserve optional transport security compatibility 2026-09-26 18:32:14 -06:00
BenCodez 74fc0006fb Preserve transport security across rollback 2026-09-26 18:13:55 -06:00
Ben ffcbafdc03 Merge pull request #1645 from BenCodez/fix-proxy-voteparty-offline-state
Fix proxy offline votes counting toward VoteParty
2026-09-26 18:01:22 -06:00
Ben 6d7eed5cce Merge pull request #1648 from BenCodez/feature/automatic-time-change-controls
Add granular automatic time change controls
2026-09-26 17:58:48 -06:00
Ben b56ad17144 Fix proxy offline votes counting toward VoteParty 2026-09-26 17:57:21 -06:00
BenCodez 96f6c64112 Merge remote-tracking branch 'origin/master' into security/shared-transport-auth-20260926
# Conflicts:
#	AGENTS.md
2026-09-26 17:56:08 -06:00
Ben f3aa70c9ab Keep ban cache refresh when automatic rollover is disabled 2026-09-26 17:54:51 -06:00
Ben f036fca34e Fix backend config diff check 2026-09-26 17:49:57 -06:00
Ben 63a4196ee8 Fix automatic time change review findings 2026-09-26 17:46:50 -06:00
Ben 817d9fee66 Add automatic time change controls 2026-09-26 17:40:23 -06:00
BenCodez fc123b7bcf Bound backend vote replay state 2026-09-26 17:33:38 -06:00
BenCodez 81a12f5cdd Bound webhook admission and retries 2026-09-26 17:31:50 -06:00
Ben 3f7427ad35 Merge pull request #1644 from BenCodez/agents-compatibility-guardrails-20260926
Require drop-in compatible VotingPlugin upgrades
2026-09-26 17:19:30 -06:00
Ben fe812a8044 docs: enforce compatibility-first agent guidance 2026-09-26 17:12:24 -06:00
BenCodez ccc1052272 Keep external vote service identifiers inert 2026-09-26 16:56:02 -06:00
BenCodez fed067f200 Reload proxy transport encryption policy 2026-09-26 16:48:10 -06:00
BenCodez 6a0195cd49 Enforce GUI authorization consistently 2026-09-26 16:28:21 -06:00
BenCodez 6ebbdfb3ee Harden VotingPlugin GitHub workflows 2026-09-26 16:26:01 -06:00
BenCodez 41da204190 Authenticate shared proxy transport messages 2026-09-26 15:39:27 -06:00
Ben d12d0f3584 Merge pull request #1639 from BenCodez/codex/neoforge-deferred-completion-20260926
Add durable NeoForge deferred-vote completion
2026-09-26 15:07:48 -06:00
BenCodez 839f63b0b4 Add durable NeoForge deferred vote completion 2026-09-26 14:56:40 -06:00
Ben e86a88279c Merge pull request #1621 from BenCodez/codex/guaranteed-proxy-delivery
Add durable proxy vote delivery
2026-09-26 13:11:07 -06:00
Ben c760b8b6fa Merge pull request #1637 from BenCodez/codex/neoforge-deferred-votes-20260926
Persist deferred NeoForge votes
2026-09-26 13:08:32 -06:00
Ben 388099c67d Merge pull request #1636 from BenCodez/codex/neoforge-accepted-vote-processor-20260926
Add NeoForge accepted-vote processor
2026-09-26 13:06:37 -06:00
BenCodez cdc99d8cea Isolate malformed deferred vote queues 2026-09-26 12:03:09 -06:00
BenCodez 77017a931b Defer completed vote retirement design 2026-09-26 11:54:23 -06:00
BenCodez 07f8038543 Reject malformed deferred vote text 2026-09-26 11:50:27 -06:00
BenCodez 687e141cbf Bound and stabilize deferred vote retries 2026-09-26 11:46:17 -06:00
BenCodez 6f69d71f0d Persist deferred NeoForge votes 2026-09-26 11:32:24 -06:00
BenCodez ffbad36675 Add NeoForge accepted-vote processor 2026-09-26 11:12:03 -06:00
Ben 850e1978d3 Merge pull request #1635 from BenCodez/codex/neoforge-persistent-accounting-20260926
Add persistent NeoForge vote accounting
2026-09-26 10:55:28 -06:00
BenCodez 2b2cd46c08 Add persistent NeoForge vote accounting 2026-09-26 10:51:09 -06:00
Ben 6c707902aa Merge pull request #1634 from BenCodez/codex/neoforge-vote-site-config-20260926
Add NeoForge vote-site configuration adapter
2026-09-26 10:37:54 -06:00
BenCodez ce6c8512b9 Add NeoForge vote-site configuration adapter 2026-09-26 10:13:14 -06:00
Ben e8560ca6ee Merge pull request #1633 from BenCodez/workflow/pre-push-review-gate-20260926
Require local pre-push code review gate
2026-09-26 09:40:50 -06:00
Ben c3fd10825f Address review feedback on pre-push gate 2026-09-26 09:35:02 -06:00
Ben 9f7840fe11 Require pre-push local review gate 2026-09-26 09:20:48 -06:00
BenCodez b8981c964d Size receipt tombstones for sustained voting 2026-09-25 11:56:42 -06:00
BenCodez 942abae8d3 Persist rejected legacy vote attempts 2026-09-25 11:49:36 -06:00
BenCodez e8f3ae0356 Persist legacy vote attempt fences 2026-09-25 11:41:23 -06:00
BenCodez 693d78ecdb Repair durable vote journals after failed appends 2026-09-25 11:30:23 -06:00
BenCodez 3cdaf10f6d Fence accepted legacy vote retries 2026-09-25 11:20:18 -06:00
BenCodez d585a950ea Deduplicate reliable vote quarantine retries 2026-09-25 11:11:27 -06:00
BenCodez fced137fcd Reserve proxy outbox terminal record capacity 2026-09-25 10:59:31 -06:00
BenCodez 97861b2b63 Journal votes during capability negotiation 2026-09-25 10:46:42 -06:00
BenCodez 7bf6a50fb2 Keep receipt persistence off rejected scheduler lane 2026-09-25 10:38:38 -06:00
BenCodez 8890e60d2e Preserve ordering for unknown receipt releases 2026-09-25 10:32:46 -06:00
BenCodez a72a09387b Bound reliable delivery retries 2026-09-25 10:19:50 -06:00
BenCodez 778c6155fa Enforce receipt tombstone capacity 2026-09-25 10:17:58 -06:00
BenCodez cbd364be46 Keep receipt release progress bounded 2026-09-25 10:17:58 -06:00
BenCodez 2297f756ca Fence late vote retries after receipt release 2026-09-25 10:17:58 -06:00
BenCodez 63f4ba586f Keep capability probes quiet 2026-09-25 10:17:58 -06:00
BenCodez be3953356f Add durable vote receipt retirement 2026-09-25 10:17:58 -06:00
BenCodez fe0c5c7d65 Distinguish completed and quarantined vote deliveries 2026-09-25 10:17:57 -06:00
BenCodez b015ae58a7 Close remaining delivery guarantee gaps 2026-09-25 10:17:57 -06:00
BenCodez 8250222763 Complete capability discovery and downgrade delivery 2026-09-25 10:17:57 -06:00
BenCodez bb10a56cac Handle delivery downgrade and journal recovery 2026-09-25 10:17:57 -06:00
BenCodez 816b72d435 Add durable proxy vote delivery 2026-09-25 10:17:57 -06:00
Ben 4a589b31d8 Merge pull request #1632 from BenCodez/codex/mcht-build2350-fixes-20260924
Fix MCHT proxy lifecycle and reward failures
2026-09-25 10:11:20 -06:00
BenCodez b45e19a387 Reopen legacy ForceUpdate acknowledgments 2026-09-25 05:09:02 -06:00
BenCodez 7211babd0b Bind ForceUpdate acknowledgments to requests 2026-09-25 05:02:30 -06:00
BenCodez ac94134549 Bound reminder rollback with storage lifecycle 2026-09-25 04:54:46 -06:00
BenCodez b7408b9564 Make reminder rollback shutdown retryable 2026-09-24 22:39:47 -06:00
BenCodez 86a3563ac0 Fix shutdown reminder and legacy reward races 2026-09-24 22:31:37 -06:00
BenCodez ba7255bf8a Fence ForceUpdate acknowledgments and reminder rollback 2026-09-24 22:20:38 -06:00
BenCodez 38adff36b6 Retry global time-change finalization 2026-09-24 22:05:31 -06:00
BenCodez e5d4e10101 Fence global-data work across runtime retirement 2026-09-24 21:56:51 -06:00
BenCodez 50d991fbf6 Address reminder and period lifecycle review findings 2026-09-24 21:08:58 -06:00
BenCodez 1830fc83cf Reserve reminder delivery before entity handoff 2026-09-24 20:47:32 -06:00
BenCodez 33b8a1b67a Fence reminder delivery and rollover handoff 2026-09-24 20:38:27 -06:00
BenCodez a5f7bbaa72 Coordinate global-data work across runtime replacement 2026-09-24 20:10:39 -06:00
BenCodez a86278a662 Keep time-change completions routed across reloads 2026-09-24 19:52:15 -06:00
BenCodez 0c00711670 Keep time-change handoff clear of transport I/O 2026-09-24 19:08:39 -06:00
BenCodez 485cdeaa53 Close remaining lifecycle settlement races 2026-09-24 18:59:07 -06:00
BenCodez 69679b459d Complete proxy lifecycle and VoteShop settlements safely 2026-09-24 18:38:20 -06:00
BenCodez 64edda43da Preserve VoteShop failure completion semantics 2026-09-24 18:19:30 -06:00
BenCodez efbc9b82f2 Complete proxy reward lifecycle fixes 2026-09-24 18:03:40 -06:00
BenCodez dbf4a8d8a5 Fence proxy time changes across runtime close 2026-09-24 17:30:46 -06:00
BenCodez 4a137fd40f Fix proxy lifecycle reward execution failures 2026-09-24 17:05:30 -06:00
Ben 69064fedd3 Merge pull request #1518 from BenCodez/dependabot/maven/VotingPlugin/org.apache.maven.plugins-maven-resources-plugin-3.5.0
Bump org.apache.maven.plugins:maven-resources-plugin from 3.4.0 to 3.5.0 in /VotingPlugin
2026-09-24 16:28:17 -06:00
Ben 89b3d1d2a2 Merge pull request #1514 from BenCodez/dependabot/maven/VotingPlugin/org.apache.maven.plugins-maven-compiler-plugin-3.15.0
Bump org.apache.maven.plugins:maven-compiler-plugin from 3.13.0 to 3.16.0 in /VotingPlugin
2026-09-24 16:28:03 -06:00
Ben f875970876 Merge pull request #1516 from BenCodez/dependabot/maven/VotingPlugin/org.projectlombok-lombok-1.18.46
Bump org.projectlombok:lombok from 1.18.42 to 1.18.48 in /VotingPlugin
2026-09-24 16:27:44 -06:00
Ben 672b979de2 Merge pull request #1620 from BenCodez/codex/reduce-votingplugin-jar
Reduce shaded VotingPlugin JAR size
2026-09-24 16:26:21 -06:00
dependabot[bot] 43474e669b Bump org.apache.maven.plugins:maven-compiler-plugin in /VotingPlugin
Bumps [org.apache.maven.plugins:maven-compiler-plugin](https://github.com/apache/maven-compiler-plugin) from 3.13.0 to 3.16.0.
- [Release notes](https://github.com/apache/maven-compiler-plugin/releases)
- [Commits](https://github.com/apache/maven-compiler-plugin/compare/maven-compiler-plugin-3.13.0...maven-compiler-plugin-3.16.0)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-compiler-plugin
  dependency-version: 3.15.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-24 22:16:18 +00:00
dependabot[bot] 0344105adb Bump org.apache.maven.plugins:maven-resources-plugin in /VotingPlugin
Bumps [org.apache.maven.plugins:maven-resources-plugin](https://github.com/apache/maven-resources-plugin) from 3.4.0 to 3.5.0.
- [Release notes](https://github.com/apache/maven-resources-plugin/releases)
- [Commits](https://github.com/apache/maven-resources-plugin/compare/v3.4.0...maven-resources-plugin-3.5.0)

---
updated-dependencies:
- dependency-name: org.apache.maven.plugins:maven-resources-plugin
  dependency-version: 3.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-24 22:16:11 +00:00
dependabot[bot] 810e7b0b51 Bump org.projectlombok:lombok from 1.18.42 to 1.18.48 in /VotingPlugin
Bumps [org.projectlombok:lombok](https://github.com/projectlombok/lombok) from 1.18.42 to 1.18.48.
- [Changelog](https://github.com/projectlombok/lombok/blob/master/doc/changelog.markdown)
- [Commits](https://github.com/projectlombok/lombok/compare/v1.18.42...v1.18.48)

---
updated-dependencies:
- dependency-name: org.projectlombok:lombok
  dependency-version: 1.18.46
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-24 22:16:05 +00:00
BenCodez 0474c8b7ef Correct the packaging decision record 2026-09-24 12:28:43 -06:00
BenCodez 84cb2c3e47 Make SQLite native provisioning reload safe 2026-09-24 12:15:58 -06:00
BenCodez 4c1821f935 Reduce packaged runtime to under 10 MiB 2026-09-24 10:26:57 -06:00
BenCodez 98637a2926 Preserve Jedis module linkage 2026-09-24 08:23:40 -06:00
BenCodez 5626aa7a55 Restore offline runtime dependencies 2026-09-24 08:13:22 -06:00
BenCodez 1a31b88e01 Load large runtime libraries on demand 2026-09-23 22:58:47 -06:00
BenCodez 6e4bf529d6 Merge remote-tracking branch 'origin/master' into codex/reduce-votingplugin-jar 2026-09-23 22:40:36 -06:00
Ben 0f5aff7568 Merge pull request #1629 from BenCodez/fix/background-task-thread-boundaries
Separate background storage and platform work
2026-09-23 20:47:49 -06:00
BenCodez a03ee6643a Bound background storage refresh admission 2026-09-23 20:30:25 -06:00
BenCodez 2d047c76a6 Preserve refresh requests after snapshot failure 2026-09-23 19:38:46 -06:00
BenCodez 0a509b5fb8 Keep periodic user refresh off vote executor 2026-09-23 19:35:11 -06:00
Ben 25586baad9 Merge pull request #1631 from BenCodez/fix/bulk-admin-storage-threading
Move bulk admin storage work off platform threads
2026-09-23 19:32:08 -06:00
BenCodez 5ee108f752 Bound bulk player capture work 2026-09-23 19:24:08 -06:00
BenCodez dcd9a8ecda Bound cancelled background snapshot waits 2026-09-23 19:20:17 -06:00
BenCodez f4342bf1bd Move bulk set-points lookup off command thread 2026-09-23 19:05:05 -06:00
BenCodez 8c8d118095 Complete refresh snapshots after entity retirement 2026-09-23 19:00:27 -06:00
BenCodez 535eff5b88 Keep background scans off vote executor 2026-09-23 18:46:05 -06:00
BenCodez 9eb69ff601 Finish bulk admin platform handoffs 2026-09-23 18:40:29 -06:00
BenCodez 1d778fa897 Finish background refresh thread boundaries 2026-09-23 18:38:37 -06:00
Ben 0b90ca3e9b Merge pull request #1625 from BenCodez/codex/control-backend-enrollment-20260922
Securely auto-enroll backends across proxy transports
2026-09-23 18:31:08 -06:00
Ben 699817daee Merge pull request #1628 from BenCodez/fix/vote-reminder-thread-boundaries
Separate vote reminder platform and storage work
2026-09-23 18:22:38 -06:00
Ben ab0c755adc Merge pull request #1630 from BenCodez/fix/force-update-cache-clear-completion
Wait for shared cache clear before force update
2026-09-23 18:21:46 -06:00
Ben 6e459bbdad Separate background storage and platform work 2026-09-23 18:17:02 -06:00
Ben d44c17dcda Separate reminder platform and storage work 2026-09-23 18:15:26 -06:00
BenCodez 37fcf39908 Authenticate Control enrollment responses 2026-09-23 18:07:36 -06:00
Ben ad98b727bb Move bulk admin storage work off platform threads 2026-09-23 18:06:49 -06:00
BenCodez b524ba2385 Merge remote-tracking branch 'origin/master' into codex/control-backend-enrollment-20260922 2026-09-23 17:59:00 -06:00
Ben d7149b2af7 Wait for shared cache clear before force update 2026-09-23 16:59:45 -06:00
Ben 053c5d4c93 Merge pull request #1627 from BenCodez/fix/shared-mysql-cache-lock-inversion
Fix shared point cache lock inversion
2026-09-23 16:26:58 -06:00
Ben 5ed37bd159 Fix shared point cache lock inversion 2026-09-23 16:18:53 -06:00
Ben 31bb5efafd Merge pull request #1624 from BenCodez/codex/placeholder-worker-safety-20260922
Make placeholder cache updates safe on the user-storage worker
2026-09-23 04:26:51 -06:00
BenCodez 290f058a63 Complete all-player cache updates after quit 2026-09-22 22:28:12 -06:00
BenCodez 62c12392af Register presence listeners before snapshot 2026-09-22 22:20:30 -06:00
BenCodez 49cbb6b0c1 Keep online presence on Bukkit identity 2026-09-22 22:11:40 -06:00
BenCodez 2e79d81e0f Scope placeholder update ordering 2026-09-22 22:08:58 -06:00
BenCodez 986a3c13c3 Guard quit cache cleanup against reconnects 2026-09-22 21:57:50 -06:00
BenCodez ef0aba21ba Order placeholder presence lifecycle updates 2026-09-22 21:48:36 -06:00
BenCodez 5e3c303c0a Restore storage identities during presence refresh 2026-09-22 21:33:51 -06:00
BenCodez 5040bc6571 Resolve placeholder reloads with storage identity 2026-09-22 21:29:31 -06:00
BenCodez 35886d94bc Preserve placeholder presence ownership across reload 2026-09-22 21:18:35 -06:00
BenCodez 8b83238f58 Snapshot vote eligibility before placeholder scheduling 2026-09-22 21:05:07 -06:00
BenCodez bcf40b0655 Authenticate backend enrollment off the server thread 2026-09-22 20:07:15 -06:00
BenCodez 1980befd80 Preserve placeholder ownership across login 2026-09-22 19:41:25 -06:00
BenCodez 564312902d Harden placeholder presence lifecycle handling 2026-09-22 19:19:49 -06:00
BenCodez 6402e23786 Secure backend Control auto-enrollment across transports 2026-09-22 18:58:28 -06:00
BenCodez 438490adfd Make placeholder cache updates storage-worker safe 2026-09-22 17:52:12 -06:00
BenCodez afaf703288 Trim unused crypto payloads 2026-09-22 00:35:20 -06:00
Ben 298754ce56 Merge pull request #1622 from BenCodez/codex/lifecycle-shutdown-20260921
Fix proxy shutdown lifecycle handling
2026-09-21 17:30:58 -06:00
coderabbitai[bot] f3cdb3f956 Preserve exception details in ordered proxy vote overflow error logs 2026-09-21 23:22:19 +00:00
BenCodez 38600063a7 Fix proxy shutdown lifecycle handling 2026-09-21 17:06:29 -06:00
BenCodez 4c4f20c58c Correct JAR packaging documentation 2026-09-21 05:12:58 -06:00
BenCodez 9bd6da2178 Reduce shaded VotingPlugin jar size 2026-09-21 04:59:53 -06:00
Ben 71fa9a2aad Merge pull request #1619 from BenCodez/codex/mcht-vote-threading-votestreak-fix
Fix proxy vote threading and VoteStreak SQL key registration
2026-09-20 22:27:36 -06:00
BenCodez c18b8c3dee Preserve invalid overflow records on recovery 2026-09-20 22:20:26 -06:00
BenCodez f1303280e1 Move overflow persistence off ingress callbacks 2026-09-20 22:11:14 -06:00
BenCodez ede8b7386f Retain ambiguous votes across bounded shutdown 2026-09-20 21:55:04 -06:00
Ben bfe27a4ce2 Merge pull request #1618 from BenCodez/codex/neoforge-bootstrap
Add minimal NeoForge bootstrap
2026-09-20 21:43:49 -06:00
BenCodez 3c08f9f928 Persist vote completion and bound proxy shutdown 2026-09-20 21:37:24 -06:00
BenCodez 37b610aad3 Durably admit overflow votes and quarantine ambiguous failures 2026-09-20 21:15:12 -06:00
BenCodez a16c3402a2 Retain failed vote updates and fence ordered shutdown 2026-09-20 20:41:13 -06:00
Ben 57d7f10160 Bound and hand off ordered proxy vote backlog 2026-09-20 20:25:29 -06:00
Ben 53612df2bd Fix ordered proxy vote dispatch and VoteStreak SQL keys 2026-09-20 20:04:47 -06:00
BenCodez 8e6e84d5a9 Keep platform SLF4J binding and close bootstrap tests 2026-09-20 19:58:00 -06:00
BenCodez 55b5466b77 Add minimal NeoForge bootstrap 2026-09-20 19:40:20 -06:00
Ben 558763ac0a Merge pull request #1617 from BenCodez/codex/shared-vote-accepted-path
Move Bukkit vote processing into shared core
2026-09-20 19:01:20 -06:00
coderabbitai[bot] ad8b80b7e4 Skip monthly vote limit enforcement for proxy votes without text totals 2026-09-21 00:34:31 +00:00
BenCodez edea5960c0 Move Bukkit vote sequence into shared core processor 2026-09-20 16:57:19 -06:00
BenCodez 4402cf91ff Use shared vote decisions in accepted Bukkit path 2026-09-20 16:41:46 -06:00
Ben f2091768a2 Merge pull request #1616 from BenCodez/codex/voting-core-no-receipts
Keep shared voting core inside VotingPlugin without receipt contract
2026-09-20 16:21:24 -06:00
BenCodez 5ced4bd758 Keep shared vote code in core.vote subpackage 2026-09-20 16:17:45 -06:00
BenCodez 7e645c6888 Keep shared vote core in plugin without receipt contract 2026-09-20 16:09:51 -06:00
Ben 0930a85f5a Merge pull request #1614 from BenCodez/codex/use-shared-vote-policy
Use shared vote counting policy in Bukkit listener
2026-09-20 15:40:14 -06:00
BenCodez 265a1d709b Use shared vote counting policy in Bukkit listener 2026-09-20 15:31:44 -06:00
Ben 28e45e91da Merge pull request #1613 from BenCodez/codex/mex-project-memory
Add MEX project memory and Codex integration
2026-09-20 15:11:32 -06:00
BenCodez f7b4ab6422 docs: align proxy method capability guide 2026-09-20 15:00:14 -06:00
BenCodez 2e935855c1 docs: correct MEX project context 2026-09-20 14:55:15 -06:00
BenCodez 5f629660f4 chore: add MEX project memory 2026-09-20 14:25:32 -06:00
Ben 460887368d Merge pull request #1594 from BenCodez/codex/http-transport
Add secure single-port HTTP proxy transport
2026-09-19 21:26:20 -06:00
BenCodez f95f2a34de Normalize HTTP proxy capability selection 2026-09-19 20:50:00 -06:00
BenCodez 88e785870f Keep live vote timestamps stable across retries 2026-09-19 20:31:03 -06:00
BenCodez 4c99045949 Preserve deferred proxy method and gate HTTP previews 2026-09-19 20:12:43 -06:00
Ben fda281652d Merge pull request #1608 from BenCodez/codex/shared-vote-processing-core
Add platform-neutral shared vote-processing core
2026-09-19 20:00:46 -06:00
Ben 03251519a4 Merge pull request #1610 from BenCodez/codex/fix-control-null-comments
Fix Control apply rollback on blank YAML comments
2026-09-19 19:20:56 -06:00
Ben e89e60bd06 Merge pull request #1609 from BenCodez/codex/control-verified-update-staging
Enable verified VotingPlugin staging from Control
2026-09-19 19:20:41 -06:00
BenCodez bc9dac6303 Require proxy method v2 for HTTP quick apply 2026-09-19 19:10:16 -06:00
BenCodez 43fbf0b8a0 Continue rollover queue after retryable auxiliary broadcast 2026-09-19 18:55:44 -06:00
BenCodez d3d46fd932 Integrate deployment hardening and package HTTP transport classes 2026-09-19 18:36:35 -06:00
BenCodez 1c592fcf38 Merge current master into secure HTTP transport 2026-09-19 18:19:53 -06:00
BenCodez 0c5b725b1a Require atomic deployment publication 2026-09-19 18:13:57 -06:00
BenCodez 9bd840f8bc Reject unsafe Bukkit update targets and unknown deployment fields 2026-09-19 18:05:56 -06:00
BenCodez dd119ed7cf Merge current master into blank-comment fix
# Conflicts:
#	VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java
2026-09-19 18:01:05 -06:00
BenCodez ef2be5253c Publish deployment marker before proxy activation 2026-09-19 17:57:38 -06:00
BenCodez 5766dbf83c Recognize verified staged artifact across deployment retries 2026-09-19 17:50:11 -06:00
BenCodez fa14482c86 Merge current master into verified update staging 2026-09-19 17:46:37 -06:00
Ben 6e8cab0ee5 Merge pull request #1600 from BenCodez/security/atomic-vote-shop
Make vote-shop debits atomic before granting rewards
2026-09-19 17:43:53 -06:00
BenCodez d5811f2ab3 Settle nullable transfer credits and recover quoted point columns 2026-09-19 16:46:41 -06:00
BenCodez 8498622ffc Keep MySQL transfers off server lanes and pin queued point columns 2026-09-19 16:38:16 -06:00
BenCodez 765f2e7e0e Confirm all MySQL shop debits and atomic per-server credits 2026-09-19 16:23:58 -06:00
BenCodez c650501f04 Serialize per-server MySQL point mutations with journals 2026-09-19 16:12:58 -06:00
BenCodez 01adaf394e Credit nullable first-use per-server points atomically 2026-09-19 15:43:41 -06:00
BenCodez 2376b64d7a Dispatch per-server point hook on entity lane and ensure column 2026-09-19 15:39:07 -06:00
BenCodez 0998dac18a Accept quoted legacy per-server points columns 2026-09-19 15:26:48 -06:00
BenCodez dc3875f509 Merge current master into PR 1600
# Conflicts:
#	VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java
#	VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java
2026-09-19 15:19:49 -06:00
BenCodez f3cd9eaaeb Settle PerServer reward points atomically with shared journal 2026-09-19 15:19:28 -06:00
BenCodez b12bf791b1 Complete queued proxy deployment on connector shutdown 2026-09-19 15:07:33 -06:00
BenCodez 5e4a20e144 Merge current master with deployment capability gates 2026-09-19 14:52:42 -06:00
Ben 4fd3b4396c Merge pull request #1612 from BenCodez/codex/control-named-reward-files
Control: add bounded named reward file support
2026-09-19 14:36:57 -06:00
BenCodez 0ce98c9a09 Preserve Control v1 read failure code 2026-09-19 13:29:01 -06:00
BenCodez 2856e343f3 Harden named reward recovery and capability reprobe 2026-09-19 13:15:10 -06:00
BenCodez 22ff678d5e Bound named reward directory scans 2026-09-19 12:58:12 -06:00
BenCodez 607e3a1ab4 Abort changed pending configuration before parsing 2026-09-19 12:52:27 -06:00
BenCodez 9c3a8deee1 Reject ambiguous named reward recovery 2026-09-19 12:34:50 -06:00
BenCodez 973837bda9 Keep named reward recovery and reads bounded 2026-09-19 12:19:31 -06:00
BenCodez 292a9b82a6 Complete named reward recovery and inspection failures 2026-09-19 12:02:36 -06:00
BenCodez 18699caa5c Tighten named reward inventory and publication 2026-09-19 11:35:11 -06:00
BenCodez 99deff1fbc Add bounded Control reward file support 2026-09-19 10:56:52 -06:00
Ben 11f20c39f5 Merge pull request #1611 from BenCodez/fix/offline-votegui-permission
Fix VoteGUI permissions in offline mode
2026-09-18 16:40:40 -06:00
Ben 4d1b7e226a Harden VoteGUI self permission check 2026-09-18 16:18:40 -06:00
Ben 70641af27a Fix VoteGUI permissions in offline mode 2026-09-18 15:58:35 -06:00
Ben a2b1381240 Fix verified deployment hardening build 2026-09-17 21:39:58 -06:00
Ben 632058fe3a Document verified deployment protocol 2026-09-17 18:15:01 -06:00
Ben f938c9f888 Document verified deployment protocol 2026-09-17 18:14:58 -06:00
Ben ad2c992279 Cover secure deployment and consumed Bukkit updates 2026-09-17 18:14:39 -06:00
Ben bca3ec4d4c Fence and secure proxy deployment polling 2026-09-17 18:14:27 -06:00
Ben 187db97bf3 Restrict backend deployment to active secure routes 2026-09-17 18:14:11 -06:00
Ben 3369cb847f Harden verified deployment transport and idempotency 2026-09-17 18:13:59 -06:00
Ben 25b9b870c0 Reproduce Control apply with blank comment metadata 2026-09-17 18:07:12 -06:00
Ben 5f7d9c721c Handle blank-line comment metadata in Control config masking 2026-09-17 18:05:58 -06:00
Ben 607129f31a Cover proxy deployment capability advertisement 2026-09-17 18:01:22 -06:00
Ben f623288b5b Expose deterministic proxy capability advertisement 2026-09-17 18:01:17 -06:00
Ben d89bdf5183 Enable verified Control staging on proxy nodes 2026-09-17 18:00:52 -06:00
Ben c3be440769 Cover deployment capability advertisement 2026-09-17 18:00:20 -06:00
Ben 157ddde85c Enable verified Control staging on Bukkit nodes 2026-09-17 18:00:12 -06:00
Ben dec7cd1604 Test verified VotingPlugin staging 2026-09-17 18:00:09 -06:00
Ben b3cac6f857 Add verified VotingPlugin staging service 2026-09-17 18:00:06 -06:00
BenCodez 288dff89a7 Keep transfer player access on the entity lane 2026-09-15 20:13:57 -06:00
BenCodez 6e4bebf81a Preserve point replay safety across mode changes 2026-09-15 19:59:08 -06:00
BenCodez 83470a225a Preserve authoritative bulk point validation 2026-09-15 19:31:30 -06:00
BenCodez ec0574ea73 Dispatch vote shop purchases synchronously 2026-09-15 19:12:17 -06:00
BenCodez 2e5e7d4a36 Release ambiguous unstarted point claims 2026-09-15 19:01:25 -06:00
BenCodez 5bae2bd656 Retire completed admin point operations 2026-09-15 18:47:39 -06:00
BenCodez 265d211b35 Avoid unnecessary cache monitor coupling 2026-09-15 18:28:29 -06:00
BenCodez 72d5183de1 Recover interrupted plugin activation 2026-09-15 18:27:30 -06:00
BenCodez 8818f29998 Serialize optimistic cache clearing 2026-09-15 18:24:34 -06:00
BenCodez 15eea849c3 Cover durable proxy lease retry 2026-09-15 18:07:47 -06:00
BenCodez 85e8cdf86e Protect offline point cache clearing 2026-09-15 18:04:29 -06:00
BenCodez 713e07c731 Harden durable proxy capability discovery 2026-09-14 17:10:14 -06:00
BenCodez f192886726 Harden shared point and shop recovery 2026-09-14 17:07:28 -06:00
BenCodez 0f0ed27118 Harden mixed-version proxy capability discovery 2026-09-14 16:17:50 -06:00
BenCodez 2e49dd6669 Persist multi-proxy capability peers 2026-09-13 20:03:24 -06:00
BenCodez 3ac1341b0c Recover point journals across mode changes 2026-09-13 19:58:39 -06:00
BenCodez 3304911e24 Use a stable vote shop reset zone 2026-09-13 19:33:04 -06:00
BenCodez a1fafd47a5 Keep HTTP delivery on verified capable peers 2026-09-13 19:17:47 -06:00
BenCodez 9d7a6c5123 Make shared point mutations durably confirmable 2026-09-13 19:10:29 -06:00
BenCodez 3adf47c4db Bound deployments and same-port HTTP changes 2026-09-13 18:16:10 -06:00
BenCodez 17080c041b Harden shared point mutation recovery 2026-09-13 18:03:39 -06:00
BenCodez b1664b2d57 Harden artifact reads and legacy proxy delivery 2026-09-13 17:56:37 -06:00
BenCodez 60fb262112 Harden mixed-version control deployment 2026-09-13 17:18:45 -06:00
BenCodez 4949ac9620 Exercise atomic vote receipt concurrency 2026-09-13 15:46:47 -06:00
BenCodez 044bef3429 Preserve mixed-version Vote Party settings 2026-09-13 15:33:41 -06:00
BenCodez d29b10784f Harden replay recovery and mixed-version setup 2026-09-13 15:13:03 -06:00
BenCodez 03dd3fbdb8 Version Vote Party configuration support 2026-09-13 14:44:43 -06:00
Ben de49ab1c13 Cover concurrent vote receipt races 2026-09-13 13:28:24 -06:00
Ben 24a8dca8aa Model atomic sentinel and proxy-route persistence 2026-09-13 13:26:42 -06:00
Ben b38e5105b5 Trust atomic vote receipt winner 2026-09-13 13:26:08 -06:00
Ben 5f25641f2e Preserve vote sentinel and proxy routing semantics 2026-09-13 13:25:50 -06:00
BenCodez 3acae78914 Keep global sync tests snapshot-compatible 2026-09-13 11:45:01 -06:00
BenCodez 0ee7950c98 Harden Control operations and backend scheduling 2026-09-13 11:14:38 -06:00
Ben 64697cb4e5 Persist normalized vote time and prepared reward version 2026-09-13 10:35:10 -06:00
Ben 0a0a7eb1e7 Use live online state for proxy vote totals 2026-09-13 10:09:34 -06:00
BenCodez 283c2b2c4f Harden proxy compatibility edge cases 2026-09-13 09:45:22 -06:00
BenCodez a2ad2e4518 Harden backend transport cleanup 2026-09-13 09:32:10 -06:00
Ben e231f62ee3 Make shared vote persistence and reward handoff recoverable by vote ID
Replace the mutation-only processing boundary with an atomic vote mutation
and pending receipt contract. Retry from the persisted receipt without
recounting or re-resolving identity; recover bounded pending batches and
acknowledge only after keyed reward completion or durable offline handoff.
Preserve existing public methods, but fail closed for unsafe legacy adapters
rather than silently recreating the persistence-to-reward crash window.

Production storage/replay ownership remains with the AdvancedCore-facing
adapters; no second SQL stack/cache/queue, native loader or Bukkit entry-path
wiring is added. Arbitrary external command exactly-once execution is not
claimed. Update the four existing end-to-end tests and add thirteen recovery,
lost-acknowledgement and concurrent-retry regressions using test-only atomic
file snapshots for the user transaction and keyed reward owner.

Local JDK21 production compilation and all seventeen regression methods
passed with a small assertion/annotation harness, not Maven/JUnit discovery.
Full repository build, real JUnit and fresh packaged artifact validation must
run in GitHub Actions. No live SQL/game-server integration result is claimed.
2026-09-12 23:43:06 -06:00
BenCodez 32f7c1b766 Harden presence and legacy multi-proxy retries 2026-09-12 23:05:03 -06:00
BenCodez 74ead43129 Make exclusive transport replacement rollback safe 2026-09-12 22:48:45 -06:00
BenCodez 20081a6d29 Preserve legacy multi-proxy delivery across restarts 2026-09-12 22:24:39 -06:00
BenCodez 76a65de3de Preserve mixed-proxy vote delivery 2026-09-12 22:07:04 -06:00
BenCodez 91108dda95 Harden mixed-version proxy delivery 2026-09-12 21:51:25 -06:00
BenCodez 64a983b34a Track epochs for every limited purchase 2026-09-12 21:40:03 -06:00
BenCodez a4f44eaf82 Retain timed SQL rows when deletion fails 2026-09-12 21:38:39 -06:00
BenCodez 36a5979c9e Document shared limit cache contract 2026-09-12 21:29:42 -06:00
BenCodez 12d328c9fc Keep pending proxy ACKs from blocking votes 2026-09-12 21:27:29 -06:00
BenCodez 8bc2e1c960 Fence all shared MySQL cache dumps 2026-09-12 21:15:53 -06:00
BenCodez a29828bedc Retry rejected live HTTP broadcasts 2026-09-12 21:12:44 -06:00
BenCodez 0647d07b2e Fence shared vote shop resets from cache dumps 2026-09-12 21:01:38 -06:00
BenCodez 17e16d01ba Persist retained HTTP listener settings durably 2026-09-12 20:53:49 -06:00
BenCodez b5d049041d Prepare transport disable off the server thread 2026-09-12 19:50:49 -06:00
BenCodez de0d3f742e Keep vote cache twins consistent 2026-09-12 19:39:34 -06:00
BenCodez 48185a74a6 Harden transport retirement and durable proxy state 2026-09-12 19:21:47 -06:00
BenCodez 19ed58be4e Keep staged Redis cleanup off the server thread 2026-09-12 19:03:20 -06:00
Ben f4d39a76b1 Test headless vote persistence and rewards across restart 2026-09-12 18:48:04 -06:00
Ben 429e624d94 Add platform-neutral vote processor 2026-09-12 18:47:35 -06:00
Ben 3047c9faa2 Add shared vote processing result 2026-09-12 18:47:26 -06:00
Ben e57adc8b10 Add shared vote processing policy 2026-09-12 18:47:22 -06:00
Ben 6bed0f0857 Add AdvancedCore-facing reward services port 2026-09-12 18:47:07 -06:00
Ben d0c185c476 Add AdvancedCore-facing vote user services port 2026-09-12 18:47:03 -06:00
Ben ea53c5c42e Add shared vote user snapshot 2026-09-12 18:46:57 -06:00
Ben 83305bfd87 Add shared vote persistence mutation 2026-09-12 18:46:53 -06:00
Ben 7c745354f3 Add shared vote identity resolver 2026-09-12 18:46:49 -06:00
Ben 2396efc0ce Add shared vote identity 2026-09-12 18:46:45 -06:00
Ben 555cabe391 Add platform-neutral vote input 2026-09-12 18:46:40 -06:00
BenCodez 8086691cb6 Preserve HTTP handoff queue during rollback 2026-09-12 18:40:52 -06:00
Ben aad0d0bb54 Merge pull request #1606 from BenCodez/codex/coordinated-jar-size
Reduce bundled dependency duplication
2026-09-12 18:02:55 -06:00
BenCodez e2213e20f6 Make size checks upstream compatible 2026-09-12 17:53:27 -06:00
BenCodez 3c6ae5adde Reduce bundled dependency duplication 2026-09-12 17:08:50 -06:00
BenCodez fa9f1a582f Keep cache expiry and transport shutdown scoped 2026-09-12 11:25:12 -06:00
BenCodez 98e8c797f9 Retain HTTP listener settings for queue recovery 2026-09-12 11:02:53 -06:00
BenCodez c3924c5590 Reuse retained HTTP listener on method revert 2026-09-12 09:27:03 -06:00
BenCodez ff3665842e Keep unpaired legacy JSON cache rows 2026-09-12 09:10:49 -06:00
BenCodez b7ab91fc8b Preserve distinct legacy timed cache rows 2026-09-12 08:51:16 -06:00
BenCodez dc0a0ccdc4 Scope online cache keys by player 2026-09-12 08:41:39 -06:00
BenCodez 325eb3bf6b Keep distinct legacy cross-store rows 2026-09-12 08:35:08 -06:00
BenCodez 58df2c8738 Preserve legacy cache row identities 2026-09-12 08:28:09 -06:00
BenCodez dbcdad9d1d Preserve distinct legacy timed vote rows 2026-09-12 08:10:13 -06:00
BenCodez 4122d62b16 Retire completed multi-proxy fences safely 2026-09-11 22:42:57 -06:00
BenCodez f5e6b06f77 Migrate legacy timed votes before reliable forwarding 2026-09-11 22:37:11 -06:00
BenCodez ecd70f386d Preserve durable multi-proxy retry fences 2026-09-11 22:23:22 -06:00
BenCodez ae66bc7957 Fix multi-proxy completion races 2026-09-11 21:55:12 -06:00
BenCodez 8f75535b4d Fix proxy retry durability and reload safety 2026-09-11 21:39:34 -06:00
BenCodez 68976d5f25 Harden indeterminate shared point operations 2026-09-11 05:42:37 -06:00
BenCodez 0da1c46084 Make multi-proxy vote delivery durable 2026-09-11 00:37:12 -06:00
BenCodez a404248d82 Settle live vote retries before shutdown 2026-09-10 22:34:51 -06:00
BenCodez d8fbbed94e Keep vote completion retries idempotent 2026-09-10 22:01:26 -06:00
BenCodez cbe604683d Preserve live vote retries across reload 2026-09-10 21:24:48 -06:00
BenCodez adcfa279d8 Make shared point rewards retry safe 2026-09-10 20:15:27 -06:00
BenCodez 3e5c168005 Make proxy vote delivery crash durable 2026-09-10 19:51:15 -06:00
BenCodez 7ed6948462 Await durable shared point rewards 2026-09-10 16:43:24 -06:00
Ben 3dec4333b5 Merge pull request #1602 from BenCodez/codex/improve-agent-review-guidance-20260910
Improve agent and code review guidance
2026-09-10 16:06:29 -06:00
Ben 222b38a97c Merge pull request #1604 from BenCodez/codex/player-all-perms-report
Report effective bulk command permissions
2026-09-10 16:05:24 -06:00
Ben 0da81abc83 Merge pull request #1603 from RapidGravy/feature/disabled-command-alias-unregistration
Unregister disabled standalone command aliases
2026-09-10 16:03:00 -06:00
Ben 1ff9a31c64 Merge pull request #1605 from RapidGravy/feature/votestreak-best-amount-placeholder
Add new vote streak best amount placeholder
2026-09-10 16:02:10 -06:00
BenCodez a6894fa28a Preserve partial review coverage 2026-09-10 11:01:44 -06:00
BenCodez b117c36b08 Harden HTTP shutdown and cache durability 2026-09-10 10:44:43 -06:00
BenCodez 18ff986162 Harden HTTP transition failure handling 2026-09-10 09:36:45 -06:00
Ben bd16c5e073 Preserve static-only review coverage 2026-09-10 09:14:30 -06:00
BenCodez 96c850408b Harden shared MySQL purchase completion 2026-09-10 09:02:08 -06:00
BenCodez 59b5e75c9d Preserve stopped presence during transport disable 2026-09-10 08:39:57 -06:00
BenCodez 058e8e7aa9 Fix deferred transport retirement 2026-09-10 06:24:59 -06:00
BenCodez bf8de86a77 Preserve proxy runtime during deferred handoffs 2026-09-10 01:53:22 -06:00
BenCodez 367f941b29 Complete atomic point transfer safeguards 2026-09-10 01:09:00 -06:00
BenCodez 38ae6f676c Complete bounded proxy transport handoffs 2026-09-10 00:49:21 -06:00
BenCodez 3c049f4a51 Align weekly reset generation with configured period 2026-09-09 22:41:04 -06:00
BenCodez f74a228a2e Avoid Redis handoff callback lock inversion 2026-09-09 22:32:47 -06:00
BenCodez e8e931632b Exclude optimistic points from cache dumps 2026-09-09 22:27:12 -06:00
BenCodez 5d29a7cee2 Require secure AdvancedCore bulk API 2026-09-09 22:16:02 -06:00
BenCodez c94842ee72 Harden vote shop recovery paths 2026-09-09 22:07:23 -06:00
BenCodez ba2c091f69 Replay Redis handoff after inbound activation 2026-09-09 22:06:50 -06:00
BenCodez 6472e664dd Report shared bulk command permissions 2026-09-09 22:01:26 -06:00
Ben 13a4ec6b9c Distinguish review completeness from findings 2026-09-09 21:15:11 -06:00
Ben 66b8a2be09 Improve agent runtime review invariants 2026-09-09 21:15:10 -06:00
BenCodez f1337fa6d4 Preserve deliveries across transport replacement 2026-09-09 20:55:42 -06:00
BenCodez c45f3ea0b2 Preserve proxy deliveries across transport changes 2026-09-09 20:01:06 -06:00
BenCodez 21ac3f9352 Harden shared point settlement and callback recovery 2026-09-09 18:27:03 -06:00
RapidGravy 3a3b0f6256 Unregister disabled standalone command aliases 2026-09-09 19:35:04 -04:00
BenCodez 874d62fa0a Harden HTTP handoff and broadcast recovery 2026-09-09 17:25:33 -06:00
BenCodez c687cbfe27 Handle rejected transfer settlement and cache races 2026-09-09 16:44:37 -06:00
BenCodez 87de933fab Fix shared vote shop recovery and cache refresh 2026-09-09 16:19:52 -06:00
BenCodez 6b87b9fe02 Avoid blocking vote shop placeholder reads 2026-09-09 12:04:29 -06:00
BenCodez b35abf4004 Recover rejected shared point operations 2026-09-09 10:46:16 -06:00
BenCodez e7fb2416e2 Keep shared shop validation off GUI lanes 2026-09-09 10:19:55 -06:00
BenCodez 35f3ac2a35 Handle rejected shared point submissions 2026-09-09 05:46:29 -06:00
BenCodez f3141f2d4f Wait for shared point reward persistence 2026-09-09 05:32:35 -06:00
BenCodez cb733347a2 Bound shared point and purchase recovery work 2026-09-09 05:08:46 -06:00
BenCodez a58168c848 Close shared vote shop recovery races 2026-09-08 22:01:39 -06:00
BenCodez 1bfde2417d Fix shared vote shop recovery races 2026-09-08 21:11:46 -06:00
BenCodez a2e011278e Avoid stale shared purchase cache writes 2026-09-08 18:54:58 -06:00
BenCodez 6fe7db7908 Reconcile caches after recovery refunds 2026-09-08 18:27:59 -06:00
BenCodez ce7443955b Fence vote-party work during transport changes 2026-09-08 18:25:46 -06:00
BenCodez 158d4030fb Preserve HTTP delivery state across retries 2026-09-08 17:26:22 -06:00
BenCodez a52a1ec771 Keep rejected transfer recovery off Bukkit 2026-09-08 17:17:18 -06:00
BenCodez e1aa435a3f Merge remote-tracking branch 'origin/master' into security/pr-1600-local
# Conflicts:
#	VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java
2026-09-08 17:06:59 -06:00
BenCodez bdd471bd79 Preserve shared point mutation state 2026-09-08 17:02:40 -06:00
BenCodez f77273094e Merge remote-tracking branch 'origin/master' into codex/http-transport
# Conflicts:
#	VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java
#	VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java
#	VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java
#	VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java
2026-09-08 16:55:39 -06:00
BenCodez a0ee827e63 Recover auxiliary HTTP vote deliveries 2026-09-08 16:39:38 -06:00
Ben c3ff999112 Merge pull request #1601 from BenCodez/security/bound-service-and-vote-work
Bound service-site downloads and queued vote work
2026-09-08 16:27:30 -06:00
Ben 034e39aae5 Merge pull request #1596 from BenCodez/codex/control-console-foundation
Add Control proxy configuration management
2026-09-08 16:26:28 -06:00
BenCodez 4928b10f0f Close shared point settlement edge cases 2026-09-08 14:08:56 -06:00
BenCodez 17a5bd581f Restore Redis transport cleanly on failed handoff 2026-09-08 14:08:26 -06:00
BenCodez e3e577402b Preserve proxy delivery across handoff and restart 2026-09-08 13:28:19 -06:00
BenCodez 58c5d0d02f Close shared point settlement edge cases 2026-09-08 12:45:48 -06:00
BenCodez 0a4913e207 Harden shared MySQL point completion and recovery 2026-09-08 11:50:17 -06:00
BenCodez 59e05f19e4 Preserve HTTP delivery across publication and shutdown 2026-09-08 11:07:45 -06:00
BenCodez 8d07e5a8d6 Make shared MySQL purchases failure-safe 2026-09-08 10:48:09 -06:00
BenCodez d3fc00ea1e Make backend transport replacement failure-atomic 2026-09-08 10:47:42 -06:00
BenCodez 1d091e847d Harden proxy connection field redaction 2026-09-08 10:22:07 -06:00
BenCodez 4249d8cb6a Preserve redaction ownership across YAML reorder 2026-09-08 09:33:47 -06:00
BenCodez 1310ed877d Harden proxy configuration redaction 2026-09-08 08:44:10 -06:00
BenCodez bc36371e25 Defer HTTP callbacks until handler publication 2026-09-08 08:30:37 -06:00
BenCodez 2c2ee8476d Handle additional control redaction cases 2026-09-08 06:12:12 -06:00
BenCodez 4d3f404857 Recover shared transfers and bound journal names 2026-09-08 06:01:27 -06:00
BenCodez a013543696 Harden proxy control redaction and result bounds 2026-09-08 05:50:11 -06:00
BenCodez 520aa142e7 Journal shared vote shop purchases 2026-09-08 05:35:20 -06:00
BenCodez 9e3432fda1 Mask database user identity fields 2026-09-08 05:13:03 -06:00
BenCodez 95fe0931f9 Bind proxy file intents to prepared snapshots 2026-09-08 04:43:59 -06:00
BenCodez dc25f953f7 Close shared point mutation consistency gaps 2026-09-08 04:27:51 -06:00
BenCodez a25f259424 Mask dotted credential field names 2026-09-08 04:06:46 -06:00
BenCodez ca387d7267 Release shared transfer hooks before callbacks 2026-09-08 04:00:00 -06:00
BenCodez e70425ba0a Allow safe redacted list suffix removal 2026-09-08 03:46:43 -06:00
BenCodez 307a9fe626 Harden redacted configuration edits 2026-09-08 03:23:12 -06:00
BenCodez 95471fc998 Harden configuration secret snapshots 2026-09-08 02:48:57 -06:00
BenCodez 05315db092 Redact infrastructure details from config comments 2026-09-08 02:21:37 -06:00
BenCodez 3892634650 Harden Control result route isolation 2026-09-08 01:13:17 -06:00
BenCodez c5ddb3c541 Reserve shared transfer points before hooks 2026-09-08 01:06:49 -06:00
BenCodez c71599cb40 Bind Control results to coordinator routes 2026-09-08 00:50:17 -06:00
BenCodez 7c715cf95c Fix HTTP transport replacement during enrollment 2026-09-07 23:57:53 -06:00
BenCodez e07be21403 Preserve purchase and transfer semantics 2026-09-07 23:20:33 -06:00
BenCodez f519c3bd29 Complete filtered proxy recovery safely 2026-09-07 23:12:22 -06:00
BenCodez 38ae0d6fd4 Preserve synchronous shared point updates 2026-09-07 22:50:17 -06:00
BenCodez f08dd1c3a3 fix: retry HTTP enrollment failures 2026-09-07 22:33:43 -06:00
BenCodez 37543d22af fix: defer durable vote recovery 2026-09-07 22:28:34 -06:00
BenCodez 219fb35774 fix: preserve proxy change identity 2026-09-07 22:22:31 -06:00
BenCodez ca4471d917 fix: make shared vote shop purchases atomic 2026-09-07 22:14:47 -06:00
BenCodez cc1db03ea3 Retry rejected time queue processing 2026-09-07 21:49:56 -06:00
BenCodez 804271e24a Keep HTTP transport retries active 2026-09-07 21:33:02 -06:00
BenCodez c984404e68 Merge remote-tracking branch 'origin/master' into codex/http-transport 2026-09-07 21:18:19 -06:00
BenCodez 866757e65e Make vote task overflow durable 2026-09-07 21:16:44 -06:00
BenCodez 1f206eab62 Merge remote-tracking branch 'origin/master' into security/pr-1601-local 2026-09-07 20:47:06 -06:00
BenCodez b8b695eaf9 Merge remote-tracking branch 'origin/master' into security/pr-1600-local 2026-09-07 20:44:34 -06:00
BenCodez ebfbc55036 Make concurrent debit regression deterministic 2026-09-07 18:56:37 -06:00
BenCodez 3e17e38919 Preserve HTTP messages during replacement 2026-09-07 18:31:37 -06:00
Ben 2d43f6f0be Address latest review feedback 2026-09-07 17:53:09 -06:00
Ben b088eaf0ba Address latest review feedback 2026-09-07 17:52:52 -06:00
Ben 6a5cd23d90 Address latest review feedback 2026-09-07 17:52:32 -06:00
BenCodez e6ba72cf59 Resume vote parties after effect retry 2026-09-07 17:45:33 -06:00
RapidGravy 66d75189d4 Add new vote streak best amount placeholder 2026-09-07 19:35:30 -04:00
Ben b14d024476 Address latest review feedback 2026-09-07 17:26:00 -06:00
Ben 69a0b99f0c Address latest review feedback 2026-09-07 17:25:46 -06:00
BenCodez 63277bcd88 Declare SimpleAPI HTTP dependency 2026-09-07 17:19:40 -06:00
Ben dd3a7701e9 Address review feedback with regression coverage 2026-09-07 17:17:10 -06:00
Ben 9c176fdb2c Address review feedback with regression coverage 2026-09-07 17:16:58 -06:00
BenCodez a88e705926 Use SimpleAPI HTTP transport 2026-09-07 17:15:21 -06:00
Ben c219d805ba Close bounded HTTP response streams 2026-09-07 16:53:32 -06:00
Ben 5024abe440 Close bounded HTTP response streams 2026-09-07 16:53:20 -06:00
Ben e335aec9ad Keep purchase locks stable across reloads 2026-09-07 16:53:09 -06:00
Ben a199326714 Keep purchase locks stable across reloads 2026-09-07 16:52:58 -06:00
Ben b0ec02325b Address resource-bound review feedback 2026-09-07 16:36:50 -06:00
Ben fccee28efe Address resource-bound review feedback 2026-09-07 16:36:37 -06:00
Ben f394d1b2fa Address resource-bound review feedback 2026-09-07 16:36:24 -06:00
Ben 7bfe60d4bc Address resource-bound review feedback 2026-09-07 16:36:09 -06:00
Ben 70fe42f378 Use stable purchase locks and async persistence 2026-09-07 16:35:57 -06:00
Ben 1d44c4b73f Use stable purchase locks and async persistence 2026-09-07 16:35:44 -06:00
Ben f86e298da3 Add resource-bound regression coverage 2026-09-07 15:39:01 -06:00
Ben 625eaf6b21 Add resource-bound regression coverage 2026-09-07 15:38:52 -06:00
Ben fe689ca81c Add resource-bound regression coverage 2026-09-07 15:38:44 -06:00
Ben c817fec06d Bound remote service data and vote work 2026-09-07 15:38:35 -06:00
Ben fd1b2b125f Bound remote service data and vote work 2026-09-07 15:38:21 -06:00
Ben 4b12ebf96b Test concurrent vote shop debits 2026-09-07 15:21:12 -06:00
Ben 6b69462cc0 Make vote shop debit atomic 2026-09-07 15:21:07 -06:00
BenCodez 48841b80d8 Fix durable vote-party effect recovery 2026-09-05 16:37:31 -06:00
BenCodez b6d0f774bd fix(control): harden transport rollback lifecycle 2026-09-04 18:18:53 -06:00
BenCodez bfea6f1b63 fix(control): defer plugin-message relay publication 2026-09-04 17:53:46 -06:00
BenCodez fa1296ea07 fix(http): flush presence on backend shutdown 2026-09-04 17:41:08 -06:00
BenCodez e23a9691d0 fix(http): defer replacement presence publication 2026-09-01 21:44:24 -06:00
BenCodez 7495519d61 fix(http): recover incomplete TLS provisioning 2026-09-01 20:26:29 -06:00
Ben f8113897b2 fix(http): cancel stalled body reads at deadline 2026-09-01 20:04:31 -06:00
Ben 01dea25564 fix(http): bound streamed response reads 2026-09-01 20:01:20 -06:00
Ben 7fec7976a4 test(http): normalize legacy code expiry precision 2026-09-01 19:51:05 -06:00
Ben c10236a620 fix(http): import vote-party collection helpers 2026-09-01 19:47:56 -06:00
Ben 59b08052fe fix(http): retain rejected vote-party rewards 2026-09-01 19:45:39 -06:00
Ben 9b9232b110 fix(http): preserve independent delivery retries 2026-09-01 18:43:24 -06:00
Ben 57517d7593 fix(http): restore credentials after failed reload 2026-09-01 17:32:42 -06:00
Ben c8b0116381 Merge master into codex/http-transport 2026-09-01 17:31:50 -06:00
Ben 425664be7c Restore HTTP transport after failed control reload 2026-09-01 04:48:52 -06:00
Ben 67e9d1262a fix(http): open proxy listener after runtime setup 2026-08-31 21:49:09 -06:00
Ben b7503b9eb8 fix(control): cancel stale transport publication 2026-08-31 21:41:49 -06:00
Ben f81aa90ba6 fix(http): validate readiness off the server thread 2026-08-31 21:30:53 -06:00
Ben 0e9cf5ac05 fix(test): disambiguate transport validation assertion 2026-08-31 21:18:00 -06:00
Ben dd1ef83f2c fix(ci): restore complete proxy source 2026-08-31 21:16:23 -06:00
Ben 5949e113bc fix(http): coordinate transport lifecycle handoffs 2026-08-31 21:12:40 -06:00
Ben 0a938cf3ad fix(http): persist transport directory entries 2026-08-31 20:53:47 -06:00
Ben b13ce28b45 test(http): await resumed delivery acknowledgement 2026-08-31 20:44:33 -06:00
Ben 9eae8b5a35 fix(http): bound responses and persist queue roots 2026-08-31 20:42:05 -06:00
Ben 2a9fcc6d84 test(http): import filesystem helper 2026-08-31 20:31:45 -06:00
Ben 6216a04cc3 fix(http): distinguish incomplete inbound deliveries 2026-08-31 20:30:04 -06:00
Ben 71f5ca4d6d fix(http): revoke pending enrollment codes 2026-08-31 20:17:28 -06:00
Ben 13fd2b7b09 fix(http): normalize corrupt fence errors 2026-08-31 20:07:03 -06:00
Ben f104e61d83 fix(http): preserve callback order and crash dedup 2026-08-31 20:04:12 -06:00
Ben ac6a947ae5 fix(http): preserve proxy callback order 2026-08-31 19:27:49 -06:00
Ben dbf416f19b fix(http): verify backend presence against proxy route 2026-08-31 19:25:00 -06:00
Ben 5b666dc501 fix(http): serialize callbacks and renew transport CA 2026-08-31 19:18:53 -06:00
Ben 63e8f9ddd9 fix(http): validate enrollment codes in Control 2026-08-31 17:59:43 -06:00
Ben aa0f8d8ba5 fix(http): persist poll-created backend queues 2026-08-31 17:51:08 -06:00
Ben 57226856ab fix(http): persist outbound deliveries until ack 2026-08-31 17:42:49 -06:00
Ben 38a37ddb1b fix(http): retain votes rejected by transport queue 2026-08-31 17:31:49 -06:00
Ben e9bbe03612 Harden HTTP shutdown and protocol parsing 2026-08-31 17:22:19 -06:00
Ben 1d30c94201 Address HTTP transport review findings 2026-08-31 17:11:00 -06:00
Ben d27636bdcf Harden HTTP transport lifecycle 2026-08-31 16:59:14 -06:00
Ben 1fd3be90b5 Validate HTTP client certificates at TLS boundary 2026-08-31 16:27:15 -06:00
Ben 567531eeef Add secure single-port HTTP proxy transport 2026-08-30 22:32:25 -06:00
548 changed files with 121256 additions and 7922 deletions

No files matched your search

+6
View File
@@ -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
+8 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
+79 -14
View File
@@ -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
+1 -1
View File
@@ -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'
+190 -4
View File
@@ -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,43 @@ 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.
- Local Votifier captures remain owned after STARTED until normal callback completion or confirmed overflow transfer. Transfer execution stays fenced while the original callback runs; successful non-interrupted completion reconciles only its exact occurrence, while interruption/exception or failed transfer retains bounded recovery ownership. A callback completing after the final overflow snapshot is an explicit uncertain reconciliation case; a retired queue must not overwrite a successor, and ordinary partial external effects retain the existing at-least-once boundary.
- 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, use the configured project knowledge reader to verify the private MEX projection, then read its `.mex/AGENTS.md` and `.mex/ROUTER.md` and follow the router for relevant context. Run knowledge queries in that projection; read checkout-local drafts and preferences in the writable workspace when applicable. Report an unavailable reader rather than substituting another project's memory.
- 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.
- Read `.agents/skills/mex-inbox/SKILL.md` and `.agents/skills/mex-relay/SKILL.md` from this project's entry in the verified private knowledge archive. Perform MEX writes only through a supported writable project workspace and its publication workflow, never in the read-only projection; report an unavailable workspace.
- 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 -->
+2
View File
@@ -52,3 +52,5 @@ or disabling it; inspections gate disabled state immediately and report a newly
```
`LATEST` resolves to the latest stable release.
Owner-defined [DateVoteMilestones](docs/date-vote-milestones.md) count qualifying accepted votes within explicit date windows, independently of normal totals. This optional feature is disabled until events are configured.
+216 -25
View File
@@ -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.6.1</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>
@@ -232,6 +316,13 @@
<enabled>false</enabled>
</releases>
</repository>
<repository>
<id>tcoded-releases</id>
<url>https://repo.tcoded.com/releases</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>velocity</id>
<url>https://nexus.velocitypowered.com/repository/maven-public/</url>
@@ -267,16 +358,62 @@
</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>
<artifactId>snakeyaml</artifactId>
<version>2.6</version>
<version>2.7</version>
</dependency>
<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 +425,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.15.0</version>
<scope>provided</scope>
</dependency>
<dependency>
@@ -319,6 +462,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 +474,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.2</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 +495,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 +589,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 +606,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 +623,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 +657,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 +701,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 +764,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 +776,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 +823,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 +874,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
@@ -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;
}
}
}
@@ -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--;
}
}
@@ -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. */
@@ -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);
}
}
@@ -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,7 @@ public class BackendProxyMessageRouter {
}
public void register(GlobalMessageHandler messages, BungeeMethod method) {
this.messages = messages;
messages.addListener(new GlobalMessageListener(VotingPluginWire.SUB_VOTE) {
@Override public void onReceive(JsonEnvelope msg) { handleWireVote(msg); }
});
@@ -50,6 +67,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 +96,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 +109,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 +125,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,16 +563,19 @@ 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;
}
String totalsRaw = nvl(fields.get(VotingPluginWire.K_TOTALS));
VoteTotalsSnapshot totals = totalsRaw.isEmpty() ? null : VoteTotalsSnapshot.parseStorage(totalsRaw);
VoteTotalsSnapshot totals = !com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.hasPayload(totalsRaw)
? null : VoteTotalsSnapshot.parseStorage(totalsRaw);
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 +599,153 @@ 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;
com.bencodez.votingplugin.events.PlayerVoteEvent dispatchedEvent = null;
VotingPluginUser.DateMilestoneAccountingException dateAccountingFailure = null;
try {
// Keep totals correlation for replay fencing, but only the original wire ID
// identifies a canonical transport occurrence in the native event.
dispatchedEvent = user.bungeeVotePluginMessagingEvent(vote.service, vote.time, totals, !vote.manageTotals,
wasOnline, vote.broadcast, vote.num, queuedDelivery, vote.delayValidationKnown,
vote.queuedDeliveryKnown, vote.voteId,
VotingPluginWire.SUB_VOTE_ONLINE.equals(msg.getSubChannel()), vote.realVote,
vote.canonicalOccurrenceTime);
} catch (VotingPluginUser.DateMilestoneAccountingException incomplete) {
dateAccountingFailure = incomplete;
}
if ((dispatchedEvent == null || !dispatchedEvent.isCancelled())
&& plugin.getBungeeSettings().isPerServerPoints()) {
user.addPoints(plugin.getConfigFile().getPointsOnVote());
}
if (vote.service != null && !vote.service.isEmpty()) {
plugin.getServerData().addServiceSite(vote.service);
}
// Date accounting cannot suppress normal points/service observations. Keep
// the reservation incomplete so reconciliation cannot replay normal rewards.
if (dateAccountingFailure != null) throw dateAccountingFailure;
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 +757,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()) {
@@ -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();
}
}
@@ -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();
}
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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) {
@@ -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);
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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;
}
@@ -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);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,41 @@
package com.bencodez.votingplugin.commands;
import com.bencodez.advancedcore.api.command.CommandHandler;
import com.bencodez.simpleapi.command.TabCompleteHandler;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones.AdminAction;
import org.bukkit.command.CommandSender;
/** Exact-target administration; storage and confirmed completion belong to the manager. */
final class DateVoteMilestoneCommands {
private DateVoteMilestoneCommands() { }
static void register(VotingPluginMain plugin, String adminPermission) {
// The SDK only matches registered variable tokens; its built-in player token also rewrites fuzzy names.
for (String token : new String[] { "(player-id)", "(date-event)", "(vote-count)", "(date-milestone)" })
TabCompleteHandler.getInstance().addTabCompleteOption(token, new java.util.ArrayList<>());
for (AdminAction action : AdminAction.values()) {
String[] arguments = switch (action) {
case Status, Preview -> new String[] { "DateVoteMilestone&DVM", action.name(), "(player-id)", "(date-event)" };
case SetVotes -> new String[] { "DateVoteMilestone&DVM", action.name(), "(player-id)", "(date-event)", "(vote-count)" };
case Force, ForceNoLimits -> new String[] { "DateVoteMilestone&DVM", action.name(), "(player-id)", "(date-event)", "(date-milestone)" };
};
String description = switch (action) {
case Status, Preview -> "Inspect date event counts and reward fences without submitting rewards";
case SetVotes -> "Set date event count (0-4096), preserving receipt IDs and reward fences";
case Force -> "Force selected date milestones, preserving prior submission and uncertain reservation fences";
case ForceNoLimits -> "Repeat submitted date milestones; uncertain reservations remain blocked";
};
CommandHandler handler = new CommandHandler(plugin, arguments,
"VotingPlugin.Commands.AdminVote.DateVoteMilestone." + action.name() + "|" + adminPermission, description) {
@Override public void execute(CommandSender sender, String[] args) {
var manager = plugin.getDateVoteMilestones();
if (manager == null) { sender.sendMessage("Date milestones are unavailable."); return; }
manager.administer(sender, action, args[2], args[3], args.length > 4 ? args[4] : null);
}
};
handler.setAllowMultiplePermissions(true);
plugin.getAdminVoteCommand().add(handler);
}
}
}
@@ -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;
}
@@ -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.");
}
}
}
});
@@ -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);
}
}
}
@@ -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"));
}
}
@@ -0,0 +1,170 @@
package com.bencodez.votingplugin.commands.gui.player;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.function.BooleanSupplier;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.entity.HumanEntity;
import com.bencodez.advancedcore.api.inventory.BInventory;
import com.bencodez.advancedcore.api.inventory.BInventory.ClickEvent;
import com.bencodez.advancedcore.api.inventory.BInventoryButton;
import com.bencodez.advancedcore.api.item.ItemBuilder;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot;
import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot.Availability;
import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot.Event;
import com.bencodez.votingplugin.core.datemilestones.DateVoteProgressSnapshot.Milestone;
/** Entity-thread presentation of an immutable storage-worker result. */
public final class DateVoteEventsMenu {
private static final int PAGE_SIZE = 45;
private static final int LIMIT = 64;
private static final String OPEN_GUARD = "DateVoteEventsOpenGuard";
private record OpenGuard(java.util.UUID owner, BooleanSupplier eligible) { }
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss");
private final VotingPluginMain plugin;
private final Player player;
private final DateVoteProgressSnapshot snapshot;
private final BooleanSupplier current;
private DateVoteEventsMenu(VotingPluginMain plugin, Player player, DateVoteProgressSnapshot snapshot,
BooleanSupplier current) {
this.plugin = plugin;
this.player = player;
this.snapshot = snapshot;
this.current = current;
}
public static void open(VotingPluginMain plugin, Player player, DateVoteProgressSnapshot snapshot,
BooleanSupplier current) {
new DateVoteEventsMenu(plugin, player, snapshot, current).list(0);
}
private boolean eligible() {
return current.getAsBoolean() && plugin.isEnabled() && player.isOnline()
&& (player.hasPermission("VotingPlugin.Commands.Vote.DateEvents")
|| player.hasPermission("VotingPlugin.Player"));
}
private BInventory inventory(String title) {
BInventory inventory = new BInventory(title);
inventory.setMaxInvSize(54);
inventory.setPages(false);
inventory.runClicksSync();
inventory.dontClose();
inventory.addData(OPEN_GUARD, new OpenGuard(player.getUniqueId(), this::eligible));
return inventory;
}
/** Called at Bukkit's final InventoryOpenEvent, after AdvancedCore's deferred callback. */
public static boolean permitsOpen(BInventory inventory, HumanEntity viewer) {
Object value = inventory.getData(OPEN_GUARD);
return !(value instanceof OpenGuard guard)
|| guard.owner().equals(viewer.getUniqueId()) && guard.eligible().getAsBoolean();
}
private void list(int page) {
if (!eligible()) return;
List<Event> events = snapshot.events().stream().limit(LIMIT).toList();
BInventory inventory = inventory("Date vote events " + (page + 1) + "/" + pages(events.size()));
int end = Math.min(events.size(), (page + 1) * PAGE_SIZE);
for (int index = page * PAGE_SIZE; index < end; index++) {
Event event = events.get(index);
inventory.addButton(index % PAGE_SIZE, button(Material.PAPER,
event.definition().displayName(), eventLore(event), () -> detail(event, 0, page)));
}
List<String> summary = new ArrayList<>();
if (events.isEmpty()) summary.add("No date vote events configured.");
else if (events.stream().noneMatch(event -> event.definition().enabled()))
summary.add("No enabled date vote events.");
else summary.add("Select an event to view milestones.");
summary.add("Snapshot: " + Instant.ofEpochMilli(snapshot.capturedAt()));
inventory.addButton(49, button(Material.BOOK, "Date vote events", summary, () -> {}));
if (page > 0) inventory.addButton(45, button(Material.ARROW, "Previous page", List.of(), () -> list(page - 1)));
if (end < events.size()) inventory.addButton(53, button(Material.ARROW, "Next page", List.of(), () -> list(page + 1)));
if (eligible()) inventory.openInventory(player);
}
private void detail(Event event, int page, int listPage) {
if (!eligible()) return;
List<Milestone> milestones = event.milestones().stream().limit(LIMIT).toList();
BInventory inventory = inventory("Date vote milestones " + (page + 1) + "/" + pages(milestones.size()));
int end = Math.min(milestones.size(), (page + 1) * PAGE_SIZE);
for (int index = page * PAGE_SIZE; index < end; index++) {
Milestone milestone = milestones.get(index);
List<String> lore = new ArrayList<>();
lore.add("ID: " + milestone.id());
lore.add("Enabled: " + milestone.enabled());
lore.add("Award state: " + event.awardState(milestone.threshold()));
List<String> types = milestone.rewardTypes().stream().limit(LIMIT)
.map(type -> type.matches("[A-Za-z0-9_-]{1,64}") ? type : "Other").distinct().toList();
lore.add("Reward types: " + (types.isEmpty() ? "none" : String.join(", ", types)));
inventory.addButton(index % PAGE_SIZE, button(Material.PAPER, milestone.threshold() + " votes", lore, () -> {}));
}
List<String> summary = eventLore(event);
if (milestones.isEmpty()) summary.add("No milestones configured.");
inventory.addButton(48, button(Material.BOOK, event.definition().displayName(), summary, () -> {}));
inventory.addButton(49, button(Material.ARROW, "Back to events", List.of(), () -> list(listPage)));
if (page > 0) inventory.addButton(45, button(Material.ARROW, "Previous page", List.of(), () -> detail(event, page - 1, listPage)));
if (end < milestones.size()) inventory.addButton(53, button(Material.ARROW, "Next page", List.of(), () -> detail(event, page + 1, listPage)));
if (eligible()) inventory.openInventory(player);
}
private List<String> eventLore(Event event) {
var definition = event.definition();
List<String> lore = new ArrayList<>();
lore.add("ID: " + definition.id());
lore.add("Status: " + event.status(snapshot.capturedAt()));
ZoneId zone = ZoneId.of(definition.timezone());
lore.add("Start: " + TIME.format(Instant.ofEpochMilli(definition.start()).atZone(zone)));
lore.add("End (exclusive): " + TIME.format(Instant.ofEpochMilli(definition.end()).atZone(zone)));
lore.add("Timezone: " + definition.timezone());
if (event.availability() == Availability.OWNED) {
lore.add("Votes: " + event.votes());
lore.add("Next milestone: " + (event.nextThreshold().isPresent() ? event.nextThreshold().getAsInt() + " votes" : "none"));
} else if (event.availability() == Availability.REMOTE) {
lore.add("Progress owned by: " + definition.accountingServer());
lore.add("Progress unavailable on this server.");
} else {
lore.add("Progress unavailable: accounting read failed.");
}
return lore;
}
private static int pages(int size) {
return Math.max(1, (size + PAGE_SIZE - 1) / PAGE_SIZE);
}
private BInventoryButton button(Material material, String name, List<String> lore, Runnable action) {
List<String> lines = List.copyOf(lore);
return new BInventoryButton(new ItemBuilder(material)) {
// ItemBuilder evaluates display Javascript and formatting while rendering. Render only
// the blank material through it, then apply snapshot text literally to Bukkit metadata.
@Override public ItemStack getItem() {
ItemStack item = new ItemBuilder(material).toItemStack();
ItemMeta meta = item.getItemMeta();
if (meta != null) {
meta.setDisplayName(name);
meta.setLore(lines);
item.setItemMeta(meta);
}
return item;
}
@Override public ItemStack getItem(Player ignored) { return getItem(); }
@Override public ItemStack getItem(Player ignored, HashMap<String, String> placeholders) { return getItem(); }
@Override public void onClick(ClickEvent click) {
if (eligible()) action.run();
}
}.dontClose();
}
}
@@ -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());
@@ -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<>();
@@ -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);
}
}
}
@@ -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;
}
}
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -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()));
}
@@ -51,7 +51,7 @@ public class VoteTopVoter extends GUIHandler {
String topVoter = top.getName();
@SuppressWarnings("unchecked")
LinkedHashMap<TopVoterPlayer, Integer> topVotes = (LinkedHashMap<TopVoterPlayer, Integer>) plugin.getTopVoter(top)
LinkedHashMap<TopVoterPlayer, Integer> topVotes = (LinkedHashMap<TopVoterPlayer, Integer>) plugin.getTopVoterDisplay(top)
.clone();
com.bencodez.simpleapi.dialog.MultiActionDialogBuilder dialog = plugin.getDialogService().multiAction(player)
@@ -148,7 +148,7 @@ public class VoteTopVoter extends GUIHandler {
String topVoter = top.getName();
@SuppressWarnings("unchecked")
LinkedHashMap<TopVoterPlayer, Integer> topVotes = (LinkedHashMap<TopVoterPlayer, Integer>) plugin
.getTopVoter(top).clone();
.getTopVoterDisplay(top).clone();
users = topVotes.entrySet();
ConfigurationSection customization = plugin.getGui().getChestVoteTopCustomization();
@@ -31,7 +31,7 @@ public class VoteTopVoterLastMonth extends GUIHandler {
@Override
public void onDialog(Player player) {
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getLastMonthTopVoter().entrySet();
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getLastMonthTopVoterDisplay().entrySet();
com.bencodez.simpleapi.dialog.MultiActionDialogBuilder dialog = plugin.getDialogService().multiAction(player)
.placeholder("player", user.getPlayerName())
@@ -91,7 +91,7 @@ public class VoteTopVoterLastMonth extends GUIHandler {
public void onChest(Player player) {
Set<Entry<TopVoterPlayer, Integer>> users = null;
users = plugin.getLastMonthTopVoter().entrySet();
users = plugin.getLastMonthTopVoterDisplay().entrySet();
BInventory inv = new BInventory(plugin.getGui().getChestVoteTopName());
inv.addPlaceholder("topvoter", "Last Month");
@@ -60,7 +60,7 @@ public class VoteTopVoterPreviousMonths extends GUIHandler {
return;
}
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getPreviousMonthsTopVoters().get(yearMonth).entrySet();
Set<Entry<TopVoterPlayer, Integer>> users = plugin.getPreviousMonthTopVoterDisplay(yearMonth).entrySet();
com.bencodez.simpleapi.dialog.MultiActionDialogBuilder dialog = plugin.getDialogService().multiAction(player)
.placeholder("player", user.getPlayerName())
@@ -160,7 +160,7 @@ public class VoteTopVoterPreviousMonths extends GUIHandler {
player.sendMessage(ChatColor.RED + "Can't open previous months, no data");
return;
}
users = plugin.getPreviousMonthsTopVoters().get(yearMonth).entrySet();
users = plugin.getPreviousMonthTopVoterDisplay(yearMonth).entrySet();
BInventory inv = new BInventory(plugin.getGui().getChestVoteTopName());
inv.addPlaceholder("topvoter", yearMonth.toString());
@@ -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();
@@ -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;
@@ -761,6 +809,10 @@ public class Config extends YMLFile {
@Getter
private boolean topVoterAwardsTies = true;
@ConfigDataBoolean(path = "HideTopVoterIfNotPlayedBefore")
@Getter
private boolean hideTopVoterIfNotPlayedBefore = false;
@ConfigDataBoolean(path = "TopVoterIgnorePermission")
@Getter
private boolean topVoterIgnorePermission = false;
@@ -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";
@@ -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; }
}
}
@@ -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;
}
}
@@ -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);
@@ -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);
}
}
@@ -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();
@@ -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);
}
}
@@ -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; }
}
}
@@ -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);
}
}
@@ -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; }
}
@@ -11,10 +11,13 @@ import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import com.bencodez.advancedcore.api.rewards.Reward;
import com.bencodez.advancedcore.api.rewards.RewardOptions;
import com.bencodez.advancedcore.api.user.AdvancedCoreUser;
import com.bencodez.advancedcore.api.user.UserDataFetchMode;
@@ -36,12 +39,14 @@ public class CoolDownCheck implements Listener {
private final VotingPluginMain plugin;
private final Map<UUID, ScheduledFuture<?>> perSiteTasks = new ConcurrentHashMap<>();
private final Map<UUID, Object> perSiteGenerations = new ConcurrentHashMap<>();
private final Map<UUID, ScheduledFuture<?>> allSiteTasks = new ConcurrentHashMap<>();
@Getter
private final ScheduledExecutorService timer;
private volatile boolean cooldownCheckEnabled = false;
private volatile boolean stopped = false;
/**
* Constructs a new cooldown check manager.
@@ -74,6 +79,8 @@ public class CoolDownCheck implements Listener {
* Shuts down the cooldown check timer.
*/
public void shutdown() {
stopped = true;
perSiteGenerations.clear();
timer.shutdownNow();
for (ScheduledFuture<?> f : perSiteTasks.values()) {
@@ -147,6 +154,7 @@ public class CoolDownCheck implements Listener {
user.updateTempCacheWithColumns(cols);
cols.clear(); // help GC (only useful if reused)
}
capturePlayerName(user);
checkPerSite(site, user);
} finally {
@@ -219,13 +227,20 @@ public class CoolDownCheck implements Listener {
* Site-specific version used by batch checks / checkAllVoteSite.
*/
private void checkPerSite(VoteSite site, VotingPluginUser user) {
checkPerSite(site, user, null);
}
private HashMap<String, Boolean> checkPerSite(VoteSite site, VotingPluginUser user,
HashMap<String, Boolean> coolDownChecks) {
if (!plugin.getConfigFile().isPerSiteCoolDownEvents() || site == null) {
return;
return coolDownChecks;
}
HashMap<String, Boolean> coolDownChecks = user.getCoolDownCheckSiteList();
if (coolDownChecks == null) {
coolDownChecks = user.getCoolDownCheckSiteList();
}
if (Boolean.TRUE.equals(coolDownChecks.get(site.getKey()))) {
return;
return coolDownChecks;
}
if (user.canVoteSite(site)) {
@@ -237,6 +252,7 @@ public class CoolDownCheck implements Listener {
PlayerVoteSiteCoolDownEndEvent event = new PlayerVoteSiteCoolDownEndEvent(user, site);
plugin.getServer().getPluginManager().callEvent(event);
}
return coolDownChecks;
}
/**
@@ -267,8 +283,8 @@ public class CoolDownCheck implements Listener {
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onCoolDownEnd(PlayerVoteCoolDownEndEvent event) {
plugin.getRewardHandler().giveReward(event.getPlayer(), plugin.getSpecialRewardsConfig().getData(),
"VoteCoolDownEndedReward", new RewardOptions());
dispatchCooldownReward(event.getPlayer(), plugin.getSpecialRewardsConfig().getData(),
"VoteCoolDownEndedReward", "all", new RewardOptions());
}
/**
@@ -278,11 +294,73 @@ public class CoolDownCheck implements Listener {
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onCoolDownEnd(PlayerVoteSiteCoolDownEndEvent event) {
plugin.getRewardHandler().giveReward(event.getPlayer(), event.getSite().getSiteData(), "CoolDownEndRewards",
new RewardOptions().addPlaceholder("sitename", event.getSite().getDisplayName())
dispatchCooldownReward(event.getPlayer(), event.getSite().getSiteData(), "CoolDownEndRewards",
"site:" + event.getSite().getKey(), new RewardOptions()
.addPlaceholder("sitename", event.getSite().getDisplayName())
.addPlaceholder("url", event.getSite().getVoteURL(false)));
}
private void dispatchCooldownReward(VotingPluginUser user, ConfigurationSection data, String path,
String site, RewardOptions options) {
boolean configured = false;
try {
configured = hasConfiguredCooldownReward(data, path);
} catch (RuntimeException ignored) {
// Preserve legacy dispatch even when diagnostic configuration inspection fails.
}
if (!configured) {
plugin.getRewardHandler().giveReward(user, data, path, options);
return;
}
String occurrence = UUID.randomUUID().toString();
String player = user.getUUID();
String safeSite = String.valueOf(site).replaceAll("[\\r\\n\\p{Cntrl}]", "?");
long time = System.currentTimeMillis();
String context = " occurrence=" + occurrence + " player=" + player + " site=" + safeSite + " time=" + time;
logCooldownDiagnostic(false, "Cooldown reward status=attempted" + context);
options.setAsyncFailureObserver(failure -> logCooldownFailure(context, failure));
try {
plugin.getRewardHandler().giveReward(user, data, path, options);
} catch (RuntimeException | Error failure) {
logCooldownFailure(context, failure);
throw failure;
}
}
private boolean hasConfiguredCooldownReward(ConfigurationSection data, String path) {
if (data == null) return false;
if (data instanceof FileConfiguration) {
return plugin.getRewardHandler().hasRewards((FileConfiguration) data, path);
}
if (data.isList(path)) return !data.getList(path, new ArrayList<>()).isEmpty();
if (data.isConfigurationSection(path)) return !data.getConfigurationSection(path).getKeys(false).isEmpty();
return data.isString(path) && !data.getString(path, "").isEmpty();
}
private void logCooldownFailure(String context, Throwable failure) {
int completed = -1;
Throwable current = failure;
for (int depth = 0; current != null && depth < 32; depth++, current = current.getCause()) {
if (current instanceof Reward.RewardReplayFailure) {
completed = ((Reward.RewardReplayFailure) current).getCompletedInjectionCount();
break;
}
}
String error = failure == null ? "unknown" : failure.getClass().getSimpleName();
logCooldownDiagnostic(true, "Cooldown reward outcome=unknown/partial" + context + " error=" + error
+ " completedInjections=" + (completed < 0 ? "unknown" : completed)
+ "; inspect effects before reissuing");
}
private void logCooldownDiagnostic(boolean warning, String message) {
try {
if (warning) plugin.getLogger().warning(message);
else plugin.getLogger().info(message);
} catch (RuntimeException ignored) {
// A broken logger must not suppress or retry the reward.
}
}
/**
* Schedules a cooldown check for a user.
*
@@ -324,7 +402,7 @@ public class CoolDownCheck implements Listener {
* @param force whether to force the schedule
*/
public void schedulePerSite(VotingPluginUser user, boolean force) {
if (!plugin.getConfigFile().isPerSiteCoolDownEvents()) {
if (stopped || !plugin.getConfigFile().isPerSiteCoolDownEvents()) {
return;
}
@@ -333,6 +411,7 @@ public class CoolDownCheck implements Listener {
}
final UUID uuid = user.getJavaUUID();
final String scheduledName = user.getPlayerName();
final NextSite next = user.getNextSiteAvailable();
@@ -340,8 +419,13 @@ public class CoolDownCheck implements Listener {
if (existing != null) {
existing.cancel(false);
}
if (stopped) {
perSiteGenerations.remove(uuid);
return null;
}
if (next == null || next.getSite() == null || next.getSecondsUntilAvailable() <= 0) {
perSiteGenerations.remove(uuid);
plugin.extraDebug(user.getUUID() + "/" + user.getPlayerName() + " not scheduling per-site cooldown check");
return null; // remove entry
}
@@ -349,21 +433,60 @@ public class CoolDownCheck implements Listener {
plugin.devDebug("PerSiteCoolDownEvent schedule: site=" + next.getSite().getKey() + " time="
+ next.getSecondsUntilAvailable() + " seconds");
return timer.schedule(() -> {
if (!plugin.isEnabled()) {
return;
}
// Only check the next site; this will set the cooldown-end flag + fire event if ready
checkPerSite(next.getSite(), user);
// Schedule the following site (if any)
schedulePerSite(user, false);
}, next.getSecondsUntilAvailable() + 2, TimeUnit.SECONDS);
Object generation = new Object();
perSiteGenerations.put(uuid, generation);
try {
return timer.schedule(() -> runPerSiteTimer(uuid, scheduledName, next, generation, 0),
next.getSecondsUntilAvailable() + 2, TimeUnit.SECONDS);
} catch (RuntimeException | Error failure) {
perSiteGenerations.remove(uuid, generation);
throw failure;
}
});
}
private void runPerSiteTimer(UUID uuid, String scheduledName, NextSite next, Object generation, int retries) {
if (stopped || !plugin.isEnabled() || perSiteGenerations.get(uuid) != generation) return;
UUID occurrence = UUID.randomUUID();
boolean[] retried = { false };
try {
VotingPluginUser current;
try {
// The batch user's temporary row may have been cleared or replaced by a newer vote.
current = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false);
if (!current.isCached()) current.userDataFetechMode(UserDataFetchMode.NO_CACHE);
String storedName = current.getData().getString("PlayerName", current.getUserDataFetchMode());
String name = storedName != null && !storedName.isBlank() ? storedName : scheduledName;
if (name != null && !name.isBlank()) current.setPlayerName(name);
} catch (RuntimeException refreshFailure) {
if (retries < 3) {
perSiteTasks.compute(uuid, (key, existing) -> {
if (stopped || !plugin.isEnabled() || perSiteGenerations.get(uuid) != generation) return existing;
ScheduledFuture<?> retry = timer.schedule(
() -> runPerSiteTimer(uuid, scheduledName, next, generation, retries + 1),
30, TimeUnit.SECONDS);
retried[0] = true;
return retry;
});
if (retried[0]) return;
}
throw refreshFailure;
}
if (perSiteGenerations.get(uuid) != generation) return;
checkPerSite(next.getSite(), current);
if (perSiteGenerations.get(uuid) == generation) schedulePerSite(current, false);
} catch (RuntimeException | Error failure) {
String siteKey = String.valueOf(next.getSite().getKey()).replaceAll("[\\r\\n\\p{Cntrl}]", "?");
logCooldownDiagnostic(true, "Cooldown timer failed: occurrence=" + occurrence + " player=" + uuid
+ " site=" + siteKey + " time=" + System.currentTimeMillis() + " error="
+ failure.getClass().getSimpleName() + "; reward outcome is uncertain; inspect effects before reissuing");
throw failure;
} finally {
if (!retried[0]) perSiteGenerations.remove(uuid, generation);
}
}
/**
* Handles a vote event for cooldown scheduling.
*
@@ -381,6 +504,14 @@ public class CoolDownCheck implements Listener {
}
}
/** Preserve the name needed by asynchronous rewards after the temporary row is cleared. */
private static void capturePlayerName(VotingPluginUser user) {
String name = user.getPlayerName();
if (name != null && !name.isBlank()) {
user.setPlayerName(name);
}
}
/**
* Performance-optimized: scan users ONCE, process all daily-delay sites.
*/
@@ -421,9 +552,11 @@ public class CoolDownCheck implements Listener {
user.updateTempCacheWithColumns(cols);
cols.clear();
}
capturePlayerName(user);
HashMap<String, Boolean> coolDownChecks = null;
for (int i = 0; i < dailySites.size(); i++) {
checkPerSite(dailySites.get(i), user);
coolDownChecks = checkPerSite(dailySites.get(i), user, coolDownChecks);
}
check(user);
@@ -0,0 +1,64 @@
package com.bencodez.votingplugin.core.datemilestones;
import java.nio.charset.StandardCharsets;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
/** Immutable owner-defined event accounting contract; display text is not identity. */
public record DateVoteEvent(String id, String displayName, boolean enabled, long start, long end,
String timezone, List<Integer> thresholds, Set<String> sites, String accountingServer) {
public DateVoteEvent {
if (id == null || !id.matches("[A-Za-z0-9_-]{1,64}")) throw new IllegalArgumentException("Invalid event ID");
if (start <= 0 || end <= start) throw new IllegalArgumentException("End must be after start");
ZoneId.of(timezone);
if (thresholds.isEmpty() || thresholds.size() > 64 || thresholds.stream().anyMatch(n -> n <= 0 || n > 4096)
|| thresholds.stream().distinct().count() != thresholds.size()) throw new IllegalArgumentException("Invalid thresholds (1–4096, unique, max 64)");
thresholds = thresholds.stream().sorted().toList();
if (sites.size() > 100 || sites.stream().anyMatch(s -> s == null || s.length() > 128 || s.isBlank()))
throw new IllegalArgumentException("Invalid site filters");
sites = Set.copyOf(sites);
if (accountingServer == null || accountingServer.length() > 128) throw new IllegalArgumentException("Invalid accounting server");
if (displayName == null || displayName.length() > 256) throw new IllegalArgumentException("Invalid display name");
}
public boolean matches(long occurredAt, String site, boolean real, boolean proxy, String server) {
return enabled && real && occurredAt >= start && occurredAt < end && (sites.isEmpty() || sites.contains(site))
&& (!proxy || !accountingServer.isBlank() && accountingServer.equals(server));
}
public String fingerprint() {
try {
var bytes = new ByteArrayOutputStream();
try (var data = new DataOutputStream(bytes)) {
data.writeLong(start); data.writeLong(end); data.writeUTF(timezone);
data.writeInt(thresholds.size());
for (int threshold : thresholds) data.writeInt(threshold);
data.writeInt(sites.size());
for (String site : sites.stream().sorted().toList()) data.writeUTF(site);
data.writeUTF(accountingServer);
}
return hash(bytes.toByteArray());
} catch (java.io.IOException impossible) { throw new IllegalStateException(impossible); }
}
/** Portable case-sensitive identity, including on case-insensitive filesystems. */
public String fileId() { return fileId(id); }
public static String fileId(String id) {
if (id == null || !id.matches("[A-Za-z0-9_-]{1,64}")) throw new IllegalArgumentException("Invalid event ID");
return hash(id.getBytes(StandardCharsets.UTF_8));
}
private static String hash(byte[] bytes) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); }
catch (java.security.NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); }
}
public static long timestamp(String localIso, String timezone) {
LocalDateTime time = LocalDateTime.parse(localIso);
ZoneId zone = ZoneId.of(timezone);
if (zone.getRules().getValidOffsets(time).size() != 1)
throw new IllegalArgumentException("Ambiguous/nonexistent local time; choose an unambiguous boundary");
return time.atZone(zone).toInstant().toEpochMilli();
}
}
@@ -0,0 +1,36 @@
package com.bencodez.votingplugin.core.datemilestones;
import java.util.List;
import java.util.OptionalInt;
import java.util.Set;
/** Immutable worker result; viewing this snapshot never mutates accounting or rewards. */
public record DateVoteProgressSnapshot(long capturedAt, List<Event> events) {
public DateVoteProgressSnapshot { events = List.copyOf(events); }
public enum Availability { OWNED, REMOTE, UNAVAILABLE }
public record Milestone(int threshold, String id, boolean enabled, List<String> rewardTypes) {
public Milestone { rewardTypes = List.copyOf(rewardTypes); }
}
public record Event(DateVoteEvent definition, Availability availability, int votes,
Set<Integer> submitted, Set<Integer> deferred, Set<Integer> reserved, List<Milestone> milestones) {
public Event {
submitted = Set.copyOf(submitted); deferred = Set.copyOf(deferred); reserved = Set.copyOf(reserved);
milestones = List.copyOf(milestones);
}
public String status(long now) {
return !definition.enabled() ? "disabled" : now < definition.start() ? "upcoming"
: now >= definition.end() ? "ended" : "active";
}
public OptionalInt nextThreshold() {
return availability == Availability.OWNED ? milestones.stream().filter(m -> m.enabled() && m.threshold() > votes)
.mapToInt(Milestone::threshold).min() : OptionalInt.empty();
}
public String awardState(int threshold) {
if (availability != Availability.OWNED) return "progress unavailable";
if (reserved.contains(threshold)) return "pending review";
if (submitted.contains(threshold)) return "submitted";
if (deferred.contains(threshold)) return "deferred";
return votes >= threshold ? "reached" : "not reached";
}
}
}
@@ -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;
}
}
@@ -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) { }
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -0,0 +1,263 @@
package com.bencodez.votingplugin.core.vote;
import java.util.UUID;
/** The accepted vote sequence. Platform operations are supplied by the receiving adapter. */
public final class SharedVoteProcessor {
private SharedVoteProcessor() { }
/** Identifies the one queued proxy occurrence by its stable transport identity. */
public static boolean isIdentifiedQueuedProxyVote(boolean proxyVote, boolean queuedDelivery, UUID voteId) {
return proxyVote && queuedDelivery && voteId != null;
}
/**
* Legacy timestamp comparison retained for binary compatibility only.
* Production vote-delay authorization uses {@link #isIdentifiedQueuedProxyVote(boolean, boolean, UUID)}.
*
* @deprecated timestamps are not stable occurrence identities
*/
@Deprecated
public static boolean isQueuedVoteAlreadyRecorded(boolean proxyVote, long messageVoteTime, long storedVoteTime) {
return proxyVote && messageVoteTime > 0L && messageVoteTime == storedVoteTime;
}
public record Validation(boolean valid, String normalizedName, String source, String reason, boolean bedrock) { }
public record Name(String value, String rationale) { }
public interface Operations<S, U> {
boolean enabled();
String incomingName();
String serviceSite();
void debug(String message);
void extraDebug(String message);
void info(String message);
void warning(String message);
String properName(String incomingName);
boolean allowUnJoinedCheckServer();
Validation validate(String properName, boolean allowUnJoinedCheckServer);
boolean allowUnjoined();
boolean removeInvalidProxyUsers();
void removeInvalidUser(String properName);
Name resolveUnknownName(String properName);
void triggerProxyEvent();
S resolveSite();
void reportMissingSite();
boolean siteEnabled(S site);
String siteKey(S site);
String siteDisplayName(S site);
U resolveUser(String creditedName);
String userName(U user);
String userId(U user);
UUID userUuid(U user);
boolean userOnline(U user);
boolean userVanished(U user);
long lastVoteTime(U user, S site);
boolean waitUntilVoteDelay(S site);
boolean canVoteSite(U user, S site);
boolean bypassWaitPermission(U user);
boolean processRewards();
void giveWaitRewards(S site, U user, boolean online, boolean proxyVote);
boolean proxyVote();
/** Whether this proxy vote was routed only to the player's selected backend. */
default boolean targetedProxyVote() { return false; }
boolean forceProxyRouting();
boolean wasOnline();
boolean realVote();
default long currentTimeMillis() { return System.currentTimeMillis(); }
boolean addTotals();
boolean broadcastEnabled();
boolean hasBroadcastHandler();
void broadcast(UUID uuid, String name, String siteDisplayName, boolean online);
boolean hasProxyTextTotals();
UUID proxyVoteId();
/** Local receipt identity, without implying proxy provenance. */
default UUID localOccurrenceId() { return null; }
default boolean identifiedQueuedProxyVote() { return false; }
default boolean proxyQueueClassificationKnown() { return false; }
default boolean proxyDelayValidationKnown() { return false; }
void cache(U user);
void updateName(U user);
void voteParty(U user, boolean realVote, boolean forceProxyRouting, boolean onlineAtVoteTime);
long incomingTime();
void setTime(U user, S site, long time);
void setTimeNow(U user, S site);
boolean giveOfflineRewards(S site);
void playerVote(U user, S site, boolean online, boolean forceProxyRouting);
int voteNumber();
void sendVoteEffects(U user, boolean online);
boolean closeInventoryOnVote();
void closeInventory(U user);
boolean offlineVotesLimitEnabled();
int offlineVotes(U user, S site);
int offlineVotesLimitAmount();
void addOfflineVote(U user, String siteKey);
SharedVotePolicy countingPolicy();
void addTotal(U user);
void addTotalDaily(U user);
void addTotalWeekly(U user);
void addPoints(U user);
void checkDayVoteStreak(U user, boolean forceProxyRouting);
boolean limitMonthlyVotes();
int proxyMonthTotal();
int userMonthTotal(U user);
int currentDayOfMonth();
int enabledSiteCount();
void setMonthTotal(U user, int total);
void milestones(U user, UUID voteId, boolean forceProxyRouting);
default void dateMilestones(U user, S site, long voteTime, UUID voteId) { }
void cooldown(U user, S site);
void voteStreak(U user, long voteTime, UUID voteId);
void postVote(S site, U user, String playerName, long voteTime, UUID voteId, boolean cached);
boolean placeholderCacheAlways();
void updatePlaceholders(U user);
void clearCache(U user);
void setUpdate();
}
public static <S, U> void process(Operations<S, U> ops) {
if (!ops.enabled()) {
ops.warning("Plugin disabled, ignoring vote");
return;
}
String playerName = ops.incomingName();
ops.debug("Processing PlayerVoteEvent: " + playerName + "/" + ops.serviceSite());
String properName = ops.properName(playerName);
Validation validation = ops.validate(properName, ops.allowUnJoinedCheckServer());
ops.extraDebug("Vote validation result for " + properName + ": valid=" + validation.valid()
+ ", source=" + validation.source() + ", reason=" + validation.reason()
+ ", normalizedName=" + validation.normalizedName() + ", bedrock=" + validation.bedrock());
if (!validation.valid() && !ops.allowUnjoined()) {
ops.warning("Player " + properName + " has not joined before, disregarding vote. Reason: "
+ validation.reason() + ". Set AllowUnjoined to true to accept.");
if (ops.proxyVote() && ops.removeInvalidProxyUsers()) ops.removeInvalidUser(properName);
return;
}
Name resolved;
if (validation.valid()) {
String name = validation.normalizedName();
if (name == null || name.isEmpty()) name = properName;
resolved = new Name(name, "validation-" + validation.source().toLowerCase(java.util.Locale.ROOT));
} else {
resolved = ops.resolveUnknownName(properName);
}
ops.debug("Vote name resolved: " + properName + " -> " + resolved.value() + " (" + resolved.rationale() + ")");
playerName = resolved.value();
if (playerName.isEmpty()) {
ops.warning("Empty player name from vote, ignoring");
return;
}
if (ops.proxyVote()) ops.triggerProxyEvent();
S site = ops.resolveSite();
if (site == null) {
ops.reportMissingSite();
return;
}
if (!ops.siteEnabled(site)) {
ops.debug("Votesite: " + ops.siteKey(site) + " is not enabled");
return;
}
U user = ops.resolveUser(playerName);
boolean recordedProxyVote = isIdentifiedQueuedProxyVote(
ops.proxyVote(), ops.identifiedQueuedProxyVote(), ops.proxyVoteId());
// Only older proxy messages without an explicit queue-classification marker
// may use the rolling-upgrade timestamp fallback. Current live and queued
// deliveries always carry that marker, so stable-ID authorization no longer
// depends on cache delivery order.
boolean legacyRecordedProxyVote = !recordedProxyVote && ops.proxyVote()
&& !ops.proxyQueueClassificationKnown() && !ops.proxyDelayValidationKnown()
&& ops.incomingTime() > 0L
&& ops.incomingTime() == ops.lastVoteTime(user, site);
recordedProxyVote |= legacyRecordedProxyVote;
if (ops.waitUntilVoteDelay(site) && !recordedProxyVote && !ops.canVoteSite(user, site)) {
if (!ops.realVote()) {
ops.info(ops.userName(user) + " did a not real vote, bypassing WaitUntilVoteDelay");
} else if (!ops.bypassWaitPermission(user)) {
ops.info(ops.userName(user) + " must wait until votedelay is over, ignoring vote");
boolean online = ops.userOnline(user);
if (ops.proxyVote()) online = ops.wasOnline();
if (ops.processRewards()) ops.giveWaitRewards(site, user, online, ops.proxyVote());
return;
} else {
ops.info(ops.userName(user) + " has bypass permission for WaitUntilVoteDelay, bypassing");
}
}
if (recordedProxyVote) {
ops.debug("Allowing queued proxy vote for " + ops.userName(user) + " on " + ops.siteKey(site)
+ (legacyRecordedProxyVote ? "; legacy timestamp matches LastVotes: " + ops.incomingTime()
: "; stable vote ID identifies this queued delivery: " + ops.proxyVoteId()));
}
UUID voteId = ops.proxyVote() ? ops.proxyVoteId() : ops.localOccurrenceId();
if (voteId == null) voteId = UUID.randomUUID();
String userId = ops.userId(user);
ops.cache(user);
ops.updateName(user);
// Targeted proxy votes preserve the historical state sampled when the vote
// was accepted. All-server delivery additionally requires local presence so
// one network-wide online vote cannot advance every backend's VoteParty.
boolean onlineAtVoteTime = ops.proxyVote()
? ops.wasOnline() && (ops.targetedProxyVote() || ops.userOnline(user))
: ops.userOnline(user);
ops.voteParty(user, ops.realVote(), ops.forceProxyRouting(), onlineAtVoteTime);
if (ops.broadcastEnabled() && ops.hasBroadcastHandler()) {
boolean currentOnline = ops.userOnline(user);
boolean online = currentOnline;
if (ops.proxyVote()) online = ops.wasOnline();
if (!ops.userVanished(user)) {
SharedVoteIdentity identity = new SharedVoteIdentity(ops.userUuid(user), playerName, currentOnline);
ops.broadcast(identity.uuid(), identity.playerName(), ops.siteDisplayName(site), online);
} else {
ops.debug("Not broadcasting vote for vanished user: " + ops.userName(user));
}
}
long voteTime;
if (ops.incomingTime() != 0) {
ops.setTime(user, site, ops.incomingTime());
voteTime = ops.incomingTime();
} else {
ops.setTimeNow(user, site);
voteTime = ops.currentTimeMillis();
}
boolean cached = false;
if (SharedVoteDelivery.shouldDeliverNow(ops::proxyVote, () -> ops.userOnline(user),
() -> ops.giveOfflineRewards(site), ops::processRewards)) {
boolean online = true;
if (ops.proxyVote()) online = ops.wasOnline();
ops.playerVote(user, site, online, ops.forceProxyRouting());
if (ops.voteNumber() == 1) ops.sendVoteEffects(user, online);
if (ops.closeInventoryOnVote()) ops.closeInventory(user);
} else if (!ops.offlineVotesLimitEnabled() || ops.offlineVotes(user, site) <= ops.offlineVotesLimitAmount()) {
ops.addOfflineVote(user, ops.siteKey(site));
cached = true;
ops.debug("Offline vote set for " + playerName + " (" + ops.userId(user) + ") on " + ops.siteKey(site));
} else {
ops.debug("Not setting offline vote, offline vote limit reached");
}
SharedVotePolicy policy = ops.countingPolicy();
SharedVoteInput input = new SharedVoteInput(voteId, playerName, ops.serviceSite(), voteTime,
ops.realVote(), ops.addTotals(), ops.proxyVote(), ops.forceProxyRouting(), ops.wasOnline());
SharedVoteAccounting.apply(input, policy, () -> ops.userOnline(user), () -> ops.addTotal(user),
() -> ops.addTotalDaily(user), () -> ops.addTotalWeekly(user), () -> ops.addPoints(user));
ops.checkDayVoteStreak(user, ops.forceProxyRouting());
if (ops.limitMonthlyVotes() && (!ops.proxyVote() || ops.hasProxyTextTotals())) {
int value = ops.proxyVote() ? ops.proxyMonthTotal() : ops.userMonthTotal(user);
int days = ops.currentDayOfMonth();
ops.extraDebug("Current day of month: " + days + " Current total: " + value);
if (value >= days * ops.enabledSiteCount()) {
ops.debug("Detected higher month total, changing. Current Total: " + value + " Days: " + days
+ " New Total: " + days * ops.enabledSiteCount());
ops.setMonthTotal(user, days * ops.enabledSiteCount());
}
}
ops.milestones(user, voteId, ops.forceProxyRouting());
ops.dateMilestones(user, site, voteTime, voteId);
ops.cooldown(user, site);
ops.voteStreak(user, voteTime, voteId);
ops.postVote(site, user, playerName, voteTime, voteId, cached);
if (ops.userOnline(user) || ops.placeholderCacheAlways()) ops.updatePlaceholders(user);
if (!ops.userOnline(user)) ops.clearCache(user);
ops.setUpdate();
ops.extraDebug("Finished vote processing: " + playerName + "/" + userId);
}
}
@@ -0,0 +1,247 @@
package com.bencodez.votingplugin.data;
import java.io.File;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.plugin.Plugin;
import com.bencodez.advancedcore.api.time.TimeType;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.util.ServerDataPublicationFence;
/**
* Routes SDK publications through this generation's ownership lease. The SDK's
* original file/configuration remains authoritative; no YAML snapshot is copied.
*/
public final class GenerationFencedServerDataFile extends com.bencodez.advancedcore.data.ServerData {
private final com.bencodez.advancedcore.data.ServerData delegate;
private final ServerDataPublicationFence fence;
private final VotingPluginMain ownerPlugin;
public GenerationFencedServerDataFile(VotingPluginMain plugin,
com.bencodez.advancedcore.data.ServerData delegate, ServerDataPublicationFence fence) {
super(plugin);
this.ownerPlugin = java.util.Objects.requireNonNull(plugin);
this.delegate = java.util.Objects.requireNonNull(delegate);
this.fence = java.util.Objects.requireNonNull(fence);
}
public boolean wraps(com.bencodez.advancedcore.data.ServerData file) { return delegate == file; }
public boolean belongsTo(ServerDataPublicationFence owner) { return fence == owner; }
/** Checkpoint callers use the same monitor as native final publication. */
@Override public Object publicationMonitor() {
return java.util.Objects.requireNonNull(delegate.publicationMonitor(), "Server-data publication monitor");
}
private void withOwner(Runnable operation) {
try (var lease = fence.acquire()) {
synchronized (publicationMonitor()) {
try { operation.run(); }
catch (RuntimeException | Error failure) {
try { refreshVotePartyDisplay(); }
catch (RuntimeException | Error observationFailure) {
if (observationFailure != failure) failure.addSuppressed(observationFailure);
}
throw failure;
}
refreshVotePartyDisplay();
}
}
}
private <T> T withOwnedValue(Supplier<T> operation) {
try (var lease = fence.acquire()) {
synchronized (publicationMonitor()) { return operation.get(); }
}
}
@Override public FileConfiguration getData() { return delegate.getData(); }
@Override public File getdFile() { return delegate.getdFile(); }
@Override public JavaPlugin getPlugin() { return delegate.getPlugin(); }
@Override public boolean isJustCreated() { return delegate.isJustCreated(); }
@Override public boolean isFailedToRead() { return delegate.isFailedToRead(); }
@Override public boolean isIgnoreCase() { return delegate.isIgnoreCase(); }
@Override public void setIgnoreCase(boolean value) {
withOwner(() -> delegate.setIgnoreCase(value));
}
@Override public void createSection(String path) {
mutateOrdinaryData(data -> data.createSection(path));
}
@Override public void setData(FileConfiguration data) {
withOwner(() -> delegate.setData(data));
}
@Override public void setValue(String path, Object value) {
mutateOrdinaryData(data -> data.set(path, value));
}
@Override public void onFileCreation() {
withOwner(delegate::onFileCreation);
}
@Override public void setup() {
withOwner(delegate::setup);
}
@Override public void loadValues() {
withOwner(delegate::loadValues);
}
@Override public void reloadData() {
withOwner(delegate::reloadData);
}
private void refreshVotePartyDisplay() {
var wrapper = ownerPlugin.getServerData();
if (wrapper != null) {
synchronized (publicationMonitor()) {
if (delegate.getData() != null) wrapper.refreshVotePartyDisplay();
}
}
}
@Override public void saveData() {
withOwner(() -> {
refreshVotePartyDisplay();
delegate.saveData();
});
}
@Override public void saveDataChecked() {
withOwner(() -> {
refreshVotePartyDisplay();
delegate.saveDataChecked();
});
}
@Override protected void saveTimeChangeTransitionData() { saveDataChecked(); }
@Override public void mutateOrdinaryData(Consumer<FileConfiguration> mutation) {
// Delegate admission, live mutation and saving to the actual SDK owner.
// The projection is published inside that owner's callback before I/O.
withOwner(() -> delegate.mutateOrdinaryData(data -> applyOwnedMutation(mutation, data)));
}
private void refreshIfLiveMutation(FileConfiguration data) {
var wrapper = ownerPlugin.getServerData();
if (wrapper == null) return;
synchronized (publicationMonitor()) {
// Native final publication mutates a staged candidate first. Legacy
// adapters may instead mutate the live owner before their save.
if (data == delegate.getData()) wrapper.refreshVotePartyDisplay();
}
}
private void applyOwnedMutation(Consumer<FileConfiguration> mutation, FileConfiguration data) {
try { mutation.accept(data); }
catch (RuntimeException | Error failure) {
try { refreshIfLiveMutation(data); }
catch (RuntimeException | Error observationFailure) {
if (observationFailure != failure) failure.addSuppressed(observationFailure);
}
throw failure;
}
refreshIfLiveMutation(data);
}
private void finishFinalPublication(ServerDataPublicationFence.Lease lease,
CompletableFuture<Void> result, Throwable failure) {
Throwable outcome = failure;
try {
// Partial startup may have no display wrapper. In that case, do not
// introduce a YAML-observation dependency into final-stage drain.
refreshVotePartyDisplay();
} catch (Throwable observationFailure) {
if (outcome == null) outcome = observationFailure;
else if (outcome != observationFailure) outcome.addSuppressed(observationFailure);
}
// Refresh observes only the actual live owner; it never acknowledges a
// failed/cancelled publication or releases its retained generation.
if (outcome != null) { result.completeExceptionally(outcome); return; }
try { lease.close(); result.complete(null); }
catch (Throwable releaseFailure) { result.completeExceptionally(releaseFailure); }
}
/** Retain this handle only for retries of the same accepted mutation. */
FinalPublication prepareFinalPublication(Consumer<FileConfiguration> mutation) {
return new FinalPublication(mutation);
}
final class FinalPublication {
private final Consumer<FileConfiguration> mutation;
private ServerDataPublicationFence.Lease lease;
private CompletableFuture<Void> attempt;
private boolean publicationConfirmed;
private FinalPublication(Consumer<FileConfiguration> mutation) { this.mutation = mutation; }
CompletionStage<Void> publish() {
CompletableFuture<Void> result;
ServerDataPublicationFence.Lease owner;
boolean refreshOnly;
synchronized (this) {
if (attempt != null && !attempt.isCompletedExceptionally())
return attempt.minimalCompletionStage();
if (lease == null) {
try { lease = fence.acquire(); }
catch (Throwable failure) { return CompletableFuture.failedFuture(failure); }
}
owner = lease;
result = new CompletableFuture<>();
attempt = result;
refreshOnly = publicationConfirmed;
}
// No operation lock is held across the actual owner's YAML or I/O.
if (refreshOnly) finish(owner, result, null);
else {
try {
java.util.Objects.requireNonNull(mutation, "mutation");
java.util.Objects.requireNonNull(delegate.mutateFinalData(data -> applyOwnedMutation(mutation, data)))
.whenComplete((ignored, failure) -> finish(owner, result, failure));
} catch (Throwable failure) { finish(owner, result, failure); }
}
return result.minimalCompletionStage();
}
private void finish(ServerDataPublicationFence.Lease owner,
CompletableFuture<Void> result, Throwable failure) {
if (failure == null) {
synchronized (this) { publicationConfirmed = true; }
}
// A retry after a display-observation failure must not repeat a
// confirmed mutation. Failed publication instead retains this one
// lease until this same operation has a confirmed successful retry.
finishFinalPublication(owner, result, failure);
}
}
@Override public CompletionStage<Void> mutateFinalData(Consumer<FileConfiguration> mutation) {
// Ordinary one-shot callers retain failed ownership conservatively.
// A caller with an explicit retry contract retains the operation handle.
return prepareFinalPublication(mutation).publish();
}
@Override public void setData(String path, Object value) { mutateOrdinaryData(data -> data.set(path, value)); }
@Override public void setIgnoreTime(boolean value) { withOwner(() -> delegate.setIgnoreTime(value)); }
@Override public void setLastUpdated() { withOwner(delegate::setLastUpdated); }
@Override public void setPluginVersion(Plugin plugin) { withOwner(() -> delegate.setPluginVersion(plugin)); }
@Override public void setPrevDay(int value) { withOwner(() -> delegate.setPrevDay(value)); }
@Override public void setPrevMonth(String value) { withOwner(() -> delegate.setPrevMonth(value)); }
@Override public void setPrevWeekDay(int value) { withOwner(() -> delegate.setPrevWeekDay(value)); }
@Override public TimeChangeTransitionState beginTimeChangeTransition(TimeType type, String period, String marker) {
return withOwnedValue(() -> delegate.beginTimeChangeTransition(type, period, marker));
}
@Override public TimeChangeTransitionState getPendingTimeChangeTransition(TimeType type) {
return withOwnedValue(() -> delegate.getPendingTimeChangeTransition(type));
}
@Override public void completeTimeChangeTransition(TimeChangeTransitionState transition) {
withOwner(() -> delegate.completeTimeChangeTransition(transition));
}
@Override public void failTimeChangeTransition(TimeChangeTransitionState transition) {
withOwner(() -> delegate.failTimeChangeTransition(transition));
}
@Override public boolean isPendingTimeChangeTransition(TimeChangeTransitionState transition) {
return withOwnedValue(() -> delegate.isPendingTimeChangeTransition(transition));
}
}
@@ -18,9 +18,182 @@ import com.bencodez.votingplugin.signs.SignHandler;
import com.bencodez.votingplugin.timequeue.VoteTimeQueue;
import com.bencodez.votingplugin.topvoter.TopVoter;
public class ServerData {
public class ServerData {
/** Immutable live display values, separate from durable publication acknowledgement. */
public record VotePartyDisplay(int total, int extraRequired) {
public int required(int configured) { return extraRequired > 0 ? configured + extraRequired : configured; }
public int needed(int configured) { return required(configured) - total; }
}
private volatile VotePartyDisplay votePartyDisplay = new VotePartyDisplay(0, 0);
/** Snapshot-only: never acquires a YAML owner or waits for filesystem publication. */
public VotePartyDisplay getVotePartyDisplay() { return votePartyDisplay; }
/** Called during startup before placeholders are registered. */
public void refreshVotePartyDisplay() { mutateData(this::captureVotePartyDisplay); }
private void captureVotePartyDisplay() {
var data = getFile().getData().getConfigurationSection("VotingPlugin");
votePartyDisplay = data == null ? new VotePartyDisplay(0, 0)
: new VotePartyDisplay(data.getInt("VoteParty.Total"), data.getInt("VotePartyExtraRequired"));
}
private VotingPluginMain plugin = VotingPluginMain.plugin;
private volatile com.bencodez.advancedcore.data.ServerData generationFile;
private final Object generationBinding = new Object();
private volatile com.bencodez.votingplugin.util.ServerDataPublicationFence generationFence;
private com.bencodez.advancedcore.data.ServerData getFile() {
var current = generationFile;
if (current != null) return current;
synchronized (generationBinding) {
if (generationFile == null) {
var owner = getPublicationFence();
// Reject an old lazy wrapper before exposing successor mutable state.
try (var lease = owner == null ? null : owner.acquire()) {
var file = plugin.getServerDataFile();
if (file instanceof GenerationFencedServerDataFile guarded && !guarded.belongsTo(owner))
throw new IllegalStateException("Server-data file belongs to another generation");
generationFile = file;
}
}
return generationFile;
}
}
private record SignCoordinates(String world, int x, int y, int z) {
static SignCoordinates capture(Location location) {
return new SignCoordinates(location.getWorld().getName(), (int) location.getX(),
(int) location.getY(), (int) location.getZ());
}
void store(ConfigurationSection data, String path) {
data.set(path + ".World", world);
data.set(path + ".X", x);
data.set(path + ".Y", y);
data.set(path + ".Z", z);
}
}
/** Detached shutdown payload: no platform object is accessed by its publisher. */
public static final class SignSnapshot {
private final String count;
private final SignCoordinates location;
private final SignCoordinates skull;
private final String data;
private final int position;
private SignSnapshot(String count, Location location, Location skull, String data, int position) {
this.count = count;
this.location = SignCoordinates.capture(location);
this.skull = skull == null ? null : SignCoordinates.capture(skull);
this.data = data;
this.position = position;
}
private void store(ConfigurationSection target) {
location.store(target, "Signs." + count);
target.set("Signs." + count + ".Data", data);
target.set("Signs." + count + ".Position", position);
if (skull != null) skull.store(target, "Signs." + count + ".Skull");
}
}
public static SignSnapshot captureSignSnapshot(String count, Location location, Location skull,
String data, int position) {
return new SignSnapshot(count, location, skull, data, position);
}
private record ShutdownSignPublication(List<SignSnapshot> snapshot,
java.util.function.Supplier<java.util.concurrent.CompletionStage<Void>> publisher) { }
private final Object shutdownSignBinding = new Object();
private ShutdownSignPublication shutdownSignPublication;
/** Worker-only: one accepted sign batch, with the same publication owner across retries. */
public java.util.concurrent.CompletionStage<Void> publishShutdownSigns(List<SignSnapshot> signs) {
var snapshot = List.copyOf(signs);
ShutdownSignPublication publication;
synchronized (shutdownSignBinding) {
if (shutdownSignPublication == null) {
var file = getFile();
java.util.function.Consumer<org.bukkit.configuration.file.FileConfiguration> mutation = candidate -> {
var target = candidate.getConfigurationSection("VotingPlugin");
if (target == null) target = candidate.createSection("VotingPlugin");
for (var sign : snapshot) sign.store(target);
};
java.util.function.Supplier<java.util.concurrent.CompletionStage<Void>> publisher;
if (file instanceof GenerationFencedServerDataFile guarded) {
var retained = guarded.prepareFinalPublication(mutation);
publisher = retained::publish;
} else publisher = () -> file.mutateFinalData(mutation);
shutdownSignPublication = new ShutdownSignPublication(snapshot, publisher);
} else if (!shutdownSignPublication.snapshot().equals(snapshot)) {
return java.util.concurrent.CompletableFuture.failedFuture(
new IllegalStateException("Shutdown sign publication already owns a different batch"));
}
publication = shutdownSignPublication;
}
return publication.publisher().get();
}
/** Only YAML work belongs here; platform callbacks run outside the file owner. */
private void mutateData(Runnable mutation) {
var fence = getPublicationFence();
try (var owner = fence == null ? null : fence.acquire()) {
synchronized (publicationMonitor()) {
mutation.run();
}
}
}
/** Admit the complete ordinary mutation before changing live YAML or its display. */
private void mutateSavedData(Runnable mutation) {
mutateData(() -> getFile().mutateOrdinaryData(ignored -> {
try { mutation.run(); }
catch (RuntimeException | Error failure) {
try { captureVotePartyDisplay(); }
catch (RuntimeException | Error observationFailure) {
if (observationFailure != failure) failure.addSuppressed(observationFailure);
}
throw failure;
}
captureVotePartyDisplay();
}));
}
/** Store a scalar and save under the same YAML ownership as final SDK snapshots. */
public void setInt(String path, int value) {
mutateSavedData(() -> {
getData().set(path, value);
});
}
/** Store a detached string list, preventing later caller mutation of live YAML. */
public void setStringList(String path, List<String> values) {
var snapshot = values == null ? null : new ArrayList<>(values);
mutateSavedData(() -> {
getData().set(path, snapshot);
});
}
/** All mutations serialize with the actual SDK YAML owner. */
private Object publicationMonitor() {
var file = getFile();
return java.util.Objects.requireNonNull(file.publicationMonitor(), "Server-data publication monitor");
}
private com.bencodez.votingplugin.util.ServerDataPublicationFence getPublicationFence() {
var current = generationFence;
if (current != null) return current;
synchronized (generationBinding) {
if (generationFence == null) generationFence = plugin.getServerDataPublicationFence();
return generationFence;
}
}
/**
* Constructs a new ServerData.
@@ -29,6 +202,7 @@ public class ServerData {
*/
public ServerData(VotingPluginMain plugin) {
this.plugin = plugin;
this.generationFence = plugin.getServerDataPublicationFence();
}
/**
@@ -36,13 +210,15 @@ public class ServerData {
*
* @param placeholder the placeholder to add
*/
public void addAutoCachedPlaceholder(String placeholder) {
List<String> p = getAutoCachedPlaceholder();
if (!ArrayUtils.containsIgnoreCase(p, placeholder)) {
p.add(placeholder);
setAutoCachedPlaceholder(p);
}
public void addAutoCachedPlaceholder(String placeholder) {
mutateData(() -> {
List<String> p = getAutoCachedPlaceholder();
if (!ArrayUtils.containsIgnoreCase(p, placeholder)) {
p.add(placeholder);
setAutoCachedPlaceholder(p);
}
});
}
/**
@@ -50,12 +226,14 @@ public class ServerData {
*
* @param site the service site to add
*/
public synchronized void addServiceSite(String site) {
ArrayList<String> l = getServiceSites();
if (!getServiceSites().contains(site)) {
l.add(site);
}
setServiceSites(ArrayUtils.removeDuplicates(l));
public void addServiceSite(String site) {
mutateData(() -> {
ArrayList<String> l = getServiceSites();
if (!getServiceSites().contains(site)) {
l.add(site);
}
setServiceSites(ArrayUtils.removeDuplicates(l));
});
}
/**
@@ -65,19 +243,20 @@ public class ServerData {
* @param data the data
* @param position the position
*/
public void addSign(Location location, String data, int position) {
int count = nextSignNumber();
getData().set("Signs." + count + ".World", location.getWorld().getName());
getData().set("Signs." + count + ".X", location.getBlockX());
getData().set("Signs." + count + ".Y", location.getBlockY());
getData().set("Signs." + count + ".Z", location.getBlockZ());
getData().set("Signs." + count + ".Data", data);
getData().set("Signs." + count + ".Position", position);
saveData();
plugin.getSigns().getSigns().add(new SignHandler(plugin, "" + count, getSignLocation("" + count),
getSignSkullLocation("" + count), getSignData("" + count), getSignPosition("" + count)));
public void addSign(Location location, String data, int position) {
var coordinates = new SignCoordinates(location.getWorld().getName(), location.getBlockX(),
location.getBlockY(), location.getBlockZ());
int[] selected = new int[1];
mutateSavedData(() -> {
int count = nextSignNumber();
selected[0] = count;
coordinates.store(getData(), "Signs." + count);
getData().set("Signs." + count + ".Data", data);
getData().set("Signs." + count + ".Position", position);
});
String count = Integer.toString(selected[0]);
plugin.getSigns().getSigns().add(new SignHandler(plugin, count, getSignLocation(count),
getSignSkullLocation(count), getSignData(count), getSignPosition(count)));
}
/**
@@ -87,10 +266,14 @@ public class ServerData {
* @param vote the vote time queue entry
*/
public void addTimeVoted(int num, VoteTimeQueue vote) {
getData().set("TimedVoteCache." + num + ".Name", vote.getName());
getData().set("TimedVoteCache." + num + ".Service", vote.getService());
getData().set("TimedVoteCache." + num + ".Time", vote.getTime());
saveData();
mutateData(() -> {
getData().set("TimedVoteCache." + num + ".Name", vote.getName());
getData().set("TimedVoteCache." + num + ".Service", vote.getService());
getData().set("TimedVoteCache." + num + ".Time", vote.getTime());
getData().set("TimedVoteCache." + num + ".LocalOccurrenceId", vote.getLocalOccurrenceId() == null ? null : vote.getLocalOccurrenceId().toString());
getData().set("TimedVoteCache." + num + ".CanonicalOccurrenceTime", vote.getCanonicalOccurrenceTime() == 0L ? null : vote.getCanonicalOccurrenceTime());
saveTimedVoteCache();
});
}
/**
@@ -98,16 +281,28 @@ public class ServerData {
*
* @param ident the vote shop identifier
*/
public void addVoteShopPurchase(String ident) {
setVoteShopPurchases(ident, (getVoteShopPurchases(ident) + 1));
public void addVoteShopPurchase(String ident) {
mutateData(() -> {
setVoteShopPurchases(ident, (getVoteShopPurchases(ident) + 1));
});
}
/** Assign a legacy local cache row its identity before replay is scheduled. */
public void setTimedVoteLocalOccurrenceId(String key, UUID occurrenceId) {
mutateData(() -> {
getData().set("TimedVoteCache." + key + ".LocalOccurrenceId", occurrenceId.toString());
saveTimedVoteCache();
});
}
/**
* Clears the timed vote cache.
*/
public void clearTimedVoteCache() {
getData().set("TimedVoteCache", null);
saveData();
public void clearTimedVoteCache() {
mutateData(() -> {
getData().set("TimedVoteCache", null);
saveTimedVoteCache();
});
}
/**
@@ -142,13 +337,15 @@ public class ServerData {
*
* @return the data
*/
public ConfigurationSection getData() {
ConfigurationSection data = plugin.getServerDataFile().getData().getConfigurationSection("VotingPlugin");
if (data == null) {
plugin.getServerDataFile().getData().createSection("VotingPlugin");
data = plugin.getServerDataFile().getData().getConfigurationSection("VotingPlugin");
}
return data;
public ConfigurationSection getData() {
var fence = getPublicationFence();
try (var owner = fence == null ? null : fence.acquire()) {
synchronized (publicationMonitor()) {
ConfigurationSection data = getFile().getData().getConfigurationSection("VotingPlugin");
if (data == null) data = getFile().getData().createSection("VotingPlugin");
return data;
}
}
}
/**
@@ -165,9 +362,10 @@ public class ServerData {
*
* @return the service sites
*/
@SuppressWarnings("unchecked")
public ArrayList<String> getServiceSites() {
return (ArrayList<String>) getData().getList("GottenServiceSites", new ArrayList<>());
public ArrayList<String> getServiceSites() {
synchronized (publicationMonitor()) {
return new ArrayList<>(getData().getStringList("GottenServiceSites"));
}
}
/**
@@ -176,9 +374,11 @@ public class ServerData {
*
* @return persisted service-site observations
*/
public synchronized List<String> getServiceSitesReadOnly() {
ConfigurationSection root = plugin.getServerDataFile().getData().getConfigurationSection("VotingPlugin");
return root == null ? List.of() : List.copyOf(root.getStringList("GottenServiceSites"));
public List<String> getServiceSitesReadOnly() {
synchronized (publicationMonitor()) {
ConfigurationSection root = getFile().getData().getConfigurationSection("VotingPlugin");
return root == null ? List.of() : List.copyOf(root.getStringList("GottenServiceSites"));
}
}
/**
@@ -339,8 +539,11 @@ public class ServerData {
/**
* Reload data.
*/
public void reloadData() {
plugin.getServerDataFile().reloadData();
public void reloadData() {
mutateData(() -> {
getFile().reloadData();
captureVotePartyDisplay();
});
}
/**
@@ -359,36 +562,168 @@ public class ServerData {
* @param top the top voter type
* @param messageId the message ID
*/
public void setTopVoterMessageId(TopVoter top, long messageId) {
getData().set("DiscordSRV.TopVoterMessageId." + top.toString(), messageId);
saveData();
public void setTopVoterMessageId(TopVoter top, long messageId) {
mutateSavedData(() -> {
getData().set("DiscordSRV.TopVoterMessageId." + top.toString(), messageId);
});
}
/**
/**
* Uses the existing writer and confirms this ID in the saved file. Legacy void saves may
* swallow I/O failure; queued/custom writers may also remain unconfirmed at this point.
* False is an uncertain outcome, never permission to delete a possibly recorded message.
* Call only on the existing worker, under this facade's normal save ordering.
*/
public synchronized boolean setTopVoterMessageIdConfirmed(TopVoter top, long messageId) {
setTopVoterMessageId(top, messageId);
try {
var saved = new org.bukkit.configuration.file.YamlConfiguration();
saved.load(plugin.getServerDataFile().getdFile());
return saved.getLong("VotingPlugin.DiscordSRV.TopVoterMessageId." + top.toString(), 0) == messageId;
} catch (java.io.IOException | org.bukkit.configuration.InvalidConfigurationException | RuntimeException failure) {
return false;
}
}
/**
* Removes the sign.
*
* @param sign the sign
*/
public void removeSign(String sign) {
getData().set("Signs." + sign + ".World", null);
getData().set("Signs." + sign + ".X", null);
getData().set("Signs." + sign + ".Y", null);
getData().set("Signs." + sign + ".Z", null);
getData().set("Signs." + sign + ".Data", null);
getData().set("Signs." + sign + ".Position", null);
getData().set("Signs." + sign, null);
getData().set("Signs." + sign + ".Skull.World", null);
getData().set("Signs." + sign + ".Skull.X", null);
getData().set("Signs." + sign + ".Skull.Y", null);
getData().set("Signs." + sign + ".Skull.Z", null);
saveData();
public void removeSign(String sign) {
mutateSavedData(() -> {
getData().set("Signs." + sign + ".World", null);
getData().set("Signs." + sign + ".X", null);
getData().set("Signs." + sign + ".Y", null);
getData().set("Signs." + sign + ".Z", null);
getData().set("Signs." + sign + ".Data", null);
getData().set("Signs." + sign + ".Position", null);
getData().set("Signs." + sign, null);
getData().set("Signs." + sign + ".Skull.World", null);
getData().set("Signs." + sign + ".Skull.X", null);
getData().set("Signs." + sign + ".Skull.Y", null);
getData().set("Signs." + sign + ".Skull.Z", null);
});
}
/**
* Save data.
*/
public synchronized void saveData() {
plugin.getServerDataFile().saveData();
/** Checkpoint the pending suffix without retiring its live queue entries. */
public void replaceTimedVoteCache(java.util.Collection<VoteTimeQueue> pending) {
mutateData(() -> {
Object previous = getData().get("TimedVoteCache");
try {
setTimedVoteCache(pending);
saveTimedVoteCache();
} catch (RuntimeException | Error failure) {
getData().set("TimedVoteCache", previous);
throw failure;
}
});
}
/** Publish the recovery suffix and conservative in-flight record in one file save. */
public void prepareTimedVoteDispatch(UUID id, VoteTimeQueue vote,
java.util.Collection<VoteTimeQueue> pending) {
mutateData(() -> {
String path = "UncertainTimedVotes." + id;
Object previousCache = getData().get("TimedVoteCache");
Object previousRecord = getData().get(path);
try {
setTimedVoteCache(pending);
getData().set(path, null);
getData().createSection(path, timedVoteFields(vote));
saveTimedVoteCache();
} catch (RuntimeException | Error failure) {
getData().set("TimedVoteCache", previousCache);
getData().set(path, previousRecord);
throw failure;
}
});
}
/** Restore an undispatched occurrence and its pending suffix for ordinary restart recovery. */
public void abortTimedVoteDispatch(UUID id, java.util.Collection<VoteTimeQueue> pending) {
mutateData(() -> {
String path = "UncertainTimedVotes." + id;
Object previousCache = getData().get("TimedVoteCache");
Object previousRecord = getData().get(path);
try {
setTimedVoteCache(pending);
getData().set(path, null);
saveTimedVoteCache();
} catch (RuntimeException | Error failure) {
getData().set("TimedVoteCache", previousCache);
getData().set(path, previousRecord);
throw failure;
}
});
}
/** Clear only a confirmed dispatch record; failed writes retain its live representation. */
public void completeTimedVoteDispatch(UUID id) {
mutateData(() -> {
String path = "UncertainTimedVotes." + id;
Object previous = getData().get(path);
try {
getData().set(path, null);
saveTimedVoteCache();
} catch (RuntimeException | Error failure) {
getData().set(path, previous);
throw failure;
}
});
}
private void setTimedVoteCache(java.util.Collection<VoteTimeQueue> pending) {
java.util.Map<String, Object> rows = new java.util.LinkedHashMap<>();
int index = 0;
for (VoteTimeQueue vote : pending) rows.put(Integer.toString(index++), timedVoteFields(vote));
getData().set("TimedVoteCache", null);
getData().createSection("TimedVoteCache", rows);
}
private static java.util.Map<String, Object> timedVoteFields(VoteTimeQueue vote) {
java.util.Map<String, Object> fields = new java.util.LinkedHashMap<>();
fields.put("Name", vote.getName());
fields.put("Service", vote.getService());
fields.put("Time", vote.getTime());
if (vote.getLocalOccurrenceId() != null) fields.put("LocalOccurrenceId", vote.getLocalOccurrenceId().toString());
if (vote.getCanonicalOccurrenceTime() != 0L) fields.put("CanonicalOccurrenceTime", vote.getCanonicalOccurrenceTime());
return fields;
}
/** Retain an ambiguous event delivery without making it eligible for automatic replay. */
public void addUncertainTimedVote(java.util.UUID id, VoteTimeQueue vote) {
mutateData(() -> {
String path = "UncertainTimedVotes." + id;
getData().set(path + ".Name", vote.getName());
getData().set(path + ".Service", vote.getService());
getData().set(path + ".Time", vote.getTime());
getData().set(path + ".LocalOccurrenceId", vote.getLocalOccurrenceId() == null ? null : vote.getLocalOccurrenceId().toString());
getData().set(path + ".CanonicalOccurrenceTime", vote.getCanonicalOccurrenceTime() == 0L ? null : vote.getCanonicalOccurrenceTime());
saveTimedVoteCache();
});
}
/** Timed votes must observe write failures before their in-memory queue is retired. */
private void saveTimedVoteCache() {
var fence = getPublicationFence();
try (var owner = fence == null ? null : fence.acquire()) {
var file = getFile();
if (file instanceof GenerationFencedServerDataFile guarded) guarded.saveDataChecked();
else file.getData().save(file.getdFile());
} catch (java.io.IOException failure) {
throw new java.io.UncheckedIOException("Failed to save timed vote cache", failure);
}
}
/** Saves server data through the existing general-purpose file helper. */
public void saveData() {
mutateData(() -> {
// Publish the complete live display projection before entering disk I/O.
// A failed save still does not acknowledge durable storage.
captureVotePartyDisplay();
getFile().saveData();
});
}
/**
@@ -396,13 +731,10 @@ public class ServerData {
*
* @param disabledReminders the list of UUIDs with disabled reminders
*/
public void saveDisabledReminders(ArrayList<UUID> disabledReminders) {
ArrayList<String> uuids = new ArrayList<>();
for (UUID uuid : disabledReminders) {
uuids.add(uuid.toString());
}
getData().set("DisabledReminders", uuids);
saveData();
public void saveDisabledReminders(ArrayList<UUID> disabledReminders) {
ArrayList<String> uuids = new ArrayList<>();
for (UUID uuid : disabledReminders) uuids.add(uuid.toString());
setStringList("DisabledReminders", uuids);
}
/**
@@ -410,9 +742,8 @@ public class ServerData {
*
* @param placeholders the list of placeholders
*/
public void setAutoCachedPlaceholder(List<String> placeholders) {
getData().set("AutoCachePlaceholders", placeholders);
saveData();
public void setAutoCachedPlaceholder(List<String> placeholders) {
setStringList("AutoCachePlaceholders", placeholders);
}
/**
@@ -420,9 +751,8 @@ public class ServerData {
*
* @param current the current vote count
*/
public void setBungeeVotePartyCurrent(int current) {
getData().set("BungeeVotePartyCurrent", current);
saveData();
public void setBungeeVotePartyCurrent(int current) {
setInt("BungeeVotePartyCurrent", current);
}
/**
@@ -430,9 +760,8 @@ public class ServerData {
*
* @param required the required vote count
*/
public void setBungeeVotePartyRequired(int required) {
getData().set("BungeeVotePartyRequired", required);
saveData();
public void setBungeeVotePartyRequired(int required) {
setInt("BungeeVotePartyRequired", required);
}
/**
@@ -440,9 +769,8 @@ public class ServerData {
*
* @param list the service sites list
*/
public void setServiceSites(ArrayList<String> list) {
getData().set("GottenServiceSites", list);
saveData();
public void setServiceSites(ArrayList<String> list) {
setStringList("GottenServiceSites", list);
}
/**
@@ -450,9 +778,10 @@ public class ServerData {
*
* @param value the converted status
*/
public void setShopConverted(boolean value) {
getData().set("VoteShopConverted", value);
saveData();
public void setShopConverted(boolean value) {
mutateSavedData(() -> {
getData().set("VoteShopConverted", value);
});
}
/**
@@ -464,21 +793,15 @@ public class ServerData {
* @param data the sign data
* @param position the sign position
*/
public void setSign(String count, Location location, Location skullLocation, String data, int position) {
getData().set("Signs." + count + ".World", location.getWorld().getName());
getData().set("Signs." + count + ".X", (int) location.getX());
getData().set("Signs." + count + ".Y", (int) location.getY());
getData().set("Signs." + count + ".Z", (int) location.getZ());
getData().set("Signs." + count + ".Data", data);
getData().set("Signs." + count + ".Position", position);
if (skullLocation != null) {
getData().set("Signs." + count + ".Skull.World", skullLocation.getWorld().getName());
getData().set("Signs." + count + ".Skull.X", (int) skullLocation.getX());
getData().set("Signs." + count + ".Skull.Y", (int) skullLocation.getY());
getData().set("Signs." + count + ".Skull.Z", (int) skullLocation.getZ());
}
saveData();
public void setSign(String count, Location location, Location skullLocation, String data, int position) {
var coordinates = SignCoordinates.capture(location);
var skull = skullLocation == null ? null : SignCoordinates.capture(skullLocation);
mutateSavedData(() -> {
coordinates.store(getData(), "Signs." + count);
getData().set("Signs." + count + ".Data", data);
getData().set("Signs." + count + ".Position", position);
if (skull != null) skull.store(getData(), "Signs." + count + ".Skull");
});
}
/**
@@ -487,21 +810,20 @@ public class ServerData {
* @param count the sign count
* @param skullLocation the skull location
*/
public void setSkullLocation(String count, Location skullLocation) {
if (skullLocation != null) {
getData().set("Signs." + count + ".Skull.World", skullLocation.getWorld().getName());
getData().set("Signs." + count + ".Skull.X", (int) skullLocation.getX());
getData().set("Signs." + count + ".Skull.Y", (int) skullLocation.getY());
getData().set("Signs." + count + ".Skull.Z", (int) skullLocation.getZ());
}
public void setSkullLocation(String count, Location skullLocation) {
if (skullLocation == null) return;
var coordinates = SignCoordinates.capture(skullLocation);
mutateData(() -> coordinates.store(getData(), "Signs." + count + ".Skull"));
}
/**
* Sets the version.
*/
public void setVersion() {
getData().set("Version", Bukkit.getVersion());
saveData();
public void setVersion() {
String version = Bukkit.getVersion();
mutateSavedData(() -> {
getData().set("Version", version);
});
}
/**
@@ -509,9 +831,10 @@ public class ServerData {
*
* @param value the extra required votes
*/
public void setVotePartyExtraRequired(int value) {
getData().set("VotePartyExtraRequired", value);
saveData();
public void setVotePartyExtraRequired(int value) {
mutateSavedData(() -> {
getData().set("VotePartyExtraRequired", value);
});
}
/**
@@ -520,42 +843,45 @@ public class ServerData {
* @param ident the vote shop identifier
* @param amount the purchase amount
*/
public void setVoteShopPurchases(String ident, int amount) {
getData().set("VoteShopPurchases." + ident, amount);
saveData();
public void setVoteShopPurchases(String ident, int amount) {
mutateSavedData(() -> {
getData().set("VoteShopPurchases." + ident, amount);
});
}
/**
* Updates the last vote party timestamp to today.
*/
public void updateLastVoteParty() {
getData().set("LastVoteParty", plugin.getTimeChecker().getTime().getDayOfYear());
saveData();
public void updateLastVoteParty() {
int day = plugin.getTimeChecker().getTime().getDayOfYear();
setInt("LastVoteParty", day);
}
/**
* Updates the last vote party week timestamp to this week.
*/
public void updateLastVotePartyWeek() {
getData().set("LastVotePartyWeek",
plugin.getTimeChecker().getTime().get(WeekFields.of(Locale.getDefault()).weekOfYear()));
saveData();
public void updateLastVotePartyWeek() {
int week = plugin.getTimeChecker().getTime().get(WeekFields.of(Locale.getDefault()).weekOfYear());
setInt("LastVotePartyWeek", week);
}
/**
* Updates placeholders to lowercase format.
*/
public void updatePlaceholders() {
boolean data = getData().getBoolean("AutoCacheUpdated", false);
if (!data) {
List<String> placeholders = getAutoCachedPlaceholder();
for (int i = 0; i < placeholders.size(); i++) {
placeholders.set(i, placeholders.get(i).toLowerCase());
}
setAutoCachedPlaceholder(placeholders);
getData().set("AutoCacheUpdated", true);
saveData();
}
public void updatePlaceholders() {
mutateData(() -> {
boolean data = getData().getBoolean("AutoCacheUpdated", false);
if (!data) {
mutateSavedData(() -> {
List<String> placeholders = getAutoCachedPlaceholder();
for (int i = 0; i < placeholders.size(); i++) {
placeholders.set(i, placeholders.get(i).toLowerCase());
}
getData().set("AutoCachePlaceholders", placeholders);
getData().set("AutoCacheUpdated", true);
});
}
});
}
/**
@@ -3,10 +3,8 @@ package com.bencodez.votingplugin.discord;
import java.awt.Color;
import java.time.Instant;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -27,6 +25,7 @@ import github.scarsz.discordsrv.api.events.DiscordReadyEvent;
import github.scarsz.discordsrv.dependencies.jda.api.EmbedBuilder;
import github.scarsz.discordsrv.dependencies.jda.api.JDA;
import github.scarsz.discordsrv.dependencies.jda.api.entities.TextChannel;
import github.scarsz.discordsrv.dependencies.jda.api.entities.Message;
import github.scarsz.discordsrv.dependencies.jda.api.requests.ErrorResponse;
import github.scarsz.discordsrv.dependencies.jda.api.exceptions.ErrorResponseException;
import github.scarsz.discordsrv.util.DiscordUtil;
@@ -40,8 +39,45 @@ public class DiscordHandler {
private final VotingPluginMain plugin;
@Getter
private final HashMap<TopVoter, Long> topVoterMessageIds = new HashMap<TopVoter, Long>();
private final Set<TopVoter> recoveringTopVoters = new HashSet<>();
/** Identity, rather than generation alone, owns every request and its callbacks. */
private static final class Publication {
final long generation;
final boolean persistent;
boolean sending, editing, recovering, again, completing, retired;
long requestedGeneration;
final AtomicBoolean sendCompleted = new AtomicBoolean(false);
Publication(long generation, boolean persistent, boolean sending) {
this.generation = generation; this.requestedGeneration = generation;
this.persistent = persistent; this.sending = sending; this.editing = !sending;
}
}
private static final int PENDING = 0, WRITING = 1, RETAINED = 2, DISCARDED = 3, DETACHED = 4;
private static final int MAX_OWNED_RECEIPTS = 128;
/** External success is owned here before any cancellable worker handoff. */
private static final class SentReceipt {
final TopVoter top;
final Message message;
final long id;
final long generation;
final AtomicBoolean cleanupRequested = new AtomicBoolean(false);
int state = PENDING;
boolean confirmed;
SentReceipt(TopVoter top, Message message, long generation) {
this.top = top; this.message = message; this.id = message.getIdLong(); this.generation = generation;
}
}
private final java.util.EnumMap<TopVoter, Publication> publications = new java.util.EnumMap<>(TopVoter.class);
/** Guarded by publications; includes uncertain saves until confirmed replacement or explicit detachment. */
private final java.util.Set<SentReceipt> ownedReceipts = new java.util.HashSet<>();
private final AtomicBoolean closed = new AtomicBoolean(false);
private final AtomicBoolean discordReady = new AtomicBoolean(false);
private static final class DisplayUpdate {
private final long generation;
private long requestedGeneration;
private boolean again;
private DisplayUpdate(long generation) { this.generation = generation; this.requestedGeneration = generation; }
}
private final java.util.EnumMap<TopVoter, DisplayUpdate> preparing = new java.util.EnumMap<>(TopVoter.class);
/**
* Constructs a new Discord handler.
@@ -149,14 +185,347 @@ public class DiscordHandler {
* @param top the top voter
*/
public void updateTopVoterMessageId(TopVoter top) {
plugin.extraDebug("Updating Discord Top Voter message for: " + top);
prepareTopVoterMessage(top, plugin.getTopVoterDisplayGeneration());
}
long channelId = plugin.getConfigFile().getDiscordSRVTopVoterChannel(top);
LinkedHashMap<TopVoterPlayer, Integer> topVoters = plugin.getTopVoter(top);
private void prepareTopVoterMessage(TopVoter top, long generation) {
DisplayUpdate update;
synchronized (preparing) {
if (!currentGeneration(generation)) return;
DisplayUpdate pending = preparing.get(top);
if (pending != null) {
pending.again = true;
pending.requestedGeneration = generation;
return;
}
update = new DisplayUpdate(generation);
preparing.put(top, update);
}
try {
plugin.getTopVoterDisplayAsync(top).whenComplete((snapshot, failure) -> {
try {
plugin.getTimer().execute(() -> {
if (!releasePreparation(top, update)) return;
if (!currentGeneration(update.generation)) {
retryRequestedPreparation(top, update);
return;
}
// Joins and TTL/scope changes can revoke omissions while publication is queued.
// Recheck current evidence without opening another asynchronous deadline.
if (update.again || (failure == null && snapshot != null)) {
try { publishTopVoterMessage(top, plugin.getTopVoterDisplay(top), update.generation); }
catch (RuntimeException unavailable) { warnLeaderboardFailure("preparing a current display for " + top); }
} else plugin.getLogger().warning("Unable to prepare Discord Top Voters: " + top);
});
} catch (RuntimeException rejected) {
if (releasePreparation(top, update)) {
try { if (currentGeneration(update.generation)) warnLeaderboardFailure("preparation worker admission for " + top); }
finally { retryRequestedPreparation(top, update); }
}
}
});
} catch (RuntimeException unavailable) {
if (releasePreparation(top, update)) {
try { if (currentGeneration(update.generation)) warnLeaderboardFailure("preparing evidence for " + top); }
finally { retryRequestedPreparation(top, update); }
}
}
}
String title = plugin.getConfigFile().getDiscordSRVTopVoterTitle(top);
private boolean releasePreparation(TopVoter top, DisplayUpdate update) {
synchronized (preparing) { return preparing.remove(top, update); }
}
private void retryRequestedPreparation(TopVoter top, DisplayUpdate update) {
// The retired request's evidence never changes ownership to a replacement filter.
if (update.again && update.requestedGeneration != update.generation)
prepareTopVoterMessage(top, update.requestedGeneration);
}
void publishTopVoterMessage(TopVoter top, LinkedHashMap<TopVoterPlayer, Integer> prepared) {
publishTopVoterMessage(top, prepared, plugin.getTopVoterDisplayGeneration());
}
void publishTopVoterMessage(TopVoter top, LinkedHashMap<TopVoterPlayer, Integer> prepared, long generation) {
if (!currentGeneration(generation)) return;
boolean newMessage = plugin.getConfigFile().isDiscordSRVTopVoterNewMessageOnUpdate(top);
TextChannel channel;
try { channel = leaderboardChannel(plugin.getConfigFile().getDiscordSRVTopVoterChannel(top)); }
catch (RuntimeException unavailable) { warnLeaderboardFailure("channel lookup for " + top); return; }
if (channel == null) { warnLeaderboardFailure("missing channel for " + top); return; }
Publication publication;
long existingId;
synchronized (publications) {
if (!currentGeneration(generation)) return;
Publication pending = publications.get(top);
if (pending != null && (pending.editing || (!pending.retired && pending.generation == generation
&& (pending.sending || pending.completing)))) {
pending.again = true;
pending.requestedGeneration = generation;
return;
}
existingId = topVoterMessageIds.getOrDefault(top, 0L);
publication = new Publication(generation, !newMessage, existingId <= 0 || newMessage);
publications.put(top, publication);
}
try {
var embed = buildTopVoterEmbed(plugin.getConfigFile().getDiscordSRVTopVoterTitle(top), plugin.getTopVoterDisplay(top)).build();
if (!current(top, publication)) { finish(top, publication); return; }
if (publication.sending) send(top, channel, publication, embed);
else channel.editMessageEmbedsById(existingId, embed).queue(
message -> finishEdit(top, publication, existingId),
error -> handleMessageUpdateFailure(top, channel, publication, error));
} catch (RuntimeException unavailable) { failPublication(top, publication, "preparing or submitting a message"); }
}
private boolean current(TopVoter top, Publication publication) {
if (!currentGeneration(publication.generation)) return false;
synchronized (publications) { return publications.get(top) == publication && !publication.retired; }
}
/** Never hold the publication lock while waiting for the ServerData save monitor. */
private boolean persist(TopVoter top, Publication publication, long id) {
var data = plugin.getServerData();
synchronized (data) {
if (!current(top, publication)) return false;
data.setTopVoterMessageId(top, id);
}
return current(top, publication);
}
private void finish(TopVoter top, Publication publication) {
boolean again, prepareAgain;
long generation;
synchronized (publications) {
if (publications.get(top) != publication) return;
publications.remove(top);
again = publication.again;
generation = publication.requestedGeneration;
prepareAgain = publication.retired || generation != publication.generation;
}
// A queued request is independent of whether the previous network operation succeeded.
if (again) {
if (prepareAgain) prepareTopVoterMessage(top, generation);
else dispatchLeaderboard(generation,
() -> publishTopVoterMessage(top, plugin.getTopVoterDisplay(top), generation), () -> { });
}
}
private void warnLeaderboardFailure(String stage) {
// Provider exceptions can contain request URLs or message content. Report the phase only.
plugin.getLogger().warning("Unable to publish Discord Top Voters (" + stage
+ "). Check the Discord channel, permissions and configuration; a later update can retry.");
}
private void failPublication(TopVoter top, Publication publication, String stage) {
try { if (current(top, publication)) warnLeaderboardFailure(stage + " for " + top); }
finally { finish(top, publication); }
}
private void send(TopVoter top, TextChannel channel, Publication publication,
github.scarsz.discordsrv.dependencies.jda.api.entities.MessageEmbed embed) {
if (!current(top, publication)) { finish(top, publication); return; }
channel.sendMessageEmbeds(embed).queue(message -> {
if (!publication.sendCompleted.compareAndSet(false, true)) return;
SentReceipt receipt = new SentReceipt(top, message, publication.generation);
boolean admitted;
synchronized (publications) {
admitted = !closed.get() && (!publication.persistent || ownedReceipts.size() < MAX_OWNED_RECEIPTS);
if (admitted && publication.persistent) ownedReceipts.add(receipt);
publication.completing = true;
}
if (!admitted) {
discardCreatedMessage(publication, receipt); finish(top, publication); return;
}
dispatchLeaderboard(publication.generation, () -> {
try {
if (!current(top, publication)) return;
if (publication.persistent) { retainCreatedMessage(top, publication, receipt); return; }
if (!current(top, publication)) return;
synchronized (publications) {
if (publications.get(top) == publication) topVoterMessageIds.put(top, receipt.id);
}
} catch (RuntimeException failure) { warnLeaderboardFailure("storing a message ID for " + top); }
finally { discardCreatedMessage(publication, receipt); finish(top, publication); }
}, () -> { discardCreatedMessage(publication, receipt); finish(top, publication); });
}, failure -> failPublication(top, publication, "sending a message"));
}
/** Retry an uncertain ID save after a successful edit, without creating another external post. */
private void finishEdit(TopVoter top, Publication publication, long id) {
synchronized (publications) { publication.editing = false; publication.completing = true; }
if (!current(top, publication)) { finish(top, publication); return; }
SentReceipt retry;
synchronized (publications) {
retry = ownedReceipts.stream().filter(receipt -> receipt.top == top && receipt.id == id
&& receipt.state == RETAINED && !receipt.confirmed).findFirst().orElse(null);
}
if (retry == null) { finish(top, publication); return; }
dispatchLeaderboard(publication.generation, () -> {
try { retainCreatedMessage(top, publication, retry); }
finally { discardCreatedMessage(publication, retry); finish(top, publication); }
}, () -> finish(top, publication));
}
/** Never hold publication ownership while waiting for the existing save monitor or file I/O. */
private void retainCreatedMessage(TopVoter top, Publication publication, SentReceipt receipt) {
var obsolete = new java.util.ArrayList<SentReceipt>();
var data = plugin.getServerData();
boolean uncertain = false;
synchronized (data) {
if (!current(top, publication)) return;
synchronized (publications) {
if (!current(top, publication) || (receipt.state != PENDING && receipt.state != RETAINED)) return;
receipt.state = WRITING;
}
try {
boolean confirmed = data.setTopVoterMessageIdConfirmed(top, receipt.id);
long actual = data.getTopVoterMessageId(top);
synchronized (publications) {
if (receipt.state == DETACHED) return;
// A reentrant newer confirmed write already owns the file. Reconcile only this older post.
if (actual != receipt.id && ownedReceipts.stream().anyMatch(other -> other != receipt
&& other.top == top && other.id == actual && other.state == RETAINED && other.confirmed)) return;
receipt.state = RETAINED;
receipt.confirmed = confirmed && actual == receipt.id;
uncertain = !receipt.confirmed;
if (actual == receipt.id && (publications.get(top) == publication
|| topVoterMessageIds.getOrDefault(top, 0L) <= 0)) topVoterMessageIds.put(top, receipt.id);
if (receipt.confirmed) {
var iterator = ownedReceipts.iterator();
while (iterator.hasNext()) {
SentReceipt previous = iterator.next();
if (previous != receipt && previous.top == top && previous.state == RETAINED) {
previous.state = DISCARDED; iterator.remove(); obsolete.add(previous);
}
}
}
}
} catch (RuntimeException failure) {
// The legacy writer may have mutated or saved before throwing; do not delete a possibly recorded post.
synchronized (publications) {
if (receipt.state == DETACHED) return;
receipt.state = RETAINED; receipt.confirmed = false;
if (publications.get(top) == publication && topVoterMessageIds.getOrDefault(top, 0L) <= 0)
topVoterMessageIds.put(top, receipt.id);
}
uncertain = true;
}
}
obsolete.forEach(this::removeCreatedMessage);
if (uncertain) warnMessageStorage();
}
private void discardCreatedMessage(Publication publication, SentReceipt receipt) {
if (!publication.persistent) return; // Append mode deliberately keeps its posts.
synchronized (publications) {
if (receipt.state != PENDING && receipt.state != WRITING) return;
receipt.state = DISCARDED; ownedReceipts.remove(receipt);
}
removeCreatedMessage(receipt);
}
/** Detach the administrator's old posts; clearing an ID never authorizes deleting those posts. */
public boolean clearTopVoterMessageId(TopVoter top) {
var data = plugin.getServerData();
synchronized (data) {
synchronized (publications) {
publications.remove(top);
synchronized (preparing) { preparing.remove(top); }
topVoterMessageIds.put(top, 0L);
ownedReceipts.removeIf(receipt -> {
if (receipt.top != top || (receipt.state != PENDING && receipt.state != RETAINED
&& receipt.state != WRITING)) return false;
receipt.state = DETACHED; return true;
});
}
try { if (data.setTopVoterMessageIdConfirmed(top, 0L)) return true; }
catch (RuntimeException failure) { /* The outgoing diagnostic remains redacted. */ }
}
warnMessageStorage(); return false;
}
/** Retire invalidated generations before worker cancellation; close invalidates every generation. */
public void retireLeaderboardPublications() {
var abandoned = new java.util.ArrayList<SentReceipt>();
var restart = new java.util.EnumMap<TopVoter, Long>(TopVoter.class);
synchronized (publications) {
// An admitted edit can still write on Discord after retirement. Keep its
// bounded slot until its network callback rather than submitting overlapping edits.
publications.entrySet().removeIf(entry -> {
Publication publication = entry.getValue();
if (currentGeneration(publication.generation) && !publication.retired) return false;
boolean again = publication.again && currentGeneration(publication.requestedGeneration);
if (!publication.editing) {
if (again) restart.put(entry.getKey(), publication.requestedGeneration);
return true;
}
publication.retired = true;
publication.again = again;
return false;
});
synchronized (preparing) {
preparing.entrySet().removeIf(entry -> {
DisplayUpdate update = entry.getValue();
if (currentGeneration(update.generation)) return false;
if (update.again && currentGeneration(update.requestedGeneration))
restart.put(entry.getKey(), update.requestedGeneration);
return true;
});
}
var iterator = ownedReceipts.iterator();
while (iterator.hasNext()) {
SentReceipt receipt = iterator.next();
if (receipt.state == PENDING && !currentGeneration(receipt.generation)) {
receipt.state = DISCARDED; iterator.remove(); abandoned.add(receipt);
}
}
}
abandoned.forEach(this::removeCreatedMessage);
// Old worker completions can be cancelled; transfer explicit requests now,
// outside both ownership monitors and without waiting for those callbacks.
restart.forEach(this::prepareTopVoterMessage);
}
public void close() {
synchronized (publications) {
if (!closed.compareAndSet(false, true)) return;
}
retireLeaderboardPublications();
boolean uncertain;
synchronized (publications) {
uncertain = ownedReceipts.stream().anyMatch(receipt -> receipt.state == WRITING || !receipt.confirmed);
}
if (uncertain) warnMessageStorage();
}
private void warnMessageStorage() {
plugin.getLogger().warning("Unable to confirm Discord Top Voters message ID storage. "
+ "The post remains tracked in memory; check storage before restarting and reconcile posts manually.");
}
/** JDA owns asynchronous deletion; cleanup does not depend on the retiring plugin worker. */
private void removeCreatedMessage(SentReceipt receipt) {
if (!receipt.cleanupRequested.compareAndSet(false, true)) return;
try {
receipt.message.delete().queue(ignored -> { }, failure -> {
if (!(failure instanceof ErrorResponseException response)
|| response.getErrorResponse() != ErrorResponse.UNKNOWN_MESSAGE) warnMessageCleanup();
});
} catch (RuntimeException failure) { warnMessageCleanup(); }
}
private void warnMessageCleanup() {
plugin.getLogger().warning("Unable to remove an untracked Discord Top Voters message. "
+ "Check Discord permissions and remove the unwanted post manually.");
}
TextChannel leaderboardChannel(long channelId) {
return DiscordUtil.getJda().getTextChannelById(channelId);
}
/** Render an inspected display snapshot without accessing the Discord runtime. */
static EmbedBuilder buildTopVoterEmbed(String title, LinkedHashMap<TopVoterPlayer, Integer> topVoters) {
EmbedBuilder eb = new EmbedBuilder().setTitle(title).setColor(Color.CYAN).setTimestamp(Instant.now());
if (topVoters == null || topVoters.isEmpty()) {
@@ -187,34 +556,7 @@ public class DiscordHandler {
eb.setDescription(sb.toString()).setFooter("Top 10 • Updated");
}
TextChannel channel = DiscordUtil.getJda().getTextChannelById(channelId);
if (channel == null) {
plugin.getLogger().warning("Discord channel ID " + channelId + " not found: " + top);
return;
}
long existingId;
synchronized (recoveringTopVoters) {
if (recoveringTopVoters.contains(top)) {
plugin.debug("Skipping Discord Top Voter update while recovery is in progress: " + top);
return;
}
existingId = topVoterMessageIds.getOrDefault(top, 0L);
}
if (existingId <= 0 || newMessage) {
channel.sendMessageEmbeds(eb.build()).queue(msg -> {
long newId = msg.getIdLong();
topVoterMessageIds.put(top, newId);
if (!newMessage)
plugin.getServerData().setTopVoterMessageId(top, newId);
plugin.debug("Posted new Top Voters " + top + " (ID: " + newId + ")");
}, err -> plugin.getLogger().warning("Error sending Top Voters " + top + ": " + err.getMessage()));
} else {
channel.editMessageEmbedsById(existingId, eb.build()).queue(
m -> plugin.debug("Edited Top Voters " + top + " (ID: " + existingId + ")"),
err -> handleMessageUpdateFailure(top, channel, eb, existingId, err));
}
return eb;
}
/**
@@ -224,41 +566,65 @@ public class DiscordHandler {
* condition as recoverable so one deleted message cannot permanently stop
* leaderboard updates.
*/
private void handleMessageUpdateFailure(TopVoter top, TextChannel channel, EmbedBuilder eb, long existingId,
Throwable error) {
if (plugin.getConfigFile().isDiscordSRVTopVoterAutoRecoverMessageOnFailure()
&& error instanceof ErrorResponseException
&& ((ErrorResponseException) error).getErrorResponse() == ErrorResponse.UNKNOWN_MESSAGE) {
synchronized (recoveringTopVoters) {
if (topVoterMessageIds.getOrDefault(top, 0L) != existingId || !recoveringTopVoters.add(top)) {
plugin.debug("Skipping stale Discord Top Voter recovery because another update handled: " + top);
return;
private boolean currentGeneration(long generation) {
return !closed.get() && plugin.isEnabled() && plugin.getTopVoterDisplayGeneration() == generation;
}
private void dispatchLeaderboard(long generation, Runnable action, Runnable discarded) {
if (!currentGeneration(generation)) { discarded.run(); return; }
try {
plugin.getTimer().execute(() -> {
try {
if (currentGeneration(generation)) action.run(); else discarded.run();
} catch (RuntimeException failure) {
try { if (currentGeneration(generation)) warnLeaderboardFailure("asynchronous publication"); }
finally { discarded.run(); }
}
}
plugin.getLogger().warning("Discord Top Voters " + top + " message " + existingId
+ " no longer exists; clearing the stored ID and posting a replacement.");
synchronized (recoveringTopVoters) {
topVoterMessageIds.put(top, 0L);
}
plugin.getServerData().setTopVoterMessageId(top, 0L);
channel.sendMessageEmbeds(eb.build()).queue(msg -> {
long newId = msg.getIdLong();
synchronized (recoveringTopVoters) {
topVoterMessageIds.put(top, newId);
plugin.getServerData().setTopVoterMessageId(top, newId);
recoveringTopVoters.remove(top);
}
plugin.debug("Recovered Top Voters " + top + " with replacement message (ID: " + newId + ")");
}, sendError -> {
synchronized (recoveringTopVoters) {
recoveringTopVoters.remove(top);
}
plugin.getLogger().warning("Error recovering Top Voters " + top + ": " + sendError.getMessage());
});
} catch (RuntimeException rejected) {
try { if (currentGeneration(generation)) warnLeaderboardFailure("worker admission"); }
finally { discarded.run(); }
}
}
private void handleMessageUpdateFailure(TopVoter top, TextChannel channel, Publication publication,
Throwable error) {
synchronized (publications) { publication.editing = false; }
if (!current(top, publication)) { finish(top, publication); return; }
if (!plugin.getConfigFile().isDiscordSRVTopVoterAutoRecoverMessageOnFailure()
|| !(error instanceof ErrorResponseException response)
|| response.getErrorResponse() != ErrorResponse.UNKNOWN_MESSAGE) {
failPublication(top, publication, "editing a message");
return;
}
plugin.getLogger().warning("Error editing Top Voters " + top + ": " + error.getMessage());
synchronized (publications) {
if (publications.get(top) != publication || publication.recovering) return;
publication.recovering = true;
publication.sending = true;
}
dispatchLeaderboard(publication.generation, () -> {
try {
if (!persist(top, publication, 0L)) { finish(top, publication); return; }
synchronized (publications) {
if (publications.get(top) != publication) return;
topVoterMessageIds.put(top, 0L);
}
// Persistence can block while a join, TTL, reload or disable revokes the earlier frame.
var replacement = buildTopVoterEmbed(plugin.getConfigFile().getDiscordSRVTopVoterTitle(top),
plugin.getTopVoterDisplay(top)).build();
send(top, channel, publication, replacement);
} catch (RuntimeException failure) { failPublication(top, publication, "recovering a deleted message"); }
}, () -> {
// Rejected admission has no effects; the same failed edit may be retried.
boolean again = false;
synchronized (publications) {
if (publications.get(top) == publication) {
publication.recovering = false; publication.sending = false;
again = publication.again;
}
}
if (again) finish(top, publication);
});
}
/**
@@ -13,6 +13,11 @@ import lombok.Getter;
import lombok.Setter;
public class PlayerPostVoteEvent extends Event {
/** Original accepted occurrence time; ordinary cooldown/event time remains separate. */
@Getter
@Setter
private Long canonicalOccurrenceTime;
/** The Constant handlers. */
private static final HandlerList handlers = new HandlerList();
@@ -77,6 +82,11 @@ public class PlayerPostVoteEvent extends Event {
@Setter
private UUID voteUUID;
/** Original explicit proxy transport ID; voteUUID retains the legacy correlation fallback. */
@Getter
@Setter
private UUID proxyVoteId;
/**
* Constructs a new PlayerPostVoteEvent.
*
@@ -44,11 +44,22 @@ public class PlayerReceivePointsEvent extends Event {
* @param user the voting plugin user
* @param points the points received
*/
public PlayerReceivePointsEvent(VotingPluginUser user, int points) {
super(true);
this.player = user;
this.points = points;
}
public PlayerReceivePointsEvent(VotingPluginUser user, int points) {
this(user, points, true);
}
/**
* Constructs an event with an explicit Bukkit dispatch context.
*
* @param user the voting plugin user
* @param points the points received
* @param async whether the event is being dispatched off the Bukkit/entity lane
*/
public PlayerReceivePointsEvent(VotingPluginUser user, int points, boolean async) {
super(async);
this.player = user;
this.points = points;
}
@Override
public HandlerList getHandlers() {
@@ -1,5 +1,8 @@
package com.bencodez.votingplugin.events;
package com.bencodez.votingplugin.events;
import java.util.UUID;
import org.bukkit.event.Cancellable;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;
@@ -10,7 +13,23 @@ import com.bencodez.votingplugin.votesites.VoteSite;
import lombok.Getter;
import lombok.Setter;
public class PlayerVoteEvent extends Event {
/**
* Fired before VotingPlugin processes a player vote.
*
* <p>Cancellation follows Bukkit's {@code ignoreCancelled} dispatch contract.
* Integrations must cancel before the MONITOR vote consumer runs.</p>
*/
public class PlayerVoteEvent extends Event implements Cancellable {
/** Stable local receipt identity, separate from proxy transport provenance. */
@Getter
@Setter
private UUID localOccurrenceId;
/** Original accepted occurrence time; ordinary cooldown/event time remains separate. */
@Getter
@Setter
private Long canonicalOccurrenceTime;
/** The Constant handlers. */
private static final HandlerList handlers = new HandlerList();
@@ -34,7 +53,12 @@ public class PlayerVoteEvent extends Event {
@Getter
@Setter
private VoteTotalsSnapshot bungeeTextTotals;
private VoteTotalsSnapshot bungeeTextTotals;
/** Original explicit proxy transport ID; null for legacy totals-only correlation. */
@Getter
@Setter
private UUID proxyVoteId;
@Getter
@Setter
@@ -68,9 +92,42 @@ public class PlayerVoteEvent extends Event {
@Setter
private VotingPluginUser votingPluginUser;
@Getter
@Setter
private boolean wasOnline;
@Getter
@Setter
private boolean wasOnline;
/** Native receipt ownership is released only after the accepted consumer returns. */
@Getter
@Setter
private boolean nativeProcessingStarted;
@Getter
@Setter
private boolean nativeProcessingCompleted;
/** Missing date accounting is surfaced after event dispatch so durable proxy envelopes are not acknowledged. */
@Getter
@Setter
private boolean dateMilestoneAccountingFailed;
/** Whether this is an identified queued proxy delivery. */
@Getter
@Setter
private boolean queuedProxyVote;
/** Whether the proxy explicitly classified this as a live or queued delivery. */
@Getter
@Setter
private boolean proxyQueueClassificationKnown;
/** Whether the proxy explicitly supplied its delay-validation decision. */
@Getter
@Setter
private boolean proxyDelayValidationKnown;
@Getter
@Setter
private boolean targetedProxyVote;
@Getter
@Setter
@@ -32,7 +32,17 @@ public class VoteShopPurchaseEvent extends Event {
public VoteShopPurchaseEvent(UUID playerUUID, String playerName, VotingPluginUser user, String identifier,
int cost) {
super(true);
this(playerUUID, playerName, user, identifier, cost, true);
}
/**
* Creates a purchase event with the dispatch lane made explicit. Internal
* entity-lane purchases are synchronous; the legacy constructor remains
* asynchronous for source and behavioral compatibility with external callers.
*/
public VoteShopPurchaseEvent(UUID playerUUID, String playerName, VotingPluginUser user, String identifier,
int cost, boolean async) {
super(async);
this.playerName = playerName;
this.user = user;
this.playerUuid = playerUUID;
@@ -0,0 +1,18 @@
package com.bencodez.votingplugin.listeners;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryOpenEvent;
import com.bencodez.advancedcore.api.inventory.GUISession;
import com.bencodez.votingplugin.commands.gui.player.DateVoteEventsMenu;
/** Fence the actual platform open, including AdvancedCore's additional scheduling handoff. */
public final class DateVoteMenuOpenGuard implements Listener {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onOpen(InventoryOpenEvent event) {
GUISession session = GUISession.extractSession(event.getInventory());
if (session != null && !DateVoteEventsMenu.permitsOpen(session.getInventoryGUI(), event.getPlayer()))
event.setCancelled(true);
}
}
@@ -1,4 +1,6 @@
package com.bencodez.votingplugin.listeners;
package com.bencodez.votingplugin.listeners;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
@@ -8,9 +10,11 @@ import org.bukkit.event.player.PlayerQuitEvent;
import com.bencodez.advancedcore.api.player.UuidLookup;
import com.bencodez.advancedcore.api.user.UserDataFetchMode;
import com.bencodez.advancedcore.listeners.AdvancedCoreLoginEvent;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.user.VotingPluginUser;
import com.bencodez.advancedcore.listeners.AdvancedCoreLoginEvent;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.placeholders.PlaceHolders;
import com.bencodez.votingplugin.user.VotingPluginUser;
import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
public class PlayerJoinEvent implements Listener {
@@ -26,18 +30,38 @@ public class PlayerJoinEvent implements Listener {
this.plugin = plugin;
}
private static boolean isBlank(String s) {
return s == null || s.trim().isEmpty() || "null".equalsIgnoreCase(s.trim());
}
/** Capture the entity owner before asynchronous user notifications can need it. */
@EventHandler(priority = EventPriority.LOWEST)
public void onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent event) {
if (event == null || event.getPlayer() == null) return;
Player player = event.getPlayer();
plugin.getPlaceholderPlayerPresence().captureOnline(placeholderUuid(player), player);
}
private UUID placeholderUuid(Player player) {
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(player);
return user == null || user.getJavaUUID() == null ? player.getUniqueId() : user.getJavaUUID();
}
private static boolean isBlank(String s) {
return s == null || s.trim().isEmpty() || "null".equalsIgnoreCase(s.trim());
}
private void clearPlaceholderCachesIfOffline(UUID uuid) {
PlaceHolders placeholders = plugin.getPlaceholders();
if (placeholders != null) {
plugin.getPlaceholderPlayerPresence().runIfOffline(uuid, () -> placeholders.onLogout(uuid));
}
}
/**
* On AdvancedCore login event (post-auth / delayed login).
*
* @param event the event
*/
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onPlayerLogin(AdvancedCoreLoginEvent event) {
if (event == null || !plugin.isMySQLOkay() || event.isCancelled() || event.getUser() == null) {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onPlayerLogin(AdvancedCoreLoginEvent event) {
if (event == null || !plugin.isMySQLOkay() || event.isCancelled() || event.getUser() == null) {
return;
}
@@ -57,25 +81,58 @@ public class PlayerJoinEvent implements Listener {
return;
}
Player player = event.getPlayer();
if (player != null && player.isOp() && plugin.isYmlError()) {
user.sendMessage("&cVotingPlugin: Detected yml error, please check console for details");
Player player = event.getPlayer();
if (player != null) {
if (!plugin.getPlaceholderPlayerPresence().updateStorageUuid(user.getJavaUUID(), player)) return;
}
if (player != null && plugin.isYmlError()) {
BukkitCompletionScheduler.run(plugin, player, () -> {
if (player.isOp()) {
user.sendMessage("&cVotingPlugin: Detected yml error, please check console for details");
}
}, () -> { }, () -> { });
}
// Proxy routing needs live presence even when offline reward replay fails.
plugin.getUserManager().getDataManager().getTimer().execute(() -> {
if (player != null && plugin.getPlaceholderPlayerPresence().schedulerOwner(user.getJavaUUID()) != player) {
return;
}
if (plugin.getBungeeSettings().isUseBungeecoord()) {
plugin.getBackendProxyHandler().playerOnline(user.getPlayerName(), user.getUUID());
}
});
Runnable afterOfflineVotes = () -> {
// Replay may finish after quit or a replacement login for the same storage UUID.
if (player != null && plugin.getPlaceholderPlayerPresence().schedulerOwner(user.getJavaUUID()) != player) {
return;
}
user.loginRewardsAsync().whenComplete((ignored, failure) -> {
if (failure != null) {
plugin.getLogger().warning("Login rewards failed for " + uuid + ": " + failure);
return;
}
try {
// Reward completion can run on a player owner; storage-backed placeholders cannot.
plugin.getUserManager().getDataManager().getTimer().execute(() -> {
if (player != null && plugin.getPlaceholderPlayerPresence().schedulerOwner(user.getJavaUUID()) != player) {
return;
}
plugin.getPlaceholders().onUpdate(user, true);
});
} catch (RuntimeException rejected) {
plugin.getLogger().warning("Login placeholder update rejected for " + uuid + ": " + rejected);
}
});
};
if (hasData) {
// give offline vote (if they voted offline)
user.offVote();
// Follow-up work requires confirmed durable replay, not task admission.
user.offVoteAndThen(player, afterOfflineVotes);
} else {
plugin.debug("No data detected for " + user.getUUID() + "/" + user.getPlayerName());
}
user.loginRewards();
plugin.getPlaceholders().onUpdate(user, true);
if (plugin.getBungeeSettings().isUseBungeecoord()) {
plugin.getBackendProxyHandler().playerOnline(user.getPlayerName(), user.getUUID());
// Preserve the no-data replay skip and keep dependent work off entity owners.
plugin.getUserManager().getDataManager().getTimer().execute(afterOfflineVotes);
}
}
@@ -93,23 +150,29 @@ public class PlayerJoinEvent implements Listener {
final Player player = event.getPlayer();
if (player == null) {
return;
}
}
UUID placeholderUuid = plugin.getPlaceholderPlayerPresence().storageUuid(player);
if (placeholderUuid == null) placeholderUuid = placeholderUuid(player);
boolean retiredPresence = plugin.getPlaceholderPlayerPresence().playerOffline(placeholderUuid, player);
if (retiredPresence) clearPlaceholderCachesIfOffline(placeholderUuid);
if (plugin.getBungeeSettings().isUseBungeecoord()) {
plugin.getBackendProxyHandler().playerOffline(player.getName());
}
final UUID serverUuid = player.getUniqueId();
final String playerName = player.getName();
plugin.getLoginTimer().execute(new Runnable() {
@Override
public void run() {
VotingPluginMain.plugin.getAdvancedTab().remove(player.getUniqueId());
VotingPluginMain.plugin.getAdvancedTab().remove(serverUuid);
// Use AdvancedCore UUIDLookup to derive the correct UUID (online/offline aware)
String uuid = UuidLookup.getInstance().getUUID(player.getName());
String uuid = UuidLookup.getInstance().getUUID(playerName);
if (isBlank(uuid)) {
// Fallback to online UUID if lookup fails
uuid = player.getUniqueId().toString();
uuid = serverUuid.toString();
}
VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid);
@@ -117,10 +180,10 @@ public class PlayerJoinEvent implements Listener {
return;
}
user.userDataFetechMode(UserDataFetchMode.NO_CACHE);
user.logoutRewards();
plugin.getPlaceholders().onLogout(user);
}
});
user.userDataFetechMode(UserDataFetchMode.NO_CACHE);
user.logoutRewards();
clearPlaceholderCachesIfOffline(user.getJavaUUID());
}
});
}
}
@@ -1,311 +1,224 @@
package com.bencodez.votingplugin.listeners;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import com.bencodez.advancedcore.api.bedrock.BedrockNameResolver;
import com.bencodez.advancedcore.api.user.validation.UserValidationResult;
import com.bencodez.simpleapi.array.ArrayUtils;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.events.PlayerPostVoteEvent;
import com.bencodez.votingplugin.events.PlayerVoteEvent;
import com.bencodez.votingplugin.topvoter.TopVoter;
import com.bencodez.votingplugin.user.VotingPluginUser;
import com.bencodez.votingplugin.votesites.VoteSite;
public class PlayerVoteListener implements Listener {
private VotingPluginMain plugin;
/**
* Instantiates a new votifer event.
*
* @param plugin the plugin
*/
public PlayerVoteListener(VotingPluginMain plugin) {
this.plugin = plugin;
}
/**
* On votifer event.
*
* @param event the event
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onplayerVote(PlayerVoteEvent event) {
if (!plugin.isEnabled()) {
plugin.getLogger().warning("Plugin disabled, ignoring vote");
return;
}
String playerName = event.getPlayer();
plugin.debug("Processing PlayerVoteEvent: " + playerName + "/" + event.getServiceSite());
// check for name casing
final String properName = plugin.getUserManager().getProperName(playerName);
// Validate BEFORE full bedrock/db-backed resolution
boolean allowUnJoinedCheckServer = plugin.getConfigFile().isAllowUnJoinedCheckServer();
UserValidationResult validationResult = plugin.getUserManager().getValidationService().validate(properName,
allowUnJoinedCheckServer);
plugin.extraDebug("Vote validation result for " + properName + ": valid=" + validationResult.isValid()
+ ", source=" + validationResult.getSource() + ", reason=" + validationResult.getReason()
+ ", normalizedName=" + validationResult.getNormalizedName() + ", bedrock="
+ validationResult.isBedrock());
if (!validationResult.isValid() && !plugin.getConfigFile().isAllowUnjoined()) {
plugin.getLogger().warning("Player " + properName + " has not joined before, disregarding vote. "
+ "Reason: " + validationResult.getReason() + ". Set AllowUnjoined to true to accept.");
if (event.isBungee() && plugin.getBungeeSettings().isRemoveInvalidUsers()) {
plugin.getVotingPluginUserManager().getVotingPluginUser(properName).remove();
}
return;
}
String creditedName;
String resolutionRationale;
if (validationResult.isValid()) {
// Validation checks exact online and stored identities before attempting a
// prefixed Bedrock fallback. Keep that decision authoritative so a second
// resolver pass cannot redirect an offline Java vote to an online Bedrock
// account with the same base name.
creditedName = validationResult.getNormalizedName();
if (creditedName == null || creditedName.isEmpty()) {
creditedName = properName;
}
resolutionRationale = "validation-"
+ validationResult.getSource().toString().toLowerCase(java.util.Locale.ROOT);
} else {
// Invalid users only reach this point when AllowUnjoined is enabled. Preserve
// the existing resolver behavior for those otherwise unknown identities.
BedrockNameResolver.Result rn = plugin.getBedrockHandle().resolve(properName);
creditedName = rn.finalName;
resolutionRationale = rn.rationale;
}
package com.bencodez.votingplugin.listeners;
plugin.debug("Vote name resolved: " + properName + " -> " + creditedName + " (" + resolutionRationale + ")");
// If we get here, use the resolved/possibly-prefixed name going forward
playerName = creditedName;
if (playerName.isEmpty()) {
plugin.getLogger().warning("Empty player name from vote, ignoring");
return;
}
if (event.isBungee()) {
plugin.debug("BungeePlayerVote forcebungee: " + event.isForceBungee() + ", bungeetotals: "
+ event.getBungeeTextTotals());
if (plugin.getBungeeSettings().isTriggerVotifierEvent()) {
try {
new ProxyVotifierEvent().send(plugin, event);
} catch (NoClassDefFoundError ex) {
plugin.getLogger().severe(
"Failed to trigger Votifier event for proxy vote. Either install Votifier or disable triggering the Votifier event");
plugin.getLogger().severe("Error: " + ex.getMessage());
plugin.debug(ex);
}
}
}
VoteSite voteSite = event.getVoteSite();
if (voteSite == null) {
voteSite = plugin.getVoteSiteManager()
.getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, event.getServiceSite()), true);
}
// check valid service sites
if (voteSite == null) {
if (!plugin.getConfigFile().isDisableNoServiceSiteMessage()) {
plugin.getLogger().warning("No voting site with the service site: '" + event.getServiceSite() + "'");
ArrayList<String> services = new ArrayList<>();
for (VoteSite site : plugin.getVoteSiteManager().getVoteSites()) {
services.add(site.getServiceSite());
}
plugin.getLogger().warning("Currently set service sites: " + ArrayUtils.makeStringList(services));
}
return;
}
if (!voteSite.isEnabled()) {
plugin.debug("Votesite: " + voteSite.getKey() + " is not enabled");
return;
}
VotingPluginUser user = null;
if (event.getVotingPluginUser() != null) {
user = event.getVotingPluginUser();
} else {
Player p = Bukkit.getPlayerExact(playerName);
if (p != null) {
// player online
user = plugin.getVotingPluginUserManager().getVotingPluginUser(p);
} else {
// attempt to get exact name from cache matching casing
user = plugin.getVotingPluginUserManager().getVotingPluginUser(playerName);
}
}
boolean queuedProxyVoteAlreadyRecorded = ProxyVoteDelayCheck.isQueuedVoteAlreadyRecorded(event.isBungee(),
event.getTime(), user.getTime(voteSite));
if (voteSite.isWaitUntilVoteDelay() && !queuedProxyVoteAlreadyRecorded && !user.canVoteSite(voteSite)) {
if (!event.isRealVote()) {
plugin.getLogger().info(user.getPlayerName() + " did a not real vote, bypassing WaitUntilVoteDelay");
} else {
if (!user.hasPermission("VotingPlugin.BypassWaitUntilVoteDelay")) {
plugin.getLogger().info(user.getPlayerName() + " must wait until votedelay is over, ignoring vote");
boolean online = user.isOnline();
if (event.isBungee()) {
online = event.isWasOnline();
}
if (plugin.getOptions().isProcessRewards()) {
voteSite.giveWaitUntilVoteDelayRewards(user, online, event.isBungee());
}
return;
}
plugin.getLogger()
.info(user.getPlayerName() + " has bypass permission for WaitUntilVoteDelay, bypassing");
}
}
if (queuedProxyVoteAlreadyRecorded) {
plugin.debug("Allowing queued proxy vote for " + user.getPlayerName() + " on " + voteSite.getKey()
+ "; proxy vote time already matches LastVotes: " + event.getTime());
}
UUID voteUUID = UUID.randomUUID();
if (event.isBungee() && event.getBungeeTextTotals() != null) {
voteUUID = event.getBungeeTextTotals().getVoteUUID();
if (voteUUID == null) {
voteUUID = UUID.randomUUID();
}
}
final String uuid = user.getUUID();
// reupdate cache
user.cache();
user.updateName(true);
// vote party
plugin.getVoteParty().vote(user, event.isRealVote(), event.isForceBungee());
if (event.isBroadcast()) {
if (plugin.getBroadcastHandler() != null) {
boolean online = user.isOnline();
if (event.isBungee()) {
online = event.isWasOnline();
}
if (!user.isVanished()) {
plugin.getBroadcastHandler().broadcastVote(user.getJavaUUID(), playerName,
voteSite.getDisplayName(), online);
} else {
plugin.debug("Not broadcasting vote for vanished user: " + user.getPlayerName());
}
}
}
long voteTime = 0;
// update last vote time
if (event.getTime() != 0) {
user.setTime(voteSite, event.getTime());
voteTime = event.getTime();
} else {
user.setTime(voteSite);
voteTime = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
}
boolean cached = false;
// check if player has voted on all sites in one day
if (((user.isOnline() || voteSite.isGiveOffline()) && plugin.getOptions().isProcessRewards())
|| event.isBungee()) {
boolean online = true;
if (event.isBungee()) {
online = event.isWasOnline();
}
user.playerVote(voteSite, online, event.isForceBungee());
if (event.getVoteNumber() == 1) {
user.sendVoteEffects(online);
}
if (plugin.getConfigFile().isCloseInventoryOnVote()) {
user.closeInv();
}
} else {
if (!plugin.getConfigFile().isOfflineVotesLimitEnabled()
|| user.getNumberOfOfflineVotes(voteSite) <= plugin.getConfigFile().getOfflineVotesLimitAmount()) {
user.addOfflineVote(voteSite.getKey());
cached = true;
plugin.debug(
"Offline vote set for " + playerName + " (" + user.getUUID() + ") on " + voteSite.getKey());
} else {
plugin.debug("Not setting offline vote, offline vote limit reached");
}
}
// add to total votes
if ((plugin.getConfigFile().isCountFakeVotes() || event.isRealVote()) && event.isAddTotals()) {
if (plugin.getConfigFile().isAddTotals()) {
if (plugin.getConfigFile().isAddTotalsOffline() || user.isOnline()) {
user.addTotal();
user.addTotalDaily();
user.addTotalWeekly();
}
}
user.addPoints();
}
user.checkDayVoteStreak(event.isForceBungee());
if (plugin.getConfigFile().isLimitMonthlyVotes()) {
int value = 0;
if (event.isBungee()) {
value = event.getBungeeTextTotals().getMonthTotal();
} else {
value = user.getTotal(TopVoter.Monthly);
}
LocalDateTime cTime = plugin.getTimeChecker().getTime();
int days = cTime.getDayOfMonth();
plugin.extraDebug("Current day of month: " + days + " Current total: " + value);
if (value >= days * plugin.getVoteSiteManager().getVoteSitesEnabled().size()) {
plugin.debug("Detected higher month total, changing. Current Total: " + value + " Days: " + days
+ " New Total: " + days * plugin.getVoteSiteManager().getVoteSitesEnabled().size());
user.setTotal(TopVoter.Monthly, days * plugin.getVoteSiteManager().getVoteSitesEnabled().size());
}
}
plugin.getVoteMilestonesManager().handleVote(user, event.getBungeeTextTotals(), event.isForceBungee(), voteUUID,
new HashMap<String, String>());
plugin.getCoolDownCheck().vote(user, voteSite);
plugin.getVoteStreakHandler().processVote(user, voteTime, voteUUID);
PlayerPostVoteEvent postVoteEvent = new PlayerPostVoteEvent(voteSite, user, event.isRealVote(),
event.isForceBungee(), voteTime, cached, voteSite.getServiceSite(), user.getJavaUUID(), playerName,
voteUUID);
plugin.getServer().getPluginManager().callEvent(postVoteEvent);
if (user.isOnline() || plugin.getConfigFile().getPlaceholderCacheLevel().isCacheAlways()) {
plugin.getPlaceholders().onUpdate(user, true);
}
if (!user.isOnline()) {
user.clearCache();
}
plugin.setUpdate(true);
plugin.extraDebug("Finished vote processing: " + playerName + "/" + uuid);
}
import java.util.ArrayList;
import java.util.HashMap;
import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import com.bencodez.advancedcore.api.bedrock.BedrockNameResolver;
import com.bencodez.advancedcore.api.user.validation.UserValidationResult;
import com.bencodez.simpleapi.array.ArrayUtils;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.core.vote.SharedVotePolicy;
import com.bencodez.votingplugin.core.vote.SharedVoteProcessor;
import com.bencodez.votingplugin.events.PlayerPostVoteEvent;
import com.bencodez.votingplugin.events.PlayerVoteEvent;
import com.bencodez.votingplugin.topvoter.TopVoter;
import com.bencodez.votingplugin.user.VotingPluginUser;
import com.bencodez.votingplugin.votesites.VoteSite;
/** Bukkit event entry point and platform operations for the shared vote sequence. */
public class PlayerVoteListener implements Listener {
private final VotingPluginMain plugin;
public PlayerVoteListener(VotingPluginMain plugin) {
this.plugin = plugin;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onplayerVote(PlayerVoteEvent event) {
// Keep the production boundary defensive for direct/custom dispatchers
// as well as Bukkit's Cancellable-aware event dispatcher.
if (event.isCancelled()) return;
BukkitOperations operations = new BukkitOperations(plugin, event);
SharedVoteProcessor.process(operations);
if (event.isNativeProcessingStarted()) event.setNativeProcessingCompleted(true);
}
private static final class BukkitOperations implements SharedVoteProcessor.Operations<VoteSite, VotingPluginUser> {
private final VotingPluginMain plugin;
private final PlayerVoteEvent event;
private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) {
this.plugin = plugin;
this.event = event;
}
@Override public boolean enabled() {
boolean enabled = plugin.isEnabled();
if (enabled) event.setNativeProcessingStarted(true);
return enabled;
}
@Override public String incomingName() { return event.getPlayer(); }
@Override public String serviceSite() { return event.getServiceSite(); }
@Override public void debug(String message) { plugin.debug(message); }
@Override public void extraDebug(String message) { plugin.extraDebug(message); }
@Override public void info(String message) { plugin.getLogger().info(message); }
@Override public void warning(String message) { plugin.getLogger().warning(message); }
@Override public String properName(String name) { return plugin.getUserManager().getProperName(name); }
@Override public boolean allowUnJoinedCheckServer() { return plugin.getConfigFile().isAllowUnJoinedCheckServer(); }
@Override public SharedVoteProcessor.Validation validate(String name, boolean allowUnJoinedCheckServer) {
UserValidationResult result = plugin.getUserManager().getValidationService().validate(name, allowUnJoinedCheckServer);
return new SharedVoteProcessor.Validation(result.isValid(), result.getNormalizedName(),
result.getSource() == null ? null : result.getSource().toString(),
String.valueOf(result.getReason()), result.isBedrock());
}
@Override public boolean allowUnjoined() { return plugin.getConfigFile().isAllowUnjoined(); }
@Override public boolean removeInvalidProxyUsers() { return plugin.getBungeeSettings().isRemoveInvalidUsers(); }
@Override public void removeInvalidUser(String name) { plugin.getVotingPluginUserManager().getVotingPluginUser(name).remove(); }
@Override public SharedVoteProcessor.Name resolveUnknownName(String name) {
BedrockNameResolver.Result resolved = plugin.getBedrockHandle().resolve(name);
return new SharedVoteProcessor.Name(resolved.finalName, resolved.rationale);
}
@Override public void triggerProxyEvent() {
plugin.debug("BungeePlayerVote forcebungee: " + event.isForceBungee() + ", bungeetotals: "
+ event.getBungeeTextTotals());
if (plugin.getBungeeSettings().isTriggerVotifierEvent()) {
try {
new ProxyVotifierEvent().send(plugin, event);
} catch (NoClassDefFoundError ex) {
plugin.getLogger().severe("Failed to trigger Votifier event for proxy vote. Either install Votifier or disable triggering the Votifier event");
plugin.getLogger().severe("Error: " + ex.getMessage());
plugin.debug(ex);
}
}
}
@Override public VoteSite resolveSite() {
VoteSite site = event.getVoteSite();
if (site == null) site = plugin.getVoteSiteManager()
.getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, event.getServiceSite()), true);
return site;
}
@Override public void reportMissingSite() {
if (!plugin.getConfigFile().isDisableNoServiceSiteMessage()) {
plugin.getLogger().warning("No voting site with the service site: '" + event.getServiceSite() + "'");
ArrayList<String> services = new ArrayList<>();
for (VoteSite site : plugin.getVoteSiteManager().getVoteSites()) services.add(site.getServiceSite());
plugin.getLogger().warning("Currently set service sites: " + ArrayUtils.makeStringList(services));
}
}
@Override public boolean siteEnabled(VoteSite site) { return site.isEnabled(); }
@Override public String siteKey(VoteSite site) { return site.getKey(); }
@Override public String siteDisplayName(VoteSite site) { return site.getDisplayName(); }
@Override public VotingPluginUser resolveUser(String name) {
if (event.getVotingPluginUser() != null) return event.getVotingPluginUser();
Player player = Bukkit.getPlayerExact(name);
if (player != null) return plugin.getVotingPluginUserManager().getVotingPluginUser(player);
return plugin.getVotingPluginUserManager().getVotingPluginUser(name);
}
@Override public String userName(VotingPluginUser user) { return user.getPlayerName(); }
@Override public String userId(VotingPluginUser user) { return user.getUUID(); }
@Override public UUID userUuid(VotingPluginUser user) { return user.getJavaUUID(); }
@Override public boolean userOnline(VotingPluginUser user) { return user.isOnline(); }
@Override public boolean userVanished(VotingPluginUser user) { return user.isVanished(); }
@Override public long lastVoteTime(VotingPluginUser user, VoteSite site) { return user.getTime(site); }
@Override public boolean waitUntilVoteDelay(VoteSite site) { return site.isWaitUntilVoteDelay(); }
@Override public boolean canVoteSite(VotingPluginUser user, VoteSite site) { return user.canVoteSite(site); }
@Override public boolean bypassWaitPermission(VotingPluginUser user) { return user.hasPermission("VotingPlugin.BypassWaitUntilVoteDelay"); }
@Override public boolean processRewards() { return plugin.getOptions().isProcessRewards(); }
@Override public void giveWaitRewards(VoteSite site, VotingPluginUser user, boolean online, boolean proxyVote) {
site.giveWaitUntilVoteDelayRewards(user, online, proxyVote);
}
@Override public boolean proxyVote() { return event.isBungee(); }
@Override public boolean targetedProxyVote() { return event.isTargetedProxyVote(); }
@Override public boolean forceProxyRouting() { return event.isForceBungee(); }
@Override public boolean wasOnline() { return event.isWasOnline(); }
@Override public boolean realVote() { return event.isRealVote(); }
@Override public boolean addTotals() { return event.isAddTotals(); }
@Override public boolean broadcastEnabled() { return event.isBroadcast(); }
@Override public boolean hasBroadcastHandler() { return plugin.getBroadcastHandler() != null; }
@Override public void broadcast(UUID uuid, String name, String siteDisplayName, boolean online) {
plugin.getBroadcastHandler().broadcastVote(uuid, name, siteDisplayName, online);
}
@Override public boolean hasProxyTextTotals() { return event.getBungeeTextTotals() != null; }
@Override public UUID proxyVoteId() { return resolveProxyVoteId(event); }
@Override public UUID localOccurrenceId() { return event.getLocalOccurrenceId(); }
@Override public boolean identifiedQueuedProxyVote() { return event.isQueuedProxyVote(); }
@Override public boolean proxyQueueClassificationKnown() { return event.isProxyQueueClassificationKnown(); }
@Override public boolean proxyDelayValidationKnown() { return event.isProxyDelayValidationKnown(); }
@Override public void cache(VotingPluginUser user) { user.cache(); }
@Override public void updateName(VotingPluginUser user) { user.updateName(true); }
@Override public void voteParty(VotingPluginUser user, boolean realVote, boolean forceProxyRouting,
boolean onlineAtVoteTime) {
plugin.getVoteParty().vote(user, realVote, forceProxyRouting, onlineAtVoteTime);
}
@Override public long incomingTime() { return event.getTime(); }
@Override public void setTime(VotingPluginUser user, VoteSite site, long time) { user.setTime(site, time); }
@Override public void setTimeNow(VotingPluginUser user, VoteSite site) { user.setTime(site); }
@Override public boolean giveOfflineRewards(VoteSite site) { return site.isGiveOffline(); }
@Override public void playerVote(VotingPluginUser user, VoteSite site, boolean online, boolean forceProxyRouting) {
user.playerVote(site, online, forceProxyRouting);
}
@Override public int voteNumber() { return event.getVoteNumber(); }
@Override public void sendVoteEffects(VotingPluginUser user, boolean online) { user.sendVoteEffects(online); }
@Override public boolean closeInventoryOnVote() { return plugin.getConfigFile().isCloseInventoryOnVote(); }
@Override public void closeInventory(VotingPluginUser user) { user.closeInv(); }
@Override public boolean offlineVotesLimitEnabled() { return plugin.getConfigFile().isOfflineVotesLimitEnabled(); }
@Override public int offlineVotes(VotingPluginUser user, VoteSite site) { return user.getNumberOfOfflineVotes(site); }
@Override public int offlineVotesLimitAmount() { return plugin.getConfigFile().getOfflineVotesLimitAmount(); }
@Override public void addOfflineVote(VotingPluginUser user, String siteKey) { user.addOfflineVote(siteKey); }
@Override public SharedVotePolicy countingPolicy() {
return new SharedVotePolicy(plugin.getConfigFile().isCountFakeVotes(), plugin.getConfigFile().isAddTotals(),
plugin.getConfigFile().isAddTotalsOffline(), false, false);
}
@Override public void addTotal(VotingPluginUser user) { user.addTotal(); }
@Override public void addTotalDaily(VotingPluginUser user) { user.addTotalDaily(); }
@Override public void addTotalWeekly(VotingPluginUser user) { user.addTotalWeekly(); }
@Override public void addPoints(VotingPluginUser user) { user.addPoints(); }
@Override public void checkDayVoteStreak(VotingPluginUser user, boolean forceProxyRouting) {
user.checkDayVoteStreak(forceProxyRouting);
}
@Override public boolean limitMonthlyVotes() { return plugin.getConfigFile().isLimitMonthlyVotes(); }
@Override public int proxyMonthTotal() { return event.getBungeeTextTotals().getMonthTotal(); }
@Override public int userMonthTotal(VotingPluginUser user) { return user.getTotal(TopVoter.Monthly); }
@Override public int currentDayOfMonth() { return plugin.getTimeChecker().getTime().getDayOfMonth(); }
@Override public int enabledSiteCount() { return plugin.getVoteSiteManager().getVoteSitesEnabled().size(); }
@Override public void setMonthTotal(VotingPluginUser user, int total) { user.setTotal(TopVoter.Monthly, total); }
@Override public void milestones(VotingPluginUser user, UUID voteId, boolean forceProxyRouting) {
plugin.getVoteMilestonesManager().handleVote(user, event.getBungeeTextTotals(), forceProxyRouting,
voteId, new HashMap<String, String>());
}
@Override public void dateMilestones(VotingPluginUser user, VoteSite site, long voteTime, UUID voteId) {
long occurrenceTime = event.getCanonicalOccurrenceTime() == null
? proxyVote() ? incomingTime() : voteTime : event.getCanonicalOccurrenceTime();
if (com.bencodez.votingplugin.util.BukkitCompletionScheduler.isPlatformOwnedThread()) {
// An asynchronous event flag does not identify the thread that dispatched it.
// A proxy caller cannot acknowledge accounting that has only been admitted.
event.setDateMilestoneAccountingFailed(!plugin.getDateVoteMilestones().deferAccepted(user, site.getKey(), voteId, occurrenceTime,
realVote(), proxyVote(), event.getProxyVoteId() != null && occurrenceTime > 0,
targetedProxyVote(), forceProxyRouting()));
return;
}
if (!plugin.getDateVoteMilestones().accepted(user, site.getKey(), voteId, occurrenceTime, realVote(), proxyVote(),
event.getProxyVoteId() != null && occurrenceTime > 0, targetedProxyVote(), forceProxyRouting())) event.setDateMilestoneAccountingFailed(true);
}
@Override public void cooldown(VotingPluginUser user, VoteSite site) { plugin.getCoolDownCheck().vote(user, site); }
@Override public void voteStreak(VotingPluginUser user, long voteTime, UUID voteId) {
plugin.getVoteStreakHandler().processVote(user, voteTime, voteId);
}
@Override public void postVote(VoteSite site, VotingPluginUser user, String playerName,
long voteTime, UUID voteId, boolean cached) {
PlayerPostVoteEvent post = new PlayerPostVoteEvent(site, user, event.isRealVote(), event.isForceBungee(),
voteTime, cached, site.getServiceSite(), user.getJavaUUID(), playerName, voteId);
post.setProxyVoteId(event.getProxyVoteId());
post.setCanonicalOccurrenceTime(event.getCanonicalOccurrenceTime() == null
? event.isBungee() ? event.getTime() : voteTime : event.getCanonicalOccurrenceTime());
plugin.getServer().getPluginManager().callEvent(post);
}
@Override public boolean placeholderCacheAlways() { return plugin.getConfigFile().getPlaceholderCacheLevel().isCacheAlways(); }
@Override public void updatePlaceholders(VotingPluginUser user) { plugin.getPlaceholders().onUpdate(user, true); }
@Override public void clearCache(VotingPluginUser user) { user.clearCache(); }
@Override public void setUpdate() { plugin.setUpdate(true); }
}
@SuppressWarnings("deprecation")
static UUID resolveProxyVoteId(PlayerVoteEvent event) {
if (event.getProxyVoteId() != null) return event.getProxyVoteId();
return event.getBungeeTextTotals() == null ? null : event.getBungeeTextTotals().getVoteUUID();
}
}
@@ -1,24 +1,24 @@
package com.bencodez.votingplugin.listeners;
import com.bencodez.votingplugin.core.vote.SharedVoteProcessor;
/**
* Handles the WaitUntilVoteDelay exception for votes queued by a proxy.
* Legacy proxy vote-delay helper retained for integrations compiled against the
* previous public entry point.
*
* @deprecated production processing uses stable vote IDs
*/
@Deprecated
public final class ProxyVoteDelayCheck {
private ProxyVoteDelayCheck() {
}
/**
* A proxy stores the real vote timestamp before its queued message reaches the
* backend. The backend must accept that one delivery when the stored timestamp
* is the same timestamp carried by the message.
*
* @param proxyVote true when the vote was forwarded by VotingPlugin's proxy
* @param messageVoteTime real vote time included in the proxy message
* @param storedVoteTime current backend last-vote time for the site
* @return true when the delay check should allow the queued delivery
* @deprecated timestamps are not stable occurrence identities
*/
@Deprecated
public static boolean isQueuedVoteAlreadyRecorded(boolean proxyVote, long messageVoteTime, long storedVoteTime) {
return proxyVote && messageVoteTime > 0L && messageVoteTime == storedVoteTime;
return SharedVoteProcessor.isQueuedVoteAlreadyRecorded(proxyVote, messageVoteTime, storedVoteTime);
}
}
@@ -1,133 +1,338 @@
package com.bencodez.votingplugin.listeners;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import com.bencodez.simpleapi.array.ArrayUtils;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.events.PlayerVoteEvent;
import com.bencodez.votingplugin.proxy.BungeeMethod;
import com.bencodez.votingplugin.util.MinecraftUsernameValidator;
import com.bencodez.votingplugin.util.ServiceSiteValidator;
import com.vexsoftware.votifier.model.Vote;
import com.vexsoftware.votifier.model.VotifierEvent;
public class VotiferEvent implements Listener {
private VotingPluginMain plugin;
/**
* Instantiates a new votifer event.
*
* @param plugin the plugin
*/
public VotiferEvent(VotingPluginMain plugin) {
this.plugin = plugin;
}
/**
* On votifer event.
*
* @param event the event
*/
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
public void onVotiferEvent(VotifierEvent event) {
Vote vote = event.getVote();
String str = vote.getServiceName();
if (str == null || str.isEmpty()) {
str = "Empty";
}
final String voteSite = str;
final String IP = vote.getAddress();
final String voteUsername = vote.getUsername();
if (IP.equals("VotingPlugin")) {
return;
}
if (!ServiceSiteValidator.isValid(voteSite)) {
plugin.getLogger().warning("Rejected vote with invalid service site '"
+ ServiceSiteValidator.sanitizeForLog(voteSite) + "'");
return;
}
if (!MinecraftUsernameValidator.isValid(voteUsername, plugin.getOptions().getBedrockPlayerPrefix())) {
plugin.getLogger().warning("Rejected vote with invalid Minecraft username '"
+ MinecraftUsernameValidator.sanitizeForLog(voteUsername) + "' from service '"
+ MinecraftUsernameValidator.sanitizeForLog(voteSite) + "'");
return;
}
plugin.getLogger()
.info("Received a vote from service site '" + voteSite + "' by player '" + voteUsername + "'!");
plugin.debug("PlayerUsername: " + voteUsername);
plugin.debug("VoteSite: " + voteSite);
plugin.debug("IP: " + IP);
plugin.getVoteTimer().submit(new Runnable() {
@Override
public void run() {
try {
plugin.getServerData().addServiceSite(voteSite);
if (plugin.getBungeeSettings().isUseBungeecoord() && !plugin.getBungeeSettings().isVotifierBypass()
&& (plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.PLUGINMESSAGING)
|| plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.SOCKETS)
|| plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.MQTT)
|| plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.REDIS))) {
plugin.getLogger().severe(
"Ignoring vote from votifier since pluginmessaging, socket, redis, or mqtt bungee method is enabled, this means you aren't setup correctly for those methods, please check: https://github.com/BenCodez/VotingPlugin/wiki/Bungeecord-Setups");
return;
}
String matchSite = "";
if (plugin.getConfigFile().isAdvancedServiceSiteHandling()) {
if (plugin.getServiceSiteHandler() != null) {
matchSite = plugin.getServiceSiteHandler().matchReverse(voteSite);
}
}
String voteSiteNameStr = plugin.getVoteSiteManager().getVoteSiteName(false, voteSite, matchSite);
boolean createSite = !plugin.getVoteSiteManager().hasVoteSite(voteSiteNameStr)
&& !plugin.getVoteSiteManager().hasConfiguredVoteSite(voteSiteNameStr);
String serviceSite = voteSite;
if (plugin.getConfigFile().isAutoCreateVoteSites() && createSite) {
plugin.getLogger().warning("VoteSite with service site '" + voteSiteNameStr
+ "' does not exist, attempting to generate...");
if (plugin.getConfigVoteSites().tryAutoGenerateVoteSite(voteSiteNameStr)) {
plugin.getLogger().info("Current known service sites: "
+ ArrayUtils.makeStringList(plugin.getServerData().getServiceSites()));
} else {
plugin.getLogger().warning("Unable to generate VoteSite for service site '"
+ ServiceSiteValidator.sanitizeForLog(voteSiteNameStr) + "'");
}
}
if (plugin.getTimeChecker().isActiveProcessing()
&& plugin.getConfigFile().isQueueVotesDuringTimeChange()) {
plugin.debug("Adding vote to time queue " + voteUsername + "/" + voteSite);
plugin.getTimeQueueHandler().addVote(voteUsername, voteSite);
return;
}
String voteSiteName = plugin.getVoteSiteManager().getVoteSiteName(true, serviceSite, matchSite);
PlayerVoteEvent voteEvent = new PlayerVoteEvent(
plugin.getVoteSiteManager().getVoteSite(voteSiteName, true), voteUsername, voteSite, true);
plugin.getServer().getPluginManager().callEvent(voteEvent);
if (voteEvent.isCancelled()) {
plugin.debug("Vote cancelled");
return;
}
} catch (Exception e) {
plugin.getLogger().severe("Error occured during vote processing");
e.printStackTrace();
}
}
});
}
}
package com.bencodez.votingplugin.listeners;
import java.util.concurrent.RejectedExecutionException;
import java.util.UUID;
import java.util.LinkedHashMap;
import java.util.Map;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import com.bencodez.simpleapi.array.ArrayUtils;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.events.PlayerVoteEvent;
import com.bencodez.votingplugin.proxy.BungeeMethod;
import com.bencodez.votingplugin.util.MinecraftUsernameValidator;
import com.bencodez.votingplugin.util.ServiceSiteValidator;
import com.vexsoftware.votifier.model.Vote;
import com.vexsoftware.votifier.model.VotifierEvent;
public class VotiferEvent implements Listener {
private final VotingPluginMain plugin;
private final java.util.function.BooleanSupplier ready;
private volatile boolean accepting = true;
private static final int MAX_CAPTURED = VotifierVoteOverflowQueue.MAX_CAPTURED;
private boolean retirementQueued;
private final Object captureLock = new Object();
// Receipt ownership only. Entries leave at completed processing or confirmed
// overflow transfer; STARTED work remains owned until then.
private final Map<UUID, CapturedVote> captured = new LinkedHashMap<>();
private static final class CapturedVote {
final String username, site;
final long occurredAt;
final UUID id;
boolean transferring, transferFailureReported;
volatile boolean started, completed, finished, transferred;
VotifierVoteOverflowQueue recoveryOwner;
CapturedVote(String username, String site, long occurredAt, UUID id) {
this.username = username; this.site = site; this.occurredAt = occurredAt; this.id = id;
}
}
/**
* Instantiates a new votifer event.
*
* @param plugin the plugin
*/
public VotiferEvent(VotingPluginMain plugin) { this(plugin, () -> true); }
public VotiferEvent(VotingPluginMain plugin, java.util.function.BooleanSupplier ready) {
this.plugin = plugin; this.ready = ready;
}
/** Retire admission and reconcile tasks before the vote executor is cancelled. */
public void stop() {
synchronized (captureLock) {
accepting = false;
if (retirementQueued) return;
retirementQueued = true;
var overflow = plugin.getVotifierVoteOverflowQueue();
if (overflow == null) {
for (CapturedVote vote : java.util.List.copyOf(captured.values())) transferLocked(vote);
return;
}
var snapshot = captured.values().stream().map(vote -> new VotifierVoteOverflowQueue.RetiringCapture(
vote.username, vote.site, vote.occurredAt, vote.id,
() -> !vote.started || vote.finished && !vote.completed, () -> vote.completed,
admitted -> transferred(vote, admitted))).toList();
overflow.retireCaptures(snapshot);
}
}
public int getPendingCaptureCount() {
synchronized (captureLock) { return captured.size(); }
}
private void runCaptured(CapturedVote vote) {
synchronized (captureLock) {
if (!captured.containsKey(vote.id) || vote.transferring || vote.started) return;
if (!accepting || !ready.getAsBoolean()) {
if (accepting || !retirementQueued) transferLocked(vote);
return;
}
vote.started = true;
}
boolean completed = false;
try { completed = processCapturedVote(vote.site, vote.username, vote.occurredAt, vote.id)
&& !Thread.currentThread().isInterrupted(); }
finally {
VotifierVoteOverflowQueue acknowledge = null;
synchronized (captureLock) {
vote.completed = completed;
vote.finished = true;
if (completed) {
captured.remove(vote.id);
if (vote.transferred) acknowledge = vote.recoveryOwner;
} else if (accepting && !vote.transferred && !vote.transferring) {
// Retain the same occurrence and hand it to recovery after
// the normal callback has finished, including partial failures.
transferLocked(vote);
}
}
if (completed && vote.recoveryOwner != null) vote.recoveryOwner.releaseCapture(vote.id);
if (acknowledge != null) acknowledge.acknowledgeOccurrence(vote.id);
else if (vote.transferred && vote.recoveryOwner != null) vote.recoveryOwner.resumeTransfers();
}
}
private void transferLocked(CapturedVote vote) {
if (vote.transferring) return;
vote.transferring = true;
VotifierVoteOverflowQueue overflow = vote.recoveryOwner != null
? vote.recoveryOwner : plugin.getVotifierVoteOverflowQueue();
vote.recoveryOwner = overflow;
if (overflow == null) { transferred(vote, false); return; }
overflow.enqueueAfterInitialization(vote.username, vote.site, vote.occurredAt, vote.id,
() -> !vote.started || vote.finished && !vote.completed,
accepted -> transferred(vote, accepted));
}
private void transferred(CapturedVote vote, boolean admitted) {
boolean completed, retry, reportFailure;
synchronized (captureLock) {
if (vote.transferred && !admitted) return; // Older failure cannot revoke confirmed queue ownership.
vote.transferred = admitted; completed = vote.completed;
if (!admitted) vote.transferring = false;
if (admitted || completed) captured.remove(vote.id);
retry = !admitted && !completed && captured.get(vote.id) == vote;
reportFailure = !admitted && !completed && !vote.transferFailureReported;
if (reportFailure) vote.transferFailureReported = true;
}
if (admitted && completed) vote.recoveryOwner.acknowledgeOccurrence(vote.id);
if (completed) return; // Current normal processing won; do not leave an ordinary duplicate retry.
if (reportFailure) plugin.getLogger().severe("Votifier vote queue unavailable or full; "
+ (retry ? "captured receipt retained for retry for " : "vote was not admitted for ")
+ MinecraftUsernameValidator.sanitizeForLog(vote.username));
else if (admitted) plugin.getLogger().warning("Vote pipeline unavailable or retiring; queued Votifier vote for retry");
if (retry && vote.recoveryOwner != null) vote.recoveryOwner.retryCaptureTransfers(this::retryCapturedTransfers);
}
private void retryCapturedTransfers() {
synchronized (captureLock) {
for (CapturedVote vote : java.util.List.copyOf(captured.values())) {
// Ordinary queued/running callbacks retain their original execution.
// Only receipts which already attempted a failed handoff need retry.
if (vote.transferFailureReported && !vote.transferring) transferLocked(vote);
}
}
}
/**
* Processes a validated vote. The overflow queue invokes this callback from
* its worker after the bounded vote executor has capacity again.
*
* @param voteSite the validated service site
* @param voteUsername the validated player name
*/
public void processVote(String voteSite, String voteUsername) { processVote(voteSite, voteUsername, 0L); }
public void processVote(String voteSite, String voteUsername, long occurredAt) {
processVote(voteSite, voteUsername, occurredAt, UUID.randomUUID());
}
public void processVote(String voteSite, String voteUsername, long occurredAt, UUID localOccurrenceId) {
processCapturedVote(voteSite, voteUsername, occurredAt, localOccurrenceId);
}
/** False retains overflow ownership; partial ordinary effects may still have occurred. */
public boolean processVoteConfirmed(String voteSite, String voteUsername, long occurredAt, UUID localOccurrenceId) {
return processCapturedVote(voteSite, voteUsername, occurredAt, localOccurrenceId);
}
private boolean processCapturedVote(String voteSite, String voteUsername, long occurredAt, UUID localOccurrenceId) {
java.util.Objects.requireNonNull(localOccurrenceId, "localOccurrenceId");
try {
plugin.getServerData().addServiceSite(voteSite);
if (plugin.getBungeeSettings().isUseBungeecoord() && !plugin.getBungeeSettings().isVotifierBypass()
&& (plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.PLUGINMESSAGING)
|| plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.SOCKETS)
|| plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.HTTP)
|| plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.MQTT)
|| plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.REDIS))) {
plugin.getLogger().severe(
"Ignoring vote from votifier since a proxy vote transport is enabled; receive votes on the proxy or enable VotifierBypass, then check: https://github.com/BenCodez/VotingPlugin/wiki/Bungeecord-Setups");
return true;
}
String matchSite = "";
if (plugin.getConfigFile().isAdvancedServiceSiteHandling()) {
if (plugin.getServiceSiteHandler() != null) {
matchSite = plugin.getServiceSiteHandler().matchReverse(voteSite);
}
}
String voteSiteNameStr = plugin.getVoteSiteManager().getVoteSiteName(false, voteSite, matchSite);
boolean createSite = !plugin.getVoteSiteManager().hasVoteSite(voteSiteNameStr)
&& !plugin.getVoteSiteManager().hasConfiguredVoteSite(voteSiteNameStr);
String serviceSite = voteSite;
if (plugin.getConfigFile().isAutoCreateVoteSites() && createSite) {
plugin.getLogger().warning("VoteSite with service site '" + voteSiteNameStr
+ "' does not exist, attempting to generate...");
if (plugin.getConfigVoteSites().tryAutoGenerateVoteSite(voteSiteNameStr)) {
plugin.getLogger().info("Current known service sites: "
+ ArrayUtils.makeStringList(plugin.getServerData().getServiceSites()));
} else {
plugin.getLogger().warning("Unable to generate VoteSite for service site '"
+ ServiceSiteValidator.sanitizeForLog(voteSiteNameStr) + "'");
}
}
if (plugin.getTimeChecker().isActiveProcessing()
&& plugin.getConfigFile().isQueueVotesDuringTimeChange()) {
plugin.debug("Adding vote to time queue " + voteUsername + "/" + voteSite);
plugin.getTimeQueueHandler().addVote(voteUsername, voteSite, occurredAt, localOccurrenceId);
return true;
}
String voteSiteName = plugin.getVoteSiteManager().getVoteSiteName(true, serviceSite, matchSite);
PlayerVoteEvent voteEvent = new PlayerVoteEvent(
plugin.getVoteSiteManager().getVoteSite(voteSiteName, true), voteUsername, voteSite, true);
voteEvent.setLocalOccurrenceId(localOccurrenceId);
if (occurredAt > 0) voteEvent.setCanonicalOccurrenceTime(occurredAt);
plugin.getServer().getPluginManager().callEvent(voteEvent);
if (voteEvent.isCancelled()) {
plugin.debug("Vote cancelled");
}
// Bukkit may skip disabled listeners or catch their exceptions while
// returning normally. Dispatch alone is not confirmed processing.
return voteEvent.isNativeProcessingCompleted()
|| voteEvent.isCancelled() && !voteEvent.isNativeProcessingStarted();
} catch (Exception e) {
plugin.getLogger().severe("Error occured during vote processing");
e.printStackTrace();
return false; // An exception does not prove ordinary accounting/rewards completed.
}
}
/**
* On votifer event.
*
* @param event the event
*/
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
public void onVotiferEvent(VotifierEvent event) {
if (!accepting) return;
final long occurredAt = System.currentTimeMillis();
final UUID localOccurrenceId = UUID.randomUUID();
Vote vote = event.getVote();
String str = vote.getServiceName();
if (str == null || str.isEmpty()) {
str = "Empty";
}
final String voteSite = str;
final String IP = vote.getAddress();
final String voteUsername = vote.getUsername();
if (IP.equals("VotingPlugin")) {
return;
}
if (!ServiceSiteValidator.isValid(voteSite)) {
plugin.getLogger().warning("Rejected vote with invalid service site '"
+ ServiceSiteValidator.sanitizeForLog(voteSite) + "'");
return;
}
if (!MinecraftUsernameValidator.isValid(voteUsername, plugin.getOptions().getBedrockPlayerPrefix())) {
plugin.getLogger().warning("Rejected vote with invalid Minecraft username '"
+ MinecraftUsernameValidator.sanitizeForLog(voteUsername) + "' from service '"
+ ServiceSiteValidator.sanitizeForLog(voteSite) + "'");
return;
}
plugin.getLogger()
.info("Received a vote from service site '" + voteSite + "' by player '" + voteUsername + "'!");
plugin.debug("PlayerUsername: " + voteUsername);
plugin.debug("VoteSite: " + voteSite);
plugin.debug("IP: " + IP);
CapturedVote capturedVote = new CapturedVote(voteUsername, voteSite, occurredAt, localOccurrenceId);
VotifierVoteOverflowQueue.CaptureAdmission admission;
synchronized (captureLock) {
if (!accepting) return;
var overflow = plugin.getVotifierVoteOverflowQueue();
capturedVote.recoveryOwner = overflow;
if (captured.size() >= MAX_CAPTURED) {
// The overflow owns its independent bounded admission lane.
transferLocked(capturedVote);
return;
}
admission = overflow == null ? VotifierVoteOverflowQueue.CaptureAdmission.ACCEPTED
: overflow.reserveCapture(localOccurrenceId);
if (admission == VotifierVoteOverflowQueue.CaptureAdmission.REJECTED) {
plugin.getLogger().severe("Votifier capture capacity unavailable; vote was not admitted");
return;
}
// Reserve known capacity and publish capture ownership together with
// respect to stop's snapshot. Unknown capacity remains provisional.
captured.put(localOccurrenceId, capturedVote);
}
if (admission == VotifierVoteOverflowQueue.CaptureAdmission.WAITING_FOR_LOAD) {
capturedVote.recoveryOwner.reserveCaptureAfterInitialization(localOccurrenceId,
admitted -> admitCaptured(capturedVote, admitted));
} else admitCaptured(capturedVote, true);
}
private void admitCaptured(CapturedVote vote, boolean admitted) {
synchronized (captureLock) {
if (captured.get(vote.id) != vote) {
if (admitted && vote.recoveryOwner != null) vote.recoveryOwner.releaseCapture(vote.id);
return;
}
if (!admitted) {
captured.remove(vote.id);
plugin.getLogger().severe("Votifier capture capacity unavailable; vote was not admitted");
return;
}
if (!accepting) {
if (!retirementQueued) transferLocked(vote);
return;
}
}
try {
var timer = plugin.getVoteTimer();
if (timer == null) {
synchronized (captureLock) { transferLocked(vote); }
return;
}
timer.submit(() -> runCaptured(vote));
} catch (RejectedExecutionException rejected) {
synchronized (captureLock) { transferLocked(vote); }
}
}
}
@@ -0,0 +1,976 @@
package com.bencodez.votingplugin.listeners;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.SeekableByteChannel;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
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.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Properties;
import java.util.UUID;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import org.bukkit.configuration.file.YamlConfiguration;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.util.DurableFiles;
import com.bencodez.votingplugin.util.MinecraftUsernameValidator;
import com.bencodez.votingplugin.util.ServiceSiteValidator;
/**
* Durable overflow for votes that cannot currently be admitted to the bounded
* vote executor. The queue is drained from a daemon worker and never blocks
* the Bukkit event thread.
*/
public final class VotifierVoteOverflowQueue implements AutoCloseable {
/* Keep the overflow bounded independently of the executor's 256 admissions. */
private static final int MAX_ENTRIES = 256;
public static final int MAX_CAPTURED = 256;
private static final int MAX_RETAINED_ENTRIES = MAX_ENTRIES + MAX_CAPTURED;
private static final long MAX_FILE_BYTES = 4L * 1024L * 1024L;
private static final long RETRY_DELAY_MILLIS = 250L;
private static final String QUEUE_FILE = "VotifierVoteQueue.yml";
private static final String OWNER_FILE = "VotifierVoteQueue.yml.owner";
private static final String SHARED_OWNER_PROPERTY_PREFIX = "com.bencodez.votingplugin.VotifierVoteOverflowQueue.owner.";
private static final int MAX_OWNER_RESERVATIONS = 1024;
private final VotingPluginMain plugin;
@FunctionalInterface
public interface VoteProcessor { void process(String serviceSite, String username, long occurredAt); }
@FunctionalInterface
public interface OccurrenceProcessor {
void process(String serviceSite, String username, long occurredAt, UUID localOccurrenceId);
/** Legacy callbacks confirm completion by returning normally. */
default boolean processConfirmed(String serviceSite, String username, long occurredAt, UUID localOccurrenceId) {
process(serviceSite, username, occurredAt, localOccurrenceId);
return true;
}
}
@FunctionalInterface
public interface ConfirmedOccurrenceProcessor {
boolean processConfirmed(String serviceSite, String username, long occurredAt, UUID localOccurrenceId);
}
private final OccurrenceProcessor processor;
private final java.util.function.BooleanSupplier processingAllowed;
private final Path file;
private final Path ownerFile;
private FileChannel ownerChannel;
private FileLock ownerLock;
private String ownerKey;
private String ownerToken;
private Properties ownerProperties;
private final ScheduledThreadPoolExecutor worker;
private final Object lock = new Object();
private final Object persistenceWriteLock = new Object();
private final ArrayDeque<PendingVote> entries = new ArrayDeque<>();
private boolean drainScheduled;
private boolean drainRetryScheduled;
private boolean captureRetryScheduled;
private boolean persistenceScheduled;
private boolean persistenceDirty;
private boolean started;
private boolean closed;
private boolean closing;
private final java.util.concurrent.CompletableFuture<Void> closePublication = new java.util.concurrent.CompletableFuture<>();
private boolean closeFailureReported;
private volatile boolean loaded;
private volatile boolean loadFailed;
private int pendingIngress;
private int pendingCaptureAdmissions;
private int pendingRetirementCaptures;
private boolean retirementRequested;
private boolean initializationRetryScheduled;
private boolean initializationFailureReported;
private boolean queueFileObserved;
private final Set<UUID> captureReservations = new java.util.HashSet<>();
private final boolean asynchronousInitialization;
private long stateVersion;
private long durableVersion;
/**
* Creates and loads the overflow queue.
*
* @param plugin the owning plugin
* @param processor callback receiving service site and player name
*/
public VotifierVoteOverflowQueue(VotingPluginMain plugin, BiConsumer<String, String> processor) {
this(plugin, (site, user, occurredAt, id) -> processor.accept(site, user), () -> true);
}
/** Additive callback retains receipt occurrence and fences a retired startup generation. */
public VotifierVoteOverflowQueue(VotingPluginMain plugin, VoteProcessor processor,
java.util.function.BooleanSupplier processingAllowed) {
this(plugin, (site, user, time, id) -> processor.process(site, user, time), processingAllowed);
}
public VotifierVoteOverflowQueue(VotingPluginMain plugin, OccurrenceProcessor processor,
java.util.function.BooleanSupplier processingAllowed) {
this(plugin, processor, processingAllowed, false);
}
/** Memory-only construction; all loading and handoffs run on the existing queue worker. */
public static VotifierVoteOverflowQueue initializeAsync(VotingPluginMain plugin, OccurrenceProcessor processor,
java.util.function.BooleanSupplier processingAllowed) {
return new VotifierVoteOverflowQueue(plugin, processor, processingAllowed, true);
}
/** Named factory preserves existing void callback/lambda contracts without ambiguous overloads. */
public static VotifierVoteOverflowQueue initializeConfirmedAsync(VotingPluginMain plugin,
ConfirmedOccurrenceProcessor processor, java.util.function.BooleanSupplier processingAllowed) {
java.util.Objects.requireNonNull(processor, "processor");
return initializeAsync(plugin, new OccurrenceProcessor() {
@Override public void process(String site, String user, long time, UUID id) {
processor.processConfirmed(site, user, time, id);
}
@Override public boolean processConfirmed(String site, String user, long time, UUID id) {
return processor.processConfirmed(site, user, time, id);
}
}, processingAllowed);
}
private VotifierVoteOverflowQueue(VotingPluginMain plugin, OccurrenceProcessor processor,
java.util.function.BooleanSupplier processingAllowed, boolean asynchronousInitialization) {
this.asynchronousInitialization = asynchronousInitialization;
this.plugin = plugin;
this.processor = processor;
this.processingAllowed = processingAllowed;
this.file = new File(plugin.getDataFolder(), QUEUE_FILE).toPath();
this.ownerFile = new File(plugin.getDataFolder(), OWNER_FILE).toPath();
this.worker = new ScheduledThreadPoolExecutor(1, runnable -> {
Thread thread = new Thread(runnable, "VotingPlugin-Votifier-Overflow");
thread.setDaemon(true);
return thread;
});
this.worker.setRemoveOnCancelPolicy(true);
if (asynchronousInitialization) worker.execute(this::load);
else load();
}
/**
* Starts draining persisted and newly queued votes. Call this only after the
* Votifier and vote-processing listeners have been registered so a recovered
* vote cannot be processed before its event handlers are available.
*/
public void start() {
synchronized (lock) {
if (closed || started) return;
started = true;
scheduleDrainLocked();
}
}
/**
* Adds a validated vote to the durable queue. This method only takes a small
* monitor and schedules file I/O on the daemon worker.
*
* @param username the validated player name
* @param serviceSite the validated service site
* @return false when the bounded overflow is full or shutting down
*/
public boolean enqueue(String username, String serviceSite) {
return enqueue(username, serviceSite, System.currentTimeMillis());
}
public boolean enqueue(String username, String serviceSite, long occurredAt) {
return enqueue(username, serviceSite, occurredAt, UUID.randomUUID());
}
public boolean enqueue(String username, String serviceSite, long occurredAt, UUID localOccurrenceId) {
return enqueueStored(username, serviceSite, occurredAt, localOccurrenceId, false);
}
/** Transfer stays behind initial loading and is bounded independently of executor admissions. */
public void enqueueAfterInitialization(String username, String serviceSite, long occurredAt, UUID localOccurrenceId,
Consumer<Boolean> completion) {
enqueueAfterInitialization(username, serviceSite, occurredAt, localOccurrenceId, () -> true, completion);
}
/** A STARTED original callback retains execution ownership until its outcome is known. */
public void enqueueAfterInitialization(String username, String serviceSite, long occurredAt, UUID localOccurrenceId,
java.util.function.BooleanSupplier executionReleased, Consumer<Boolean> completion) {
if (username == null || serviceSite == null || occurredAt <= 0 || localOccurrenceId == null) {
completion.accept(false);
return;
}
var finished = new java.util.concurrent.atomic.AtomicBoolean();
class Transfer implements Runnable {
void finish(boolean accepted) {
if (!finished.compareAndSet(false, true)) return;
synchronized (lock) { pendingIngress--; }
completion.accept(accepted);
}
@Override public void run() {
try {
synchronized (lock) {
if (!closed && !loaded) {
// Admission owns this receipt, including the saturated
// path which has no entry in the listener's capture map.
scheduleInitializationRetryLocked();
worker.schedule(this, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
return;
}
}
} catch (RejectedExecutionException rejected) { finish(false); return; }
boolean accepted = false;
try { accepted = enqueueStored(username, serviceSite, occurredAt, localOccurrenceId, true, executionReleased); }
finally { finish(accepted); }
}
}
boolean submitted = false;
synchronized (lock) {
if (!closed && !closing && pendingIngress < MAX_ENTRIES) {
pendingIngress++;
try {
worker.execute(new Transfer());
submitted = true;
} catch (RejectedExecutionException rejected) { pendingIngress--; }
}
}
if (!submitted) completion.accept(false);
}
private boolean enqueueStored(String username, String serviceSite, long occurredAt, UUID localOccurrenceId,
boolean admittedTransfer) {
return enqueueStored(username, serviceSite, occurredAt, localOccurrenceId, admittedTransfer, () -> true);
}
private boolean enqueueStored(String username, String serviceSite, long occurredAt, UUID localOccurrenceId,
boolean admittedTransfer, java.util.function.BooleanSupplier executionReleased) {
if (username == null || serviceSite == null || occurredAt <= 0 || localOccurrenceId == null) return false;
synchronized (lock) {
if (closed || closing && !admittedTransfer || !loaded || loadFailed) return false;
var existing = occurrence(localOccurrenceId);
if (existing != null) {
boolean same = matches(existing, username, serviceSite, occurredAt);
if (same) captureReservations.remove(localOccurrenceId);
return same;
}
int entryLimit = closing && admittedTransfer ? MAX_RETAINED_ENTRIES : MAX_ENTRIES;
if (entries.size() >= entryLimit || !captureReservations.contains(localOccurrenceId)
&& entries.size() + captureReservations.size() >= MAX_RETAINED_ENTRIES) return false;
entries.addLast(new PendingVote(username, serviceSite, occurredAt, localOccurrenceId, executionReleased));
captureReservations.remove(localOccurrenceId);
stateVersion++;
requestPersistenceLocked();
scheduleDrainLocked();
return true;
}
}
public enum CaptureAdmission { ACCEPTED, WAITING_FOR_LOAD, REJECTED }
/** Reserve final-snapshot capacity before the normal executor owns this receipt. */
public CaptureAdmission reserveCapture(UUID id) {
java.util.Objects.requireNonNull(id);
synchronized (lock) {
if (closed || closing || loadFailed) return CaptureAdmission.REJECTED;
if (!loaded) return CaptureAdmission.WAITING_FOR_LOAD;
if (captureReservations.contains(id)) return CaptureAdmission.ACCEPTED;
if (captureReservations.size() >= MAX_CAPTURED
|| entries.size() + captureReservations.size() >= MAX_RETAINED_ENTRIES)
return CaptureAdmission.REJECTED;
captureReservations.add(id);
return CaptureAdmission.ACCEPTED;
}
}
/** Unknown disk capacity is provisional; keep at most one deferred admission per capture. */
public void reserveCaptureAfterInitialization(UUID id, Consumer<Boolean> completion) {
java.util.Objects.requireNonNull(id); java.util.Objects.requireNonNull(completion);
boolean admitted;
synchronized (lock) {
admitted = !closed && !closing && pendingCaptureAdmissions < MAX_CAPTURED;
if (admitted) pendingCaptureAdmissions++;
}
if (!admitted) { completion.accept(false); return; }
var finished = new java.util.concurrent.atomic.AtomicBoolean();
class Admission implements Runnable {
void finish(boolean accepted) {
if (!finished.compareAndSet(false, true)) return;
synchronized (lock) { pendingCaptureAdmissions--; }
completion.accept(accepted);
}
@Override public void run() {
var result = reserveCapture(id);
if (result != CaptureAdmission.WAITING_FOR_LOAD) {
finish(result == CaptureAdmission.ACCEPTED);
return;
}
try { worker.schedule(this, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); }
catch (RejectedExecutionException rejected) { finish(false); }
}
}
var admission = new Admission();
try { worker.execute(admission); }
catch (RejectedExecutionException rejected) { admission.finish(false); }
}
public void releaseCapture(UUID id) {
synchronized (lock) { captureReservations.remove(id); }
}
public record RetiringCapture(String username, String serviceSite, long time, UUID id,
java.util.function.BooleanSupplier executionReleased,
java.util.function.BooleanSupplier completed, Consumer<Boolean> completion) {
public RetiringCapture {
java.util.Objects.requireNonNull(username); java.util.Objects.requireNonNull(serviceSite);
java.util.Objects.requireNonNull(id); java.util.Objects.requireNonNull(executionReleased);
java.util.Objects.requireNonNull(completed); java.util.Objects.requireNonNull(completion);
if (time <= 0) throw new IllegalArgumentException("capture receipt time must be positive");
}
}
/** One FIFO batch, before close's final snapshot; normal overflow admission stays capped at256. */
public void retireCaptures(List<RetiringCapture> captures) {
var snapshot = List.copyOf(captures);
if (snapshot.size() > MAX_CAPTURED) throw new IllegalArgumentException("capture retirement exceeds capacity");
if (snapshot.isEmpty()) return;
class Transfer implements Runnable {
@Override public void run() {
synchronized (lock) {
if (!closed && (!loaded || loadFailed)) {
scheduleInitializationRetryLocked();
worker.schedule(this, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
return;
}
}
boolean[] results = new boolean[snapshot.size()];
try {
for (int index = 0; index < snapshot.size(); index++) {
var capture = snapshot.get(index);
boolean completed = false;
try { completed = capture.completed().getAsBoolean(); }
catch (RuntimeException | Error unknown) { /* Retain unconfirmed completion. */ }
synchronized (lock) {
if (!closed && loaded && !loadFailed) {
var existing = occurrence(capture.id());
if (completed) {
captureReservations.remove(capture.id());
results[index] = true;
} else if (existing != null) {
results[index] = matches(existing, capture.username(), capture.serviceSite(), capture.time());
if (results[index]) captureReservations.remove(capture.id());
} else if (entries.size() < MAX_RETAINED_ENTRIES
&& (captureReservations.contains(capture.id())
|| entries.size() + captureReservations.size() < MAX_RETAINED_ENTRIES)) {
entries.addLast(new PendingVote(capture.username(), capture.serviceSite(), capture.time(),
capture.id(), capture.executionReleased()));
captureReservations.remove(capture.id());
stateVersion++; requestPersistenceLocked();
results[index] = true;
}
}
}
}
// Retain the whole batch before any external completion callback.
for (int index = 0; index < snapshot.size(); index++) {
try { snapshot.get(index).completion().accept(results[index]); }
catch (RuntimeException | Error failure) {
try { plugin.getLogger().warning("Votifier retirement callback failed; queue ownership is retained"); }
catch (RuntimeException | Error ignored) { }
}
}
} finally {
synchronized (lock) { pendingRetirementCaptures -= snapshot.size(); }
}
}
}
boolean submitted = false;
synchronized (lock) {
if (!closed && !closing && pendingRetirementCaptures + snapshot.size() <= MAX_CAPTURED) {
pendingRetirementCaptures += snapshot.size();
try { worker.execute(new Transfer()); retirementRequested = true; submitted = true; }
catch (RejectedExecutionException rejected) { pendingRetirementCaptures -= snapshot.size(); }
}
}
if (!submitted) for (var capture : snapshot) {
try { capture.completion().accept(false); }
catch (RuntimeException | Error ignored) { /* Reject every bounded caller, including the suffix. */ }
}
}
private void scheduleInitializationRetryLocked() {
if (closed || initializationRetryScheduled || loaded && !loadFailed) return;
initializationRetryScheduled = true;
worker.schedule(() -> {
synchronized (lock) {
initializationRetryScheduled = false;
if (closed) return;
}
load();
}, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
}
private PendingVote occurrence(UUID id) {
for (var vote : entries) if (vote.localOccurrenceId.equals(id)) return vote;
return null;
}
private static boolean matches(PendingVote vote, String username, String site, long time) {
return vote.username.equals(username) && vote.serviceSite.equals(site) && vote.time == time;
}
/** Completion only; transfers separately report load/capacity admission failure. */
public boolean isInitializationComplete() { return loaded; }
/**
* Returns the number of votes waiting for admission or completion.
*
* @return queue size
*/
public int size() {
synchronized (lock) {
return entries.size();
}
}
private void scheduleDrain() {
synchronized (lock) {
scheduleDrainLocked();
}
}
private void scheduleDrainLocked() {
if (closed || closing || !loaded || loadFailed || !started || drainScheduled) return;
drainScheduled = true;
try {
worker.schedule(this::drain, 0, TimeUnit.MILLISECONDS);
} catch (RejectedExecutionException ignored) {
drainScheduled = false;
}
}
private void drain() {
while (true) {
// A generation fence parks its receipts instead of repeatedly submitting
// callbacks which are forbidden to execute (including inline submitters).
if (!processingAllowed.getAsBoolean()) {
synchronized (lock) { drainScheduled = false; }
return;
}
synchronized (lock) {
if (closed || closing || !loaded || loadFailed) { drainScheduled = false; return; }
if (durableVersion != stateVersion) {
drainScheduled = false;
return;
}
PendingVote pending = nextUnsubmittedLocked();
if (pending == null) {
drainScheduled = false;
return;
}
pending.submitted = true;
try {
// Serialize admission with enqueue so the version proven durable
// above cannot change in the gap before submit accepts this vote.
plugin.getVoteTimer().submit(() -> {
boolean attempted = false;
boolean completed = false;
try {
if (!processingAllowed.getAsBoolean()) return;
synchronized (lock) {
if (closed || closing || !entries.contains(pending)) return;
}
attempted = true;
completed = processor.processConfirmed(pending.serviceSite, pending.username,
pending.time, pending.localOccurrenceId) && !Thread.currentThread().isInterrupted();
} catch (RuntimeException | Error failure) {
plugin.getLogger().warning("Queued Votifier processing failed; retained receipt for retry: "
+ failure.getClass().getSimpleName());
throw failure;
} finally {
if (completed) acknowledge(pending);
else retainFailedAttempt(pending, attempted);
}
});
} catch (RejectedExecutionException rejected) {
pending.submitted = false;
drainScheduled = false;
try {
worker.schedule(this::drain, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
drainScheduled = true;
} catch (RejectedExecutionException ignored) {
// The queue is closing; the persisted entry remains for restart.
}
return;
} catch (RuntimeException | Error failure) {
// An inline submitter can expose callback failure on this worker.
// Retain retry ownership without leaving drainScheduled stuck true.
drainScheduled = false;
retainFailedAttempt(pending, true);
throw failure;
}
}
}
}
private PendingVote nextUnsubmittedLocked() {
long now = System.nanoTime();
boolean retryWaiting = false;
for (PendingVote pending : entries) {
if (pending.submitted || !pending.executionReleased.getAsBoolean()) continue;
if (pending.retryNotBeforeNanos != 0 && now - pending.retryNotBeforeNanos < 0) {
retryWaiting = true;
continue;
}
return pending;
}
if (retryWaiting) scheduleDrainRetryLocked();
return null;
}
private void retainFailedAttempt(PendingVote pending, boolean attempted) {
synchronized (lock) {
pending.submitted = false;
if (closed || closing || !entries.contains(pending) || !attempted) return;
// Inline submitters must not retry this same receipt in the current drain.
pending.retryNotBeforeNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(RETRY_DELAY_MILLIS);
scheduleDrainRetryLocked();
}
}
private void scheduleDrainRetryLocked() {
if (closed || closing || !loaded || loadFailed || !started || drainRetryScheduled) return;
drainRetryScheduled = true;
try {
worker.schedule(() -> {
synchronized (lock) {
drainRetryScheduled = false;
scheduleDrainLocked();
}
}, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
} catch (RejectedExecutionException rejected) { drainRetryScheduled = false; }
}
private void acknowledge(PendingVote pending) {
synchronized (lock) {
if (closed) {
plugin.getLogger().warning("Completed Votifier callback outlived queue shutdown; retained occurrence requires reconciliation");
return;
}
if (!entries.remove(pending)) return;
stateVersion++;
requestPersistenceLocked();
scheduleDrainLocked();
}
}
/** Reconcile a receipt whose original normal callback completed during transfer. */
public void acknowledgeOccurrence(UUID localOccurrenceId) {
synchronized (lock) {
if (closed) {
// A nonterminating callback outlived the final snapshot. A retired
// writer cannot overwrite a successor's queue; retain uncertainty.
plugin.getLogger().warning("Completed Votifier callback outlived queue shutdown; retained occurrence requires reconciliation");
return;
}
if (!entries.removeIf(pending -> pending.localOccurrenceId.equals(localOccurrenceId))) return;
stateVersion++; requestPersistenceLocked(); scheduleDrainLocked();
}
}
/** Wake the same bounded drain when an original transferred callback finishes uncertainly. */
public void resumeTransfers() { scheduleDrain(); }
/** One wakeup for the ingress listener's bounded failed-transfer captures.
* Retry runs outside the queue monitor and never adds another executor or waiter list. */
public void retryCaptureTransfers(Runnable retry) {
synchronized (lock) {
if (closed || closing || captureRetryScheduled) return;
captureRetryScheduled = true;
try {
worker.schedule(() -> {
synchronized (lock) {
captureRetryScheduled = false;
if (closed || closing) return;
}
retry.run();
}, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
} catch (RejectedExecutionException rejected) { captureRetryScheduled = false; }
}
}
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) {
List<PendingVote> snapshot;
long snapshotVersion;
synchronized (lock) {
if (!persistenceDirty) {
persistenceScheduled = false;
return;
}
persistenceDirty = false;
snapshot = new ArrayList<>(entries);
snapshotVersion = stateVersion;
}
try {
writeSnapshot(snapshot);
} catch (IOException failure) {
plugin.getLogger().warning("Unable to persist queued Votifier votes: " + failure.getClass().getSimpleName());
synchronized (lock) {
persistenceDirty = true;
try {
worker.schedule(this::persistLoop, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
} catch (RejectedExecutionException ignored) {
persistenceScheduled = false;
}
}
return;
}
synchronized (lock) {
durableVersion = Math.max(durableVersion, snapshotVersion);
if (!persistenceDirty) {
persistenceScheduled = false;
scheduleDrainLocked();
return;
}
}
}
}
private void load() {
ArrayDeque<PendingVote> recovered = new ArrayDeque<>();
boolean successful = false;
try {
synchronized (lock) { if (closed || loaded && !loadFailed) return; }
if (!acquireOwner()) {
synchronized (lock) {
if (asynchronousInitialization && !closing || pendingRetirementCaptures > 0 || pendingIngress > 0) {
scheduleInitializationRetryLocked();
return;
}
}
throw new IOException("queue file is owned by another active instance");
}
if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) {
if (queueFileObserved) throw new IOException("queue file disappeared before recovery");
successful = true;
return;
}
queueFileObserved = true;
YamlConfiguration yaml = new YamlConfiguration();
yaml.loadFromString(readQueueFile());
Object raw = yaml.get("Votes");
if (!(raw instanceof List<?> values)) throw new IOException("queue votes are not a list");
if (values.size() > MAX_RETAINED_ENTRIES) throw new IOException("queue exceeds entry capacity");
boolean skipped = false;
for (Object value : values) {
if (!(value instanceof Map<?, ?> map)) {
skipped = true;
continue;
}
Object username = map.get("Username");
Object serviceSite = map.get("ServiceSite");
Object time = map.get("Time");
if (!(username instanceof String name) || !(serviceSite instanceof String site)
|| !(time instanceof Number timestamp)
|| !MinecraftUsernameValidator.isValid(name, plugin.getOptions().getBedrockPlayerPrefix())
|| !ServiceSiteValidator.isValid(site) || timestamp.longValue() <= 0) {
skipped = true;
continue;
}
UUID localOccurrenceId;
if (!map.containsKey("LocalOccurrenceId")) {
localOccurrenceId = UUID.randomUUID();
skipped = true; // Publish the migration before admitting any recovered row.
} else {
Object rawId = map.get("LocalOccurrenceId");
try {
if (!(rawId instanceof String id)) throw new IllegalArgumentException();
localOccurrenceId = UUID.fromString(id);
if (!localOccurrenceId.toString().equalsIgnoreCase(id)) throw new IllegalArgumentException();
} catch (IllegalArgumentException malformed) {
skipped = true;
continue; // Explicit corrupt IDs cannot become fresh occurrences.
}
}
recovered.addLast(new PendingVote(name, site, timestamp.longValue(), localOccurrenceId));
}
synchronized (lock) { entries.clear(); entries.addAll(recovered); }
if (skipped) {
synchronized (lock) {
stateVersion++;
requestPersistenceLocked();
}
}
successful = true;
} catch (Exception failure) {
loadFailed = true;
if (!initializationFailureReported) {
initializationFailureReported = true;
plugin.getLogger().warning("Unable to load queued Votifier votes: " + failure.getClass().getSimpleName());
}
} finally {
if (ownerLock == null && !loadFailed) return;
if (successful) loadFailed = false;
loaded = true;
synchronized (lock) {
scheduleDrainLocked();
if (loadFailed && pendingRetirementCaptures > 0) scheduleInitializationRetryLocked();
}
}
}
private boolean acquireOwner() throws IOException {
if (ownerLock != null && ownerLock.isValid()) return true;
Path parent = ownerFile.toAbsolutePath().normalize().getParent();
if (parent == null || Files.isSymbolicLink(ownerFile)) throw new IOException("queue ownership file is unsafe");
Files.createDirectories(parent);
String key = SHARED_OWNER_PROPERTY_PREFIX + hashOwnerPath(parent.toRealPath().resolve(ownerFile.getFileName()));
String token = UUID.randomUUID().toString();
Properties properties = System.getProperties();
synchronized (properties) {
if (properties.getProperty(key) != null) return false;
int reservations = 0;
for (Object property : properties.keySet())
if (property instanceof String name && name.startsWith(SHARED_OWNER_PROPERTY_PREFIX)) reservations++;
if (reservations >= MAX_OWNER_RESERVATIONS) return false;
properties.setProperty(key, token);
}
try {
FileChannel channel = FileChannel.open(ownerFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS);
try {
FileLock acquired;
try { acquired = channel.tryLock(); }
catch (OverlappingFileLockException busy) { acquired = null; }
if (acquired == null) { channel.close(); clearOwnerReservation(properties, key, token); return false; }
ownerKey = key; ownerToken = token; ownerProperties = properties; ownerChannel = channel; ownerLock = acquired;
return true;
} catch (IOException | RuntimeException failure) {
channel.close();
clearOwnerReservation(properties, key, token);
throw failure;
}
} catch (IOException | RuntimeException failure) {
clearOwnerReservation(properties, key, token);
throw failure;
}
}
private void releaseOwner() {
try { if (ownerLock != null) ownerLock.release(); } catch (IOException ignored) { }
try { if (ownerChannel != null) ownerChannel.close(); } catch (IOException ignored) { }
clearOwnerReservation(ownerProperties, ownerKey, ownerToken);
ownerLock = null;
ownerChannel = null;
ownerKey = null;
ownerToken = null;
ownerProperties = null;
}
private static void clearOwnerReservation(Properties properties, String key, String token) {
if (properties == null || key == null || token == null) return;
synchronized (properties) {
if (token.equals(properties.getProperty(key))) properties.remove(key);
}
}
private static String hashOwnerPath(Path path) throws IOException {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(path.toString().getBytes(StandardCharsets.UTF_8));
StringBuilder result = new StringBuilder(digest.length * 2);
for (byte value : digest) result.append(String.format("%02x", value));
return result.toString();
} catch (NoSuchAlgorithmException impossible) {
throw new IOException("SHA-256 unavailable", impossible);
}
}
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 void writeSnapshot(List<PendingVote> snapshot) throws IOException {
synchronized (persistenceWriteLock) {
if (closed) return;
writeSnapshotLocked(snapshot);
}
}
private void writeSnapshotLocked(List<PendingVote> snapshot) throws IOException {
if (ownerLock == null || !ownerLock.isValid()) throw new IOException("queue file ownership is unavailable");
if (snapshot.size() > MAX_RETAINED_ENTRIES) throw new IOException("queue snapshot exceeds entry capacity");
YamlConfiguration yaml = new YamlConfiguration();
List<Map<String, Object>> values = new ArrayList<>();
for (PendingVote pending : snapshot) {
Map<String, Object> value = new LinkedHashMap<>();
value.put("Username", pending.username);
value.put("ServiceSite", pending.serviceSite);
value.put("Time", pending.time);
value.put("LocalOccurrenceId", pending.localOccurrenceId.toString());
values.add(value);
}
yaml.set("Votes", values);
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, "VotifierVoteQueue-", ".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);
}
DurableFiles.forceDirectory(parent);
} finally {
Files.deleteIfExists(temporary);
}
}
@Override
public void close() {
List<PendingVote> snapshot = null;
boolean initializedWorker;
synchronized (lock) {
initializedWorker = asynchronousInitialization || retirementRequested || pendingIngress > 0;
if (!initializedWorker) {
if (closed) return;
closed = true;
// Legacy callers keep their existing close path until retirement admission.
snapshot = new ArrayList<>(entries);
persistenceScheduled = false;
}
}
if (initializedWorker) { closeInitializedWorker(); return; }
worker.shutdownNow();
try {
worker.awaitTermination(1, TimeUnit.SECONDS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
if (loadFailed) { releaseOwner(); return; }
try {
synchronized (persistenceWriteLock) {
writeSnapshotLocked(snapshot);
}
} catch (IOException failure) {
plugin.getLogger().warning("Unable to persist queued Votifier votes during shutdown: "
+ failure.getClass().getSimpleName());
}
releaseOwner();
}
private void closeInitializedWorker() {
synchronized (lock) {
if (!closing && !closed) {
closing = true;
// Keep the existing worker and file owner through failed final publication.
worker.execute(this::finishClosePublication);
}
}
try { closePublication.get(1, TimeUnit.SECONDS); }
catch (java.util.concurrent.TimeoutException pending) {
plugin.getLogger().warning("Votifier queue shutdown publication is still pending on its worker; recovery is unconfirmed");
} catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); }
catch (java.util.concurrent.ExecutionException failure) {
throw new IllegalStateException("Votifier queue shutdown publication failed", failure.getCause());
}
}
private void finishClosePublication() {
List<PendingVote> snapshot;
long version;
synchronized (lock) {
if (closed) return;
if (pendingRetirementCaptures > 0 || pendingIngress > 0) {
if (!loaded || loadFailed) scheduleInitializationRetryLocked();
worker.schedule(this::finishClosePublication, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
return;
}
snapshot = new ArrayList<>(entries);
version = stateVersion;
}
if (loadFailed || !loaded) {
// Unreadable/unowned storage is unknown; never replace it with an empty guess.
synchronized (lock) { closed = true; }
releaseOwner(); worker.shutdown(); closePublication.complete(null);
return;
}
try { synchronized (persistenceWriteLock) { writeSnapshotLocked(snapshot); } }
catch (IOException failure) {
if (!closeFailureReported) {
closeFailureReported = true;
try { plugin.getLogger().warning("Unable to publish final Votifier queue; its worker retains ownership for retry"); }
catch (RuntimeException | Error ignored) { }
}
worker.schedule(this::finishClosePublication, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS);
return;
}
synchronized (lock) {
// A confirmed original callback may have removed a receipt during IO.
if (version != stateVersion) {
worker.execute(this::finishClosePublication);
return;
}
closed = true;
durableVersion = version;
}
releaseOwner(); worker.shutdown(); closePublication.complete(null);
}
private static final class PendingVote {
private final String username;
private final String serviceSite;
private final long time;
private final UUID localOccurrenceId;
private final java.util.function.BooleanSupplier executionReleased;
private boolean submitted;
private long retryNotBeforeNanos;
private PendingVote(String username, String serviceSite, long time, UUID localOccurrenceId) {
this(username, serviceSite, time, localOccurrenceId, () -> true);
}
private PendingVote(String username, String serviceSite, long time, UUID localOccurrenceId,
java.util.function.BooleanSupplier executionReleased) {
this.username = username;
this.serviceSite = serviceSite;
this.time = time;
this.localOccurrenceId = localOccurrenceId;
this.executionReleased = java.util.Objects.requireNonNull(executionReleased);
}
}
}
@@ -0,0 +1,212 @@
package com.bencodez.votingplugin.maintenance;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.LongSupplier;
import java.util.logging.Logger;
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState;
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState.Candidate;
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState.Snapshot;
import com.bencodez.votingplugin.util.DurableFiles.PublishedException;
/** Owner-only console reminder; all file work runs on its bounded background worker. */
public final class AutoCreateVoteSitesReminderService {
public record Settings(boolean enabled, boolean autoCreateEnabled, long afterMillis, long repeatMillis) {
public Settings {
if (afterMillis < 0 || repeatMillis <= 0
|| afterMillis > AutoCreateVoteSitesReminderState.MAX_INTERVAL_MILLIS
|| repeatMillis > AutoCreateVoteSitesReminderState.MAX_INTERVAL_MILLIS) {
throw new IllegalArgumentException("Reminder intervals must be non-negative and bounded");
}
}
}
@FunctionalInterface
interface StoreFactory {
AutoCreateVoteSitesReminderStore create(Path directory) throws IOException;
}
private final Path directory;
private final ScheduledExecutorService executor;
private final Logger logger;
private final LongSupplier clock;
private final StoreFactory storeFactory;
private final long startedNanos;
private final AtomicBoolean started = new AtomicBoolean();
private final AtomicBoolean recoveryRequested = new AtomicBoolean();
private record CloseRequest(CompletableFuture<Void> result, long stoppedNanos) { }
private final AtomicReference<CloseRequest> closing = new AtomicReference<>();
private final Object clockGate = new Object();
private volatile Settings settings;
private volatile ScheduledFuture<?> task;
// Accessed only inside the serialized worker checkpoint, never by gameplay callers.
private AutoCreateVoteSitesReminderStore store;
private AutoCreateVoteSitesReminderState state;
private boolean failed;
private boolean checkpointRequired;
private boolean waitingReported;
public AutoCreateVoteSitesReminderService(Path directory, ScheduledExecutorService executor,
Logger logger, Settings settings) {
this(directory, executor, logger, settings, System::nanoTime, AutoCreateVoteSitesReminderStore::new);
}
AutoCreateVoteSitesReminderService(Path directory, ScheduledExecutorService executor,
Logger logger, Settings settings, LongSupplier clock, StoreFactory storeFactory) {
this.directory = Objects.requireNonNull(directory);
this.executor = Objects.requireNonNull(executor);
this.logger = Objects.requireNonNull(logger);
this.settings = Objects.requireNonNull(settings);
this.clock = Objects.requireNonNull(clock);
this.storeFactory = Objects.requireNonNull(storeFactory);
startedNanos = clock.getAsLong();
}
public void start() {
if (closing.get() != null || !started.compareAndSet(false, true)) return;
try {
task = executor.scheduleWithFixedDelay(this::tick, 0, 60, TimeUnit.SECONDS);
if (closing.get() != null) task.cancel(false);
} catch (RuntimeException rejected) {
logger.warning("AutoCreateVoteSites setup reminder could not start; voting is unaffected.");
}
}
/** Reload is a snapshot update, never synchronous file I/O or another periodic task. */
public void reload(Settings next) {
settings = Objects.requireNonNull(next);
recoveryRequested.set(true);
}
private void tick() {
long nowNanos;
synchronized (clockGate) {
if (closing.get() != null) return;
nowNanos = clock.getAsLong();
}
try {
checkpoint(nowNanos, false);
} catch (IOException | RuntimeException failure) {
logger.warning("AutoCreateVoteSites setup reminder paused after a checkpoint failure; "
+ "repair its checkpoint if necessary and reload or restart. Voting is unaffected.");
}
}
private synchronized void checkpoint(long nowNanos, boolean finalCheckpoint) throws IOException {
CloseRequest stopped = closing.get();
if (stopped != null) {
if (!finalCheckpoint) return;
nowNanos = stopped.stoppedNanos();
}
if (failed && !recoveryRequested.getAndSet(false)) {
if (finalCheckpoint) throw new IOException("Reminder checkpoint remains unavailable");
return;
}
try {
if (store == null) store = storeFactory.create(directory);
if (!store.tryAcquireLease()) {
if (finalCheckpoint) throw new IOException("Final reminder checkpoint has no storage ownership");
if (!waitingReported) {
logger.warning("AutoCreateVoteSites setup reminder is waiting for a retiring reminder worker; "
+ "voting is unaffected.");
waitingReported = true;
}
return;
}
waitingReported = false;
if (state == null || failed) {
Snapshot saved;
try {
saved = store.load();
} catch (IOException | RuntimeException unverified) {
checkpointRequired = true;
throw unverified;
}
if (saved == null && checkpointRequired) {
throw new IOException("Reminder checkpoint was removed while running");
}
if (saved != null) checkpointRequired = true;
state = new AutoCreateVoteSitesReminderState(saved == null ? new Snapshot(0, -1) : saved,
state == null && !failed ? startedNanos : nowNanos);
failed = false;
} else if (checkpointRequired && store.load() == null) {
throw new IOException("Reminder checkpoint was removed while running");
}
recoveryRequested.set(false);
Settings current = settings;
Candidate candidate = state.propose(nowNanos, !finalCheckpoint && current.enabled(),
current.autoCreateEnabled(), current.afterMillis(), current.repeatMillis());
try {
store.save(candidate.snapshot());
} catch (PublishedException uncertain) {
checkpointRequired = true;
throw uncertain;
}
checkpointRequired = true;
state.commit(candidate);
// Do not publish a stale warning if shutdown or configuration changed during I/O.
if (candidate.reminderDue() && closing.get() == null && current.equals(settings)) {
logger.warning("After voting-site setup, set AutoCreateVoteSites: false in Config.yml.");
}
} catch (IOException | RuntimeException failure) {
failed = true;
throw failure;
}
}
/** Cancel promptly; enqueue the final checkpoint without blocking the server thread. */
public CompletableFuture<Void> closeAsync() {
CloseRequest request;
synchronized (clockGate) {
CloseRequest existing = closing.get();
if (existing != null) return existing.result();
request = new CloseRequest(new CompletableFuture<>(), clock.getAsLong());
closing.set(request);
}
CompletableFuture<Void> result = request.result();
long stoppedNanos = request.stoppedNanos();
ScheduledFuture<?> current = task;
if (current != null) current.cancel(false);
if (!started.get()) {
result.complete(null);
return result;
}
try {
executor.execute(() -> {
Exception failure = null;
try {
checkpoint(stoppedNanos, true);
} catch (IOException | RuntimeException checkpointFailure) {
failure = checkpointFailure;
} finally {
if (store != null) {
try {
store.releaseLease();
} catch (IOException | RuntimeException cleanupFailure) {
if (failure == null) failure = cleanupFailure;
else failure.addSuppressed(cleanupFailure);
}
}
}
if (failure == null) result.complete(null);
else {
logger.warning("AutoCreateVoteSites reminder checkpoint or cleanup did not complete; "
+ "inspect its storage before reloading.");
result.completeExceptionally(failure);
}
});
} catch (RuntimeException rejected) {
result.completeExceptionally(rejected);
}
return result;
}
}
@@ -0,0 +1,240 @@
package com.bencodez.votingplugin.maintenance;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.OpenOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.PosixFileAttributeView;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Objects;
import java.util.Set;
import com.bencodez.votingplugin.core.maintenance.AutoCreateVoteSitesReminderState.Snapshot;
import com.bencodez.votingplugin.util.DurableFiles;
/**
* A fixed, checksummed active-runtime checkpoint in the plugin's existing data
* directory. One reminder worker owns all load/save operations. This store never
* creates the directory, repairs corrupt records, or follows a record symlink.
*/
public final class AutoCreateVoteSitesReminderStore {
private static final String FILE_NAME = ".auto-create-sites-reminder.dat";
private static final byte[] MAGIC = "VPACR001".getBytes(StandardCharsets.US_ASCII);
private static final int HEADER_BYTES = 24;
private static final int RECORD_BYTES = 56;
private final Path directory;
private final Path target;
private final Publisher publisher;
private FileChannel leaseChannel;
private FileLock lease;
@FunctionalInterface
interface Publisher {
void publish(Path staged, Path target) throws IOException;
}
public AutoCreateVoteSitesReminderStore(Path dataDirectory) throws IOException {
this(dataDirectory, DurableFiles::publishStagedFile);
}
AutoCreateVoteSitesReminderStore(Path dataDirectory, Publisher publisher) throws IOException {
this.directory = Objects.requireNonNull(dataDirectory, "dataDirectory").toAbsolutePath().normalize();
this.target = directory.resolve(FILE_NAME);
this.publisher = Objects.requireNonNull(publisher, "publisher");
validateDirectory();
}
/**
* Nonblocking admission across plugin instances/class loaders in one JVM, held
* through final publication. Multiple JVMs sharing the directory are unsupported:
* closing another channel may release native locks on some platforms.
*/
public boolean tryAcquireLease() throws IOException {
if (lease != null && lease.isValid()) return true;
validateDirectory();
Path lockPath = directory.resolve(".auto-create-sites-reminder.lock");
try {
BasicFileAttributes attributes = Files.readAttributes(lockPath, BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS);
if (!attributes.isRegularFile() || attributes.isSymbolicLink() || attributes.size() != 0) {
throw new IOException("Invalid reminder ownership file");
}
} catch (NoSuchFileException absent) {
// Create only this fixed empty metadata file inside the checked data directory.
}
Set<OpenOption> options = Set.of(StandardOpenOption.CREATE, StandardOpenOption.WRITE,
LinkOption.NOFOLLOW_LINKS);
FileChannel channel = Files.getFileAttributeView(directory, PosixFileAttributeView.class,
LinkOption.NOFOLLOW_LINKS) == null ? FileChannel.open(lockPath, options)
: FileChannel.open(lockPath, options,
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
boolean acquired = false;
try {
if (channel.size() != 0) throw new IOException("Invalid reminder ownership file");
FileLock candidate;
try {
candidate = channel.tryLock();
} catch (OverlappingFileLockException busy) {
return false;
}
if (candidate == null) return false;
leaseChannel = channel;
lease = candidate;
acquired = true;
return true;
} finally {
if (!acquired) channel.close();
}
}
/** Closing the channel releases its lock; keep the fixed lock file for successors. */
public void releaseLease() throws IOException {
FileChannel channel = leaseChannel;
leaseChannel = null;
lease = null;
if (channel != null) channel.close();
}
/** Returns null only when the record is absent from a valid existing directory. */
public Snapshot load() throws IOException {
validateDirectory();
try {
return readRecord(target);
} catch (NoSuchFileException absent) {
// A missing/replaced data directory is a failure, not an absent checkpoint.
validateDirectory();
if (Files.notExists(target, LinkOption.NOFOLLOW_LINKS)) {
return null;
}
throw absent;
}
}
/**
* Publish a checked same-directory staged record using DurableFiles. Existing
* invalid or unreadable records block replacement. PublishedException propagates
* unchanged: the new record may already be visible, so no rollback is attempted.
*/
public void save(Snapshot snapshot) throws IOException {
Objects.requireNonNull(snapshot, "snapshot");
load();
Path staged = createStagedFile();
Throwable failure = null;
try {
ByteBuffer record = ByteBuffer.wrap(encode(snapshot));
try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING, LinkOption.NOFOLLOW_LINKS)) {
while (record.hasRemaining()) {
if (channel.write(record) <= 0) {
throw new IOException("Could not write reminder checkpoint");
}
}
}
if (!snapshot.equals(readRecord(staged))) {
throw new IOException("Staged reminder checkpoint did not match");
}
// Recheck after staging, before replacing any existing destination.
load();
publisher.publish(staged, target);
} catch (IOException | RuntimeException | Error primary) {
failure = primary;
throw primary;
} finally {
try {
Files.deleteIfExists(staged);
} catch (IOException | RuntimeException cleanup) {
if (failure != null) {
failure.addSuppressed(cleanup);
} else {
throw new DurableFiles.PublishedException(cleanup instanceof IOException io ? io
: new IOException("Reminder staging cleanup failed after publication", cleanup));
}
}
}
}
private void validateDirectory() throws IOException {
BasicFileAttributes attributes = Files.readAttributes(directory, BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS);
if (!attributes.isDirectory() || attributes.isSymbolicLink()) {
throw new IOException("Reminder data directory must be an existing directory");
}
}
private Path createStagedFile() throws IOException {
if (Files.getFileAttributeView(directory, PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS) != null) {
return Files.createTempFile(directory, ".auto-create-sites-reminder-", ".tmp",
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
}
return Files.createTempFile(directory, ".auto-create-sites-reminder-", ".tmp");
}
private static Snapshot readRecord(Path recordPath) throws IOException {
BasicFileAttributes attributes = Files.readAttributes(recordPath, BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS);
if (!attributes.isRegularFile() || attributes.isSymbolicLink() || attributes.size() != RECORD_BYTES) {
throw new IOException("Invalid reminder checkpoint file");
}
ByteBuffer record = ByteBuffer.allocate(RECORD_BYTES);
try (FileChannel channel = FileChannel.open(recordPath, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
if (channel.size() != RECORD_BYTES) {
throw new IOException("Invalid reminder checkpoint length");
}
while (record.hasRemaining()) {
if (channel.read(record) <= 0) {
throw new IOException("Incomplete reminder checkpoint");
}
}
if (channel.size() != RECORD_BYTES || channel.read(ByteBuffer.allocate(1)) != -1) {
throw new IOException("Trailing reminder checkpoint data");
}
}
return decode(record.array());
}
private static byte[] encode(Snapshot snapshot) {
ByteBuffer record = ByteBuffer.allocate(RECORD_BYTES).order(ByteOrder.BIG_ENDIAN);
record.put(MAGIC).putLong(snapshot.totalRuntimeMillis()).putLong(snapshot.lastReminderRuntimeMillis());
record.put(digest(Arrays.copyOf(record.array(), HEADER_BYTES)));
return record.array();
}
private static Snapshot decode(byte[] record) throws IOException {
if (!Arrays.equals(MAGIC, Arrays.copyOf(record, MAGIC.length))) {
throw new IOException("Invalid reminder checkpoint schema");
}
byte[] expected = digest(Arrays.copyOf(record, HEADER_BYTES));
if (!MessageDigest.isEqual(expected, Arrays.copyOfRange(record, HEADER_BYTES, RECORD_BYTES))) {
throw new IOException("Invalid reminder checkpoint checksum");
}
ByteBuffer header = ByteBuffer.wrap(record).order(ByteOrder.BIG_ENDIAN);
header.position(MAGIC.length);
try {
return new Snapshot(header.getLong(), header.getLong());
} catch (IllegalArgumentException invalid) {
throw new IOException("Invalid reminder checkpoint counters", invalid);
}
}
private static byte[] digest(byte[] header) {
try {
return MessageDigest.getInstance("SHA-256").digest(header);
} catch (NoSuchAlgorithmException unavailable) {
throw new IllegalStateException("SHA-256 is unavailable", unavailable);
}
}
}
@@ -0,0 +1,66 @@
package com.bencodez.votingplugin.neoforge;
import java.util.Objects;
import java.util.UUID;
/** Immutable complete-vote input retained until NeoForge can finish its effects. */
public record NeoForgeDeferredVote(UUID voteId, UUID playerId, String playerName,
String serviceSite, String siteKey, long voteTime, boolean realVote,
boolean addTotals, boolean wasOnline, NeoForgeVoteAccountingDecision accountingDecision,
boolean quarantined, boolean releaseRequested) {
public NeoForgeDeferredVote {
Objects.requireNonNull(voteId, "voteId");
Objects.requireNonNull(playerId, "playerId");
Objects.requireNonNull(playerName, "playerName");
Objects.requireNonNull(serviceSite, "serviceSite");
Objects.requireNonNull(siteKey, "siteKey");
}
public NeoForgeDeferredVote(UUID voteId, UUID playerId, String playerName,
String serviceSite, String siteKey, long voteTime, boolean realVote,
boolean addTotals, boolean wasOnline) {
this(voteId, playerId, playerName, serviceSite, siteKey, voteTime,
realVote, addTotals, wasOnline, null, false);
}
public NeoForgeDeferredVote(UUID voteId, UUID playerId, String playerName,
String serviceSite, String siteKey, long voteTime, boolean realVote,
boolean addTotals, boolean wasOnline, NeoForgeVoteAccountingDecision accountingDecision) {
this(voteId, playerId, playerName, serviceSite, siteKey, voteTime,
realVote, addTotals, wasOnline, accountingDecision, false);
}
public NeoForgeDeferredVote(UUID voteId, UUID playerId, String playerName,
String serviceSite, String siteKey, long voteTime, boolean realVote,
boolean addTotals, boolean wasOnline, NeoForgeVoteAccountingDecision decision,
boolean quarantined) {
this(voteId, playerId, playerName, serviceSite, siteKey, voteTime,
realVote, addTotals, wasOnline, decision, quarantined, false);
}
NeoForgeDeferredVote releaseRequestedCopy() {
if (releaseRequested) return this;
return new NeoForgeDeferredVote(voteId, playerId, playerName, serviceSite, siteKey,
voteTime, realVote, addTotals, wasOnline, accountingDecision, quarantined, true);
}
NeoForgeDeferredVote withReplayContext(String currentName, String configuredServiceSite) {
Objects.requireNonNull(currentName, "currentName");
Objects.requireNonNull(configuredServiceSite, "configuredServiceSite");
if (playerName.equals(currentName) && serviceSite.equals(configuredServiceSite)) return this;
return new NeoForgeDeferredVote(voteId, playerId, currentName, configuredServiceSite, siteKey,
voteTime, realVote, addTotals, wasOnline, accountingDecision, quarantined, releaseRequested);
}
NeoForgeDeferredVote quarantinedCopy() {
if (quarantined) return this;
return new NeoForgeDeferredVote(voteId, playerId, playerName, serviceSite, siteKey,
voteTime, realVote, addTotals, wasOnline, accountingDecision, true, releaseRequested);
}
NeoForgeDeferredVote replayableCopy() {
if (!quarantined) return this;
return new NeoForgeDeferredVote(voteId, playerId, playerName, serviceSite, siteKey,
voteTime, realVote, addTotals, wasOnline, accountingDecision, false, releaseRequested);
}
}
@@ -0,0 +1,870 @@
package com.bencodez.votingplugin.neoforge;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKey;
import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyString;
import com.bencodez.advancedcore.core.user.storage.SqlUserStorage;
import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend;
import com.bencodez.advancedcore.core.user.storage.sql.SqliteUserBackend;
import com.bencodez.simpleapi.sql.Column;
import com.bencodez.simpleapi.sql.data.DataValue;
import com.bencodez.simpleapi.sql.data.DataValueString;
import com.bencodez.votingplugin.core.vote.SharedVoteIdentity;
import com.bencodez.votingplugin.core.vote.SharedVoteInput;
/** Ordered, bounded storage for complete votes and their completion receipts. */
public final class NeoForgeDeferredVoteStore {
private static final Logger LOGGER = Logger.getLogger(NeoForgeDeferredVoteStore.class.getName());
static final String DEFERRED_VOTES = "DeferredVotes";
static final String COMPLETED_DEFERRED_VOTES = "CompletedDeferredVotes";
static final int MAX_DEFERRED_PER_USER = 64;
static final int MAX_DEFERRED_TOTAL = 4_096;
static final int MAX_COMPLETED_PER_USER = 4_096;
static final int MAX_COMPLETED_TOTAL = 262_144;
private static final String VERSION = "v1";
private static final String RELEASED_VERSION = "v2";
private static final int MAX_RELEASED_PER_USER = 4_096;
private static final int MAX_RELEASED_TOTAL = 262_144;
private static final long RELEASED_RETENTION_MILLIS = TimeUnit.DAYS.toMillis(7);
private static final String PENDING_VERSION = "v2";
private static final String QUARANTINED_PENDING_VERSION = "v3";
private static final String RELEASE_REQUESTED_PENDING_VERSION = "v4";
private static final String PENDING_INDEX = "VotingPlugin_NeoForgeDeferredPending";
private static final String COMPLETED_INDEX = "VotingPlugin_NeoForgeDeferredCompleted";
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder DECODER = Base64.getUrlDecoder();
private final SqlUserBackend backend;
private final int perUserLimit;
private final int totalLimit;
private final int completedPerUserLimit;
private final int completedTotalLimit;
private final int releasedPerUserLimit;
private final int releasedTotalLimit;
private final Set<OccurrenceKey> activeClaims = new HashSet<>();
private final Set<UUID> replayCandidates = new LinkedHashSet<>();
private final Map<UUID, Integer> receiptReservationsByUser = new HashMap<>();
private int receiptReservationsTotal;
private final Map<UUID, Integer> releasedReservationsByUser = new HashMap<>();
private int releasedReservationsTotal;
private int retainedCount = -1;
private int completedCount = -1;
private int releasedCount = -1;
private boolean replayCandidatesInitialized;
private volatile boolean rowIndexesInitialized;
NeoForgeDeferredVoteStore(SqlUserBackend backend) {
this(backend, MAX_DEFERRED_PER_USER, MAX_DEFERRED_TOTAL,
MAX_COMPLETED_PER_USER, MAX_COMPLETED_TOTAL);
}
NeoForgeDeferredVoteStore(SqlUserBackend backend, int perUserLimit, int totalLimit) {
this(backend, perUserLimit, totalLimit,
MAX_COMPLETED_PER_USER, MAX_COMPLETED_TOTAL);
}
NeoForgeDeferredVoteStore(SqlUserBackend backend, int perUserLimit, int totalLimit,
int completedPerUserLimit, int completedTotalLimit) {
this(backend, perUserLimit, totalLimit, completedPerUserLimit, completedTotalLimit,
MAX_RELEASED_PER_USER);
}
NeoForgeDeferredVoteStore(SqlUserBackend backend, int perUserLimit, int totalLimit,
int completedPerUserLimit, int completedTotalLimit, int releasedPerUserLimit) {
this(backend, perUserLimit, totalLimit, completedPerUserLimit, completedTotalLimit,
releasedPerUserLimit, MAX_RELEASED_TOTAL);
}
NeoForgeDeferredVoteStore(SqlUserBackend backend, int perUserLimit, int totalLimit,
int completedPerUserLimit, int completedTotalLimit, int releasedPerUserLimit,
int releasedTotalLimit) {
this.backend = Objects.requireNonNull(backend, "backend");
if (perUserLimit <= 0 || totalLimit <= 0
|| completedPerUserLimit <= 0 || completedTotalLimit <= 0
|| releasedPerUserLimit <= 0 || releasedTotalLimit <= 0) {
throw new IllegalArgumentException("limits must be positive");
}
this.perUserLimit = perUserLimit;
this.totalLimit = totalLimit;
this.completedPerUserLimit = completedPerUserLimit;
this.completedTotalLimit = completedTotalLimit;
this.releasedPerUserLimit = releasedPerUserLimit;
this.releasedTotalLimit = releasedTotalLimit;
}
static List<UserDataKey> storageKeys() {
return List.of(new UserDataKeyString(DEFERRED_VOTES).setColumnType("MEDIUMTEXT"),
new UserDataKeyString(COMPLETED_DEFERRED_VOTES).setColumnType("MEDIUMTEXT"));
}
synchronized DeferralResult defer(SharedVoteIdentity identity, SharedVoteInput input, NeoForgeVoteSite site,
NeoForgeVoteAccountingDecision accountingDecision) {
Objects.requireNonNull(identity, "identity");
Objects.requireNonNull(input, "input");
Objects.requireNonNull(site, "site");
Objects.requireNonNull(accountingDecision, "accountingDecision");
NeoForgeDeferredVote vote = new NeoForgeDeferredVote(input.voteId(), identity.uuid(), identity.playerName(),
input.serviceSite(), site.key(), input.voteTime(), input.realVote(), input.addTotals(), input.wasOnline(),
accountingDecision);
SqlUserStorage user = backend.user(identity.uuid());
Map<String, DataValue> existingRow = row(user.readRow(backend.storageType()));
List<CompletionReceipt> existingCompleted = parseCompleted(value(existingRow, COMPLETED_DEFERRED_VOTES));
if (containsReceipt(existingCompleted, vote.voteId())) {
reconcileCompleted(identity.uuid(), vote.voteId());
return new DeferralResult(Status.ALREADY_COMPLETED, List.of());
}
List<NeoForgeDeferredVote> existingPending = parsePending(value(existingRow, DEFERRED_VOTES), identity.uuid());
if (existingPending.stream().anyMatch(queued -> queued.voteId().equals(vote.voteId()))) {
return new DeferralResult(Status.ALREADY_RETAINED, List.copyOf(existingPending));
}
ensureCounts();
if (existingPending.size() >= perUserLimit || retainedCount >= totalLimit) {
return new DeferralResult(Status.CAPACITY_REACHED, List.copyOf(existingPending));
}
Mutation<DeferralResult> mutation = user.transaction(backend.storageType(), Map.of(), scope -> {
Map<String, DataValue> row = row(scope.readRow());
List<CompletionReceipt> completed = parseCompleted(value(row, COMPLETED_DEFERRED_VOTES));
if (containsReceipt(completed, vote.voteId())) {
return new Mutation<>(new DeferralResult(Status.ALREADY_COMPLETED, List.of()), 0, 0);
}
List<NeoForgeDeferredVote> pending = parsePending(value(row, DEFERRED_VOTES), identity.uuid());
if (pending.stream().anyMatch(queued -> queued.voteId().equals(vote.voteId()))) {
return new Mutation<>(new DeferralResult(Status.ALREADY_RETAINED, List.copyOf(pending)), 0, 0);
}
if (pending.size() >= perUserLimit || retainedCount >= totalLimit) {
return new Mutation<>(new DeferralResult(Status.CAPACITY_REACHED, List.copyOf(pending)), 0, 0);
}
pending.add(vote);
scope.writeValues(Map.of(
NeoForgeVoteAccountingStore.PLAYER_NAME, new DataValueString(identity.playerName()),
DEFERRED_VOTES, new DataValueString(serializePending(pending)),
COMPLETED_DEFERRED_VOTES, new DataValueString(serializeCompleted(completed))));
return new Mutation<>(new DeferralResult(Status.RETAINED, List.copyOf(pending)), 1, 0);
});
applyCounts(mutation);
if (mutation.value().status() == Status.RETAINED
|| mutation.value().status() == Status.ALREADY_RETAINED) {
replayCandidates.add(identity.uuid());
}
if (mutation.value().status() == Status.ALREADY_COMPLETED) {
reconcileCompleted(identity.uuid(), vote.voteId());
}
return mutation.value();
}
/** Returns the retained votes in admission order for one player. */
public synchronized List<NeoForgeDeferredVote> pending(UUID playerId) {
Objects.requireNonNull(playerId, "playerId");
List<NeoForgeDeferredVote> pending = readState(backend.user(playerId), playerId).pending();
if (pending.isEmpty()) replayCandidates.remove(playerId);
else replayCandidates.add(playerId);
return List.copyOf(pending);
}
/** Returns the cached replay candidates, discovering only durable replay rows after restart. */
public synchronized List<UUID> users() {
initializeDurableState();
return List.copyOf(replayCandidates);
}
public synchronized OccurrenceState state(UUID playerId, UUID voteId) {
Objects.requireNonNull(playerId, "playerId");
Objects.requireNonNull(voteId, "voteId");
Map<String, DataValue> row = row(backend.user(playerId).readRow(backend.storageType()));
if (containsReceipt(parseCompleted(value(row, COMPLETED_DEFERRED_VOTES)), voteId)) {
reconcileCompleted(playerId, voteId);
return OccurrenceState.COMPLETED;
}
return parsePending(value(row, DEFERRED_VOTES), playerId).stream()
.anyMatch(vote -> vote.voteId().equals(voteId))
? OccurrenceState.PENDING : OccurrenceState.UNKNOWN;
}
/**
* Claims one pending occurrence only when its completion receipt has capacity.
* A process crash releases the in-memory reservation while leaving the durable
* pending payload unchanged; the next claim checks durable capacity again.
*/
public synchronized Optional<Claim> claim(UUID playerId, UUID voteId) {
return claimForReplay(playerId, voteId).optionalClaim();
}
synchronized ClaimAttempt claimForReplay(UUID playerId, UUID voteId) {
Objects.requireNonNull(playerId, "playerId");
Objects.requireNonNull(voteId, "voteId");
OccurrenceKey key = new OccurrenceKey(playerId, voteId);
if (activeClaims.contains(key)) return new ClaimAttempt(ClaimStatus.ACTIVE, null);
Map<String, DataValue> row = row(backend.user(playerId).readRow(backend.storageType()));
List<CompletionReceipt> completed = parseCompleted(value(row, COMPLETED_DEFERRED_VOTES));
if (containsReceipt(completed, voteId)) {
reconcileCompleted(playerId, voteId);
return new ClaimAttempt(ClaimStatus.ALREADY_COMPLETED, null);
}
Optional<NeoForgeDeferredVote> vote = parsePending(value(row, DEFERRED_VOTES), playerId).stream()
.filter(candidate -> candidate.voteId().equals(voteId)).findFirst();
if (vote.isEmpty()) return new ClaimAttempt(ClaimStatus.NOT_PENDING, null);
if (vote.get().quarantined()) return new ClaimAttempt(ClaimStatus.QUARANTINED, null);
ensureCounts();
boolean released = vote.get().releaseRequested();
if (released) {
if (releasedCount >= releasedTotalLimit) refreshReleasedReceiptCountAndPrune();
if (releasedReceiptCount(completed) + releasedReservationsByUser.getOrDefault(playerId, 0)
>= releasedPerUserLimit || releasedCount + releasedReservationsTotal >= releasedTotalLimit)
return new ClaimAttempt(ClaimStatus.RECEIPT_CAPACITY_REACHED, null);
} else if (activeReceiptCount(completed) + receiptReservationsByUser.getOrDefault(playerId, 0)
>= completedPerUserLimit || completedCount + receiptReservationsTotal >= completedTotalLimit) {
return new ClaimAttempt(ClaimStatus.RECEIPT_CAPACITY_REACHED, null);
}
activeClaims.add(key);
if (released) {
releasedReservationsByUser.merge(playerId, 1, Integer::sum);
releasedReservationsTotal++;
} else {
receiptReservationsByUser.merge(playerId, 1, Integer::sum);
receiptReservationsTotal++;
}
return new ClaimAttempt(ClaimStatus.CLAIMED, new Claim(key, vote.get()));
}
/** Durably prevents automatic replay after an external effect may have happened. */
synchronized QuarantineResult quarantine(UUID playerId, UUID voteId) {
Objects.requireNonNull(playerId, "playerId");
Objects.requireNonNull(voteId, "voteId");
OccurrenceKey key = new OccurrenceKey(playerId, voteId);
if (activeClaims.contains(key)) {
throw new IllegalStateException("Cannot quarantine an active deferred vote claim");
}
QuarantineResult result = backend.user(playerId).transaction(backend.storageType(), Map.of(), scope -> {
Map<String, DataValue> row = row(scope.readRow());
if (containsReceipt(parseCompleted(value(row, COMPLETED_DEFERRED_VOTES)), voteId)) {
return QuarantineResult.ALREADY_COMPLETED;
}
List<NeoForgeDeferredVote> pending = parsePending(value(row, DEFERRED_VOTES), playerId);
for (int index = 0; index < pending.size(); index++) {
NeoForgeDeferredVote vote = pending.get(index);
if (!vote.voteId().equals(voteId)) continue;
if (vote.quarantined()) return QuarantineResult.ALREADY_QUARANTINED;
pending.set(index, vote.quarantinedCopy());
scope.writeValues(Map.of(DEFERRED_VOTES, new DataValueString(serializePending(pending))));
return QuarantineResult.QUARANTINED;
}
return QuarantineResult.NOT_PENDING;
});
if (result == QuarantineResult.ALREADY_COMPLETED) reconcileCompleted(playerId, voteId);
return result;
}
private synchronized FenceResult setExternalEffectFence(Claim claim, boolean fenced) {
Objects.requireNonNull(claim, "claim");
if (claim.owner != this || claim.closed || !activeClaims.contains(claim.key)) {
throw new IllegalStateException("Deferred vote claim is no longer active");
}
return backend.user(claim.key.playerId()).transaction(backend.storageType(), Map.of(), scope -> {
Map<String, DataValue> row = row(scope.readRow());
if (containsReceipt(parseCompleted(value(row, COMPLETED_DEFERRED_VOTES)), claim.key.voteId())) {
return FenceResult.ALREADY_COMPLETED;
}
List<NeoForgeDeferredVote> pending = parsePending(value(row, DEFERRED_VOTES), claim.key.playerId());
for (int index = 0; index < pending.size(); index++) {
NeoForgeDeferredVote vote = pending.get(index);
if (!vote.voteId().equals(claim.key.voteId())) continue;
if (vote.quarantined() == fenced) {
return fenced ? FenceResult.ALREADY_FENCED : FenceResult.ALREADY_REPLAYABLE;
}
pending.set(index, fenced ? vote.quarantinedCopy() : vote.replayableCopy());
scope.writeValues(Map.of(DEFERRED_VOTES, new DataValueString(serializePending(pending))));
return fenced ? FenceResult.FENCED : FenceResult.REPLAYABLE;
}
return FenceResult.NOT_PENDING;
});
}
private synchronized CompletionOutcome complete(Claim claim, NeoForgeVoteAccountingStore accounting,
NeoForgeVoteSite site, boolean currentlyOnline, String currentPlayerName) {
Objects.requireNonNull(claim, "claim");
if (claim.owner != this || claim.closed || !activeClaims.contains(claim.key)) {
throw new IllegalStateException("Deferred vote claim is no longer active");
}
ensureCounts();
Mutation<CompletionOutcome> mutation = backend.user(claim.key.playerId()).transaction(
backend.storageType(), Map.of(), scope -> {
List<Column> lockedRow = scope.readRow();
Map<String, DataValue> row = row(lockedRow);
List<CompletionReceipt> completed = parseCompleted(value(row, COMPLETED_DEFERRED_VOTES));
if (containsReceipt(completed, claim.key.voteId())) {
List<NeoForgeDeferredVote> pending;
try {
pending = parsePending(value(row, DEFERRED_VOTES), claim.key.playerId());
} catch (MalformedDeferredVoteData malformedPending) {
return new Mutation<>(new CompletionOutcome(CompletionResult.ALREADY_COMPLETED, null), 0, 0);
}
int removed = removePending(pending, claim.key.voteId());
if (removed > 0) {
scope.writeValues(Map.of(DEFERRED_VOTES, new DataValueString(serializePending(pending))));
}
return new Mutation<>(new CompletionOutcome(CompletionResult.ALREADY_COMPLETED, null), -removed, 0);
}
List<NeoForgeDeferredVote> pending = parsePending(value(row, DEFERRED_VOTES), claim.key.playerId());
boolean isPending = pending.stream().anyMatch(vote -> vote.voteId().equals(claim.key.voteId()));
if (!isPending) {
return new Mutation<>(new CompletionOutcome(CompletionResult.NOT_PENDING, null), 0, 0);
}
if (claim.vote.releaseRequested()
? releasedReceiptCount(completed) >= releasedPerUserLimit || releasedCount >= releasedTotalLimit
: activeReceiptCount(completed) >= completedPerUserLimit || completedCount >= completedTotalLimit) {
return new Mutation<>(new CompletionOutcome(CompletionResult.RECEIPT_CAPACITY_REACHED, null), 0, 0);
}
NeoForgeVoteAccountingStore.PreparedAccounting prepared = accounting == null ? null
: accounting.prepareDeferred(claim.vote, site, lockedRow, currentlyOnline,
currentPlayerName);
completed.add(new CompletionReceipt(claim.key.voteId(),
claim.vote.releaseRequested() ? System.currentTimeMillis() : 0L));
int removed = removePending(pending, claim.key.voteId());
HashMap<String, DataValue> updates = new HashMap<>();
if (prepared != null) updates.putAll(prepared.updates());
updates.put(DEFERRED_VOTES, new DataValueString(serializePending(pending)));
updates.put(COMPLETED_DEFERRED_VOTES, new DataValueString(serializeCompleted(completed)));
scope.writeValues(updates);
return new Mutation<>(new CompletionOutcome(CompletionResult.COMPLETED,
prepared == null ? null : prepared.account()), -removed,
claim.vote.releaseRequested() ? 0 : 1, claim.vote.releaseRequested() ? 1 : 0);
});
applyCounts(mutation);
if (mutation.value().result() != CompletionResult.RECEIPT_CAPACITY_REACHED) {
releaseReservation(claim);
}
return mutation.value();
}
synchronized ReleaseResult release(UUID playerId, UUID voteId) {
Objects.requireNonNull(playerId, "playerId");
Objects.requireNonNull(voteId, "voteId");
boolean expiredTarget = parseCompleted(value(row(backend.user(playerId)
.readRow(backend.storageType())), COMPLETED_DEFERRED_VOTES)).stream()
.anyMatch(receipt -> receipt.voteId().equals(voteId) && receipt.released() && receipt.expired());
ensureCounts();
if (releasedCount >= releasedTotalLimit) refreshReleasedReceiptCountAndPrune();
ReleaseMutation mutation = backend.user(playerId).transaction(
backend.storageType(), Map.of(), scope -> {
Map<String, DataValue> lockedRow = row(scope.readRow());
List<CompletionReceipt> completed = parseCompleted(value(lockedRow, COMPLETED_DEFERRED_VOTES));
for (int index = 0; index < completed.size(); index++) {
CompletionReceipt receipt = completed.get(index);
if (!receipt.voteId().equals(voteId)) continue;
if (receipt.released()) {
if (receipt.expired()) {
completed.removeIf(candidate -> candidate.released() && candidate.expired());
if (releasedReceiptCount(completed) + releasedReservationsByUser.getOrDefault(playerId, 0) >= releasedPerUserLimit
|| releasedCount + releasedReservationsTotal >= releasedTotalLimit) {
scope.writeValues(Map.of(COMPLETED_DEFERRED_VOTES,
new DataValueString(serializeCompleted(completed))));
return new ReleaseMutation(ReleaseResult.RELEASE_CAPACITY_REACHED, 0, 0);
}
completed.add(new CompletionReceipt(voteId, System.currentTimeMillis()));
scope.writeValues(Map.of(COMPLETED_DEFERRED_VOTES,
new DataValueString(serializeCompleted(completed))));
return new ReleaseMutation(ReleaseResult.ALREADY_RELEASED, 0, 1);
}
return new ReleaseMutation(ReleaseResult.ALREADY_RELEASED, 0, 0);
}
completed.removeIf(candidate -> candidate.released() && candidate.expired());
if (releasedReceiptCount(completed) + releasedReservationsByUser.getOrDefault(playerId, 0) >= releasedPerUserLimit
|| releasedCount + releasedReservationsTotal >= releasedTotalLimit) {
scope.writeValues(Map.of(COMPLETED_DEFERRED_VOTES,
new DataValueString(serializeCompleted(completed))));
return new ReleaseMutation(ReleaseResult.RELEASE_CAPACITY_REACHED, 0, 0);
}
index = completed.indexOf(receipt);
completed.set(index, new CompletionReceipt(voteId, System.currentTimeMillis()));
scope.writeValues(Map.of(COMPLETED_DEFERRED_VOTES,
new DataValueString(serializeCompleted(completed))));
return new ReleaseMutation(ReleaseResult.RELEASED, -1, 1);
}
if (expiredTarget) {
if (releasedReceiptCount(completed) + releasedReservationsByUser.getOrDefault(playerId, 0) >= releasedPerUserLimit
|| releasedCount + releasedReservationsTotal >= releasedTotalLimit) {
return new ReleaseMutation(ReleaseResult.RELEASE_CAPACITY_REACHED, 0, 0);
}
completed.add(new CompletionReceipt(voteId, System.currentTimeMillis()));
scope.writeValues(Map.of(COMPLETED_DEFERRED_VOTES,
new DataValueString(serializeCompleted(completed))));
return new ReleaseMutation(ReleaseResult.ALREADY_RELEASED, 0, 1);
}
// Durable acceptance already transferred ownership to this inbox.
// Acknowledge retirement without falsely marking effects completed.
if (!activeClaims.contains(new OccurrenceKey(playerId, voteId))) {
List<NeoForgeDeferredVote> pending = parsePending(value(lockedRow, DEFERRED_VOTES), playerId);
for (int i = 0; i < pending.size(); i++) {
NeoForgeDeferredVote vote = pending.get(i);
if (!vote.voteId().equals(voteId)) continue;
if (vote.releaseRequested()) return new ReleaseMutation(ReleaseResult.ALREADY_RELEASED, 0, 0);
pending.set(i, vote.releaseRequestedCopy());
scope.writeValues(Map.of(DEFERRED_VOTES, new DataValueString(serializePending(pending))));
return new ReleaseMutation(ReleaseResult.RELEASED, 0, 0);
}
}
return new ReleaseMutation(ReleaseResult.NOT_COMPLETED, 0, 0);
});
completedCount = Math.max(0, completedCount + mutation.completedDelta());
releasedCount = Math.max(0, releasedCount + mutation.releasedDelta());
return mutation.result();
}
private void releaseReservation(Claim claim) {
if (!claim.receiptReserved) return;
claim.receiptReserved = false;
if (claim.vote.releaseRequested()) {
releasedReservationsTotal--;
releasedReservationsByUser.compute(claim.key.playerId(), (playerId, reserved) ->
reserved == 1 ? null : reserved - 1);
} else {
receiptReservationsTotal--;
receiptReservationsByUser.compute(claim.key.playerId(), (playerId, reserved) ->
reserved == 1 ? null : reserved - 1);
}
}
private void reconcileCompleted(UUID playerId, UUID voteId) {
int removed = backend.user(playerId).transaction(backend.storageType(), Map.of(), scope -> {
Map<String, DataValue> row = row(scope.readRow());
List<CompletionReceipt> completed = parseCompleted(value(row, COMPLETED_DEFERRED_VOTES));
if (!containsReceipt(completed, voteId)) return 0;
List<NeoForgeDeferredVote> pending;
try {
pending = parsePending(value(row, DEFERRED_VOTES), playerId);
} catch (MalformedDeferredVoteData malformedPending) {
return 0;
}
int removedPending = removePending(pending, voteId);
if (removedPending > 0) {
scope.writeValues(Map.of(DEFERRED_VOTES, new DataValueString(serializePending(pending))));
}
return removedPending;
});
if (removed > 0) {
if (retainedCount >= 0) retainedCount = Math.max(0, retainedCount - removed);
else retainedCount = -1;
}
}
private static int removePending(List<NeoForgeDeferredVote> pending, UUID voteId) {
int before = pending.size();
pending.removeIf(vote -> vote.voteId().equals(voteId));
return before - pending.size();
}
private void applyCounts(Mutation<?> mutation) {
retainedCount = Math.max(0, retainedCount + mutation.pendingDelta());
completedCount = Math.max(0, completedCount + mutation.completedDelta());
releasedCount = Math.max(0, releasedCount + mutation.releasedDelta());
}
private void ensureCounts() {
if (retainedCount >= 0 && completedCount >= 0 && releasedCount >= 0) return;
initializeDurableState();
}
/**
* Loads only rows that can affect deferred replay or receipt capacity. NeoForge
* currently supports SQLite only, so this read-only query deliberately stays in
* the loader adapter instead of widening the shared SQL API for one consumer.
*/
private void initializeDurableState() {
if (replayCandidatesInitialized && retainedCount >= 0 && completedCount >= 0 && releasedCount >= 0) return;
if (!(backend instanceof SqliteUserBackend sqlite)) {
throw new IllegalStateException("NeoForge deferred votes require the SQLite user backend");
}
LinkedHashSet<UUID> discovered = new LinkedHashSet<>();
int pending = 0;
int completed = 0;
int malformedIdentityRows = 0;
String columns = "`UUID`, `" + DEFERRED_VOTES + "`, `" + COMPLETED_DEFERRED_VOTES + "`";
String table = "`" + NeoForgeRuntime.USER_TABLE_NAME + "`";
String pendingRows = "`" + DEFERRED_VOTES + "` IS NOT NULL AND `" + DEFERRED_VOTES + "` <> ''";
String completedRows = "`" + COMPLETED_DEFERRED_VOTES + "` IS NOT NULL AND `"
+ COMPLETED_DEFERRED_VOTES + "` <> ''";
String sql = "SELECT " + columns + " FROM " + table + " WHERE " + pendingRows
+ " UNION ALL SELECT " + columns + " FROM " + table + " WHERE " + completedRows
+ " AND (`" + DEFERRED_VOTES + "` IS NULL OR `" + DEFERRED_VOTES + "` = '') ORDER BY 1 ASC";
try (Connection connection = DriverManager.getConnection(
"jdbc:sqlite:" + sqlite.databaseFile().toAbsolutePath());
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet result = statement.executeQuery()) {
while (result.next()) {
UUID playerId;
try {
playerId = parseCanonicalUuid(result.getString(1));
} catch (IllegalStateException malformedIdentity) {
malformedIdentityRows++;
if (result.getString(2) != null && !result.getString(2).isEmpty()) {
pending = cappedAdd(pending, perUserLimit, totalLimit);
}
if (result.getString(3) != null && !result.getString(3).isEmpty()) {
completed = cappedAdd(completed, completedPerUserLimit, completedTotalLimit);
}
continue;
}
String pendingData = result.getString(2);
String completedData = result.getString(3);
if (pendingData != null && !pendingData.isEmpty()) {
discovered.add(playerId);
}
try {
pending = cappedAdd(pending, parsePending(pendingData, playerId).size(), totalLimit);
} catch (MalformedDeferredVoteData malformedPending) {
pending = cappedAdd(pending, perUserLimit, totalLimit);
}
try {
completed = cappedAdd(completed, activeReceiptCount(parseCompleted(completedData)),
completedTotalLimit);
} catch (MalformedDeferredVoteData malformedReceipts) {
completed = cappedAdd(completed, completedPerUserLimit, completedTotalLimit);
}
}
} catch (SQLException failure) {
throw new IllegalStateException("Failed to discover deferred NeoForge vote rows", failure);
}
if (malformedIdentityRows > 0) {
LOGGER.warning("Skipped " + malformedIdentityRows
+ " deferred NeoForge vote row(s) with malformed UUID identities");
}
replayCandidates.addAll(discovered);
replayCandidatesInitialized = true;
retainedCount = pending;
completedCount = completed;
releasedCount = refreshReleasedReceiptCountAndPrune();
}
private int refreshReleasedReceiptCountAndPrune() {
if (!(backend instanceof SqliteUserBackend sqlite)) {
throw new IllegalStateException("NeoForge deferred votes require the SQLite user backend");
}
ArrayList<UUID> expiredRows = new ArrayList<>();
int released = 0;
String table = "`" + NeoForgeRuntime.USER_TABLE_NAME + "`";
String sql = "SELECT `UUID`, `" + COMPLETED_DEFERRED_VOTES + "` FROM " + table
+ " WHERE `" + COMPLETED_DEFERRED_VOTES + "` IS NOT NULL AND `"
+ COMPLETED_DEFERRED_VOTES + "` <> ''";
try (Connection connection = DriverManager.getConnection(
"jdbc:sqlite:" + sqlite.databaseFile().toAbsolutePath());
PreparedStatement statement = connection.prepareStatement(sql);
ResultSet result = statement.executeQuery()) {
while (result.next()) {
UUID playerId;
try {
playerId = parseCanonicalUuid(result.getString(1));
List<CompletionReceipt> receipts = parseCompleted(result.getString(2));
int before = receipts.size();
receipts.removeIf(receipt -> receipt.released() && receipt.expired());
int live = releasedReceiptCount(receipts);
released = cappedAdd(released, Math.min(live, releasedPerUserLimit), releasedTotalLimit);
if (before != receipts.size() && live <= releasedPerUserLimit) expiredRows.add(playerId);
} catch (IllegalStateException malformed) {
released = cappedAdd(released, releasedPerUserLimit, releasedTotalLimit);
}
}
} catch (SQLException failure) {
throw new IllegalStateException("Failed to discover released NeoForge vote receipts", failure);
}
for (UUID playerId : expiredRows) {
backend.user(playerId).transaction(backend.storageType(), Map.of(), scope -> {
List<CompletionReceipt> receipts = parseCompleted(
value(row(scope.readRow()), COMPLETED_DEFERRED_VOTES));
int before = receipts.size();
receipts.removeIf(receipt -> receipt.released() && receipt.expired());
if (before != receipts.size()) {
scope.writeValues(Map.of(COMPLETED_DEFERRED_VOTES,
new DataValueString(serializeCompleted(receipts))));
}
return null;
});
}
releasedCount = released;
return released;
}
void initializeRelevantRowIndexes() {
if (rowIndexesInitialized) return;
if (!(backend instanceof SqliteUserBackend sqlite)) {
throw new IllegalStateException("NeoForge deferred votes require the SQLite user backend");
}
String table = "`" + NeoForgeRuntime.USER_TABLE_NAME + "`";
String pendingSql = "CREATE INDEX IF NOT EXISTS `" + PENDING_INDEX + "` ON " + table
+ " (`UUID`) WHERE `" + DEFERRED_VOTES + "` IS NOT NULL AND `" + DEFERRED_VOTES + "` <> ''";
String completedSql = "CREATE INDEX IF NOT EXISTS `" + COMPLETED_INDEX + "` ON " + table
+ " (`UUID`) WHERE `" + COMPLETED_DEFERRED_VOTES + "` IS NOT NULL AND `"
+ COMPLETED_DEFERRED_VOTES + "` <> ''";
try (Connection connection = DriverManager.getConnection(
"jdbc:sqlite:" + sqlite.databaseFile().toAbsolutePath());
PreparedStatement pending = connection.prepareStatement(pendingSql);
PreparedStatement completed = connection.prepareStatement(completedSql)) {
pending.executeUpdate();
completed.executeUpdate();
rowIndexesInitialized = true;
} catch (SQLException failure) {
throw new IllegalStateException("Failed to index deferred NeoForge vote rows", failure);
}
}
private static UUID parseCanonicalUuid(String stored) {
try {
UUID parsed = UUID.fromString(stored);
if (!parsed.toString().equals(stored)) throw new IllegalArgumentException("Non-canonical UUID");
return parsed;
} catch (IllegalArgumentException | NullPointerException invalid) {
throw new IllegalStateException("Malformed UUID in deferred NeoForge vote row", invalid);
}
}
private static int cappedAdd(int current, int added, int limit) {
return (int) Math.min(limit, (long) current + added);
}
private StoredState readState(SqlUserStorage user, UUID playerId) {
List<Column> columns = user.readRow(backend.storageType());
if (columns.isEmpty()) return new StoredState(new ArrayList<>(), new ArrayList<>());
Map<String, DataValue> row = row(columns);
return new StoredState(parsePending(value(row, DEFERRED_VOTES), playerId),
parseCompleted(value(row, COMPLETED_DEFERRED_VOTES)));
}
private static String serializePending(List<NeoForgeDeferredVote> votes) {
ArrayList<String> lines = new ArrayList<>(votes.size());
for (NeoForgeDeferredVote vote : votes) {
NeoForgeVoteAccountingDecision decision = vote.accountingDecision();
if (vote.quarantined() || vote.releaseRequested()) {
String line = String.join("|", vote.releaseRequested() ? RELEASE_REQUESTED_PENDING_VERSION : QUARANTINED_PENDING_VERSION, vote.voteId().toString(),
encode(vote.playerName()), encode(vote.serviceSite()), encode(vote.siteKey()),
Long.toString(vote.voteTime()), Boolean.toString(vote.realVote()),
Boolean.toString(vote.addTotals()), Boolean.toString(vote.wasOnline()),
Boolean.toString(vote.quarantined()), Boolean.toString(decision != null),
decision == null ? "" : Integer.toString(decision.total()),
decision == null ? "" : Integer.toString(decision.daily()),
decision == null ? "" : Integer.toString(decision.weekly()),
decision == null ? "" : Integer.toString(decision.points()),
decision == null ? "" : Boolean.toString(decision.pointsApplied()),
decision == null ? "" : Integer.toString(decision.pointLimit()));
lines.add(vote.releaseRequested() ? line + "|true" : line);
} else if (decision == null) {
lines.add(String.join("|", VERSION, vote.voteId().toString(), encode(vote.playerName()),
encode(vote.serviceSite()), encode(vote.siteKey()), Long.toString(vote.voteTime()),
Boolean.toString(vote.realVote()), Boolean.toString(vote.addTotals()),
Boolean.toString(vote.wasOnline())));
} else {
lines.add(String.join("|", PENDING_VERSION, vote.voteId().toString(), encode(vote.playerName()),
encode(vote.serviceSite()), encode(vote.siteKey()), Long.toString(vote.voteTime()),
Boolean.toString(vote.realVote()), Boolean.toString(vote.addTotals()),
Boolean.toString(vote.wasOnline()), Integer.toString(decision.total()),
Integer.toString(decision.daily()), Integer.toString(decision.weekly()),
Integer.toString(decision.points()), Boolean.toString(decision.pointsApplied()),
Integer.toString(decision.pointLimit())));
}
}
return String.join("\n", lines);
}
private static List<NeoForgeDeferredVote> parsePending(String stored, UUID playerId) {
ArrayList<NeoForgeDeferredVote> votes = new ArrayList<>();
if (stored == null || stored.isEmpty()) return votes;
for (String line : stored.split("\\n", -1)) {
String[] fields = line.split("\\|", -1);
boolean legacy = fields.length == 9 && VERSION.equals(fields[0]);
boolean versionTwo = fields.length == 15 && PENDING_VERSION.equals(fields[0]);
boolean versionFour = fields.length == 18 && RELEASE_REQUESTED_PENDING_VERSION.equals(fields[0]);
boolean versionThree = (fields.length == 17 && QUARANTINED_PENDING_VERSION.equals(fields[0])) || versionFour;
if (!legacy && !versionTwo && !versionThree) {
throw new MalformedDeferredVoteData("Unsupported or malformed deferred NeoForge vote data");
}
try {
if (versionFour && !parseBoolean(fields[17]))
throw new IllegalArgumentException("Missing pending receipt release flag");
boolean hasDecision = versionTwo || versionThree && parseBoolean(fields[10]);
int decisionOffset = versionThree ? 11 : 9;
NeoForgeVoteAccountingDecision decision = !hasDecision ? null
: new NeoForgeVoteAccountingDecision(Integer.parseInt(fields[decisionOffset]),
Integer.parseInt(fields[decisionOffset + 1]), Integer.parseInt(fields[decisionOffset + 2]),
Integer.parseInt(fields[decisionOffset + 3]), parseBoolean(fields[decisionOffset + 4]),
Integer.parseInt(fields[decisionOffset + 5]));
votes.add(new NeoForgeDeferredVote(UUID.fromString(fields[1]), playerId,
decode(fields[2]), decode(fields[3]), decode(fields[4]), Long.parseLong(fields[5]),
parseBoolean(fields[6]), parseBoolean(fields[7]), parseBoolean(fields[8]), decision,
versionThree && parseBoolean(fields[9]), versionFour && parseBoolean(fields[17])));
} catch (IllegalArgumentException failure) {
throw new MalformedDeferredVoteData("Malformed deferred NeoForge vote data", failure);
}
}
return votes;
}
private String serializeCompleted(List<CompletionReceipt> receipts) {
ArrayList<CompletionReceipt> bounded = new ArrayList<>(receipts);
bounded.removeIf(receipt -> receipt.released() && receipt.expired());
if (releasedReceiptCount(bounded) > releasedPerUserLimit) {
throw new IllegalStateException("Released NeoForge vote receipt capacity exceeded");
}
ArrayList<String> lines = new ArrayList<>(bounded.size());
for (CompletionReceipt receipt : bounded) {
lines.add(receipt.released()
? String.join("|", RELEASED_VERSION, receipt.voteId().toString(),
Long.toString(receipt.releasedAt()))
: String.join("|", VERSION, receipt.voteId().toString()));
}
return String.join("\n", lines);
}
private static List<CompletionReceipt> parseCompleted(String stored) {
ArrayList<CompletionReceipt> receipts = new ArrayList<>();
if (stored == null || stored.isEmpty()) return receipts;
for (String line : stored.split("\\n", -1)) {
String[] fields = line.split("\\|", -1);
boolean active = fields.length == 2 && VERSION.equals(fields[0]);
boolean released = fields.length == 3 && RELEASED_VERSION.equals(fields[0]);
if (!active && !released) {
throw new MalformedDeferredVoteData("Unsupported or malformed completed NeoForge vote data");
}
try {
receipts.add(new CompletionReceipt(UUID.fromString(fields[1]),
released ? Long.parseLong(fields[2]) : 0L));
} catch (IllegalArgumentException failure) {
throw new MalformedDeferredVoteData("Malformed completed NeoForge vote data", failure);
}
}
return receipts;
}
private static boolean containsReceipt(List<CompletionReceipt> receipts, UUID voteId) {
return receipts.stream().anyMatch(receipt -> receipt.voteId().equals(voteId) && !receipt.expired());
}
private static int activeReceiptCount(List<CompletionReceipt> receipts) {
return (int) receipts.stream().filter(receipt -> !receipt.released()).count();
}
private static int releasedReceiptCount(List<CompletionReceipt> receipts) {
return (int) receipts.stream().filter(receipt -> receipt.released() && !receipt.expired()).count();
}
private static boolean parseBoolean(String value) {
if ("true".equals(value)) return true;
if ("false".equals(value)) return false;
throw new IllegalArgumentException("Invalid boolean");
}
private static String encode(String value) {
return ENCODER.encodeToString(value.getBytes(StandardCharsets.UTF_8));
}
private static String decode(String value) {
byte[] bytes = DECODER.decode(value);
try {
return StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException failure) {
throw new IllegalArgumentException("Invalid UTF-8 field", failure);
}
}
private static Map<String, DataValue> row(List<Column> columns) {
HashMap<String, DataValue> values = new HashMap<>();
for (Column column : columns) values.put(column.getName().toLowerCase(Locale.ROOT), column.getValue());
return values;
}
private static String value(Map<String, DataValue> row, String key) {
DataValue value = row.get(key.toLowerCase(Locale.ROOT));
return value == null ? "" : value.getString();
}
public enum OccurrenceState { UNKNOWN, PENDING, COMPLETED }
enum QuarantineResult { QUARANTINED, ALREADY_QUARANTINED, ALREADY_COMPLETED, NOT_PENDING }
enum FenceResult { FENCED, ALREADY_FENCED, REPLAYABLE, ALREADY_REPLAYABLE, ALREADY_COMPLETED, NOT_PENDING }
enum Status { RETAINED, ALREADY_RETAINED, ALREADY_COMPLETED, CAPACITY_REACHED }
public enum CompletionResult { COMPLETED, ALREADY_COMPLETED, NOT_PENDING, RECEIPT_CAPACITY_REACHED }
enum ReleaseResult { RELEASED, ALREADY_RELEASED, NOT_COMPLETED, RELEASE_CAPACITY_REACHED }
enum ClaimStatus { CLAIMED, ACTIVE, ALREADY_COMPLETED, NOT_PENDING, QUARANTINED, RECEIPT_CAPACITY_REACHED }
record ClaimAttempt(ClaimStatus status, Claim claim) {
Optional<Claim> optionalClaim() { return Optional.ofNullable(claim); }
}
public record CompletionOutcome(CompletionResult result, NeoForgeVoteAccount account) { }
record DeferralResult(Status status, List<NeoForgeDeferredVote> pending) { }
private record CompletionReceipt(UUID voteId, long releasedAt) {
boolean released() { return releasedAt > 0L; }
boolean expired() { return released() && releasedAt < System.currentTimeMillis() - RELEASED_RETENTION_MILLIS; }
}
private record StoredState(List<NeoForgeDeferredVote> pending, List<CompletionReceipt> completed) { }
private record OccurrenceKey(UUID playerId, UUID voteId) { }
private record Mutation<T>(T value, int pendingDelta, int completedDelta, int releasedDelta) {
Mutation(T value, int pendingDelta, int completedDelta) { this(value, pendingDelta, completedDelta, 0); }
}
private record ReleaseMutation(ReleaseResult result, int completedDelta, int releasedDelta) { }
public final class Claim implements AutoCloseable {
private final NeoForgeDeferredVoteStore owner = NeoForgeDeferredVoteStore.this;
private final OccurrenceKey key;
private final NeoForgeDeferredVote vote;
private boolean receiptReserved = true;
private boolean closed;
private Claim(OccurrenceKey key, NeoForgeDeferredVote vote) {
this.key = key;
this.vote = vote;
}
public NeoForgeDeferredVote vote() { return vote; }
FenceResult fenceExternalEffects() { return owner.setExternalEffectFence(this, true); }
FenceResult makeReplayable() { return owner.setExternalEffectFence(this, false); }
public CompletionResult complete() {
return owner.complete(this, null, null, vote.wasOnline(), vote.playerName()).result();
}
public CompletionOutcome completeWithAccounting(NeoForgeVoteAccountingStore accounting,
NeoForgeVoteSite site, boolean currentlyOnline) {
return completeWithAccounting(accounting, site, currentlyOnline, vote.playerName());
}
public CompletionOutcome completeWithAccounting(NeoForgeVoteAccountingStore accounting,
NeoForgeVoteSite site, boolean currentlyOnline, String currentPlayerName) {
return owner.complete(this, Objects.requireNonNull(accounting, "accounting"),
Objects.requireNonNull(site, "site"), currentlyOnline,
Objects.requireNonNull(currentPlayerName, "currentPlayerName"));
}
@Override public void close() {
synchronized (owner) {
if (closed) return;
closed = true;
owner.releaseReservation(this);
owner.activeClaims.remove(key);
}
}
}
static final class MalformedDeferredVoteData extends IllegalStateException {
private static final long serialVersionUID = 1L;
private MalformedDeferredVoteData(String message) { super(message); }
private MalformedDeferredVoteData(String message, Throwable cause) { super(message, cause); }
}
}
@@ -0,0 +1,194 @@
package com.bencodez.votingplugin.neoforge;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
/** Executes supported reward actions on the NeoForge server tick lane. */
final class NeoForgeNativeRewardActions implements NeoForgeRewardActions {
private final Object server;
private final NeoForgeServerScheduler scheduler;
private final NeoForgePlayerDirectory players;
NeoForgeNativeRewardActions(Object server, NeoForgeServerScheduler scheduler,
NeoForgePlayerDirectory players) {
this.server = Objects.requireNonNull(server, "server");
this.scheduler = Objects.requireNonNull(scheduler, "scheduler");
this.players = Objects.requireNonNull(players, "players");
}
@Override public CompletableFuture<Void> execute(NeoForgeDeferredVote vote, NeoForgeRewardPlan plan) {
try {
return scheduler.executeAsync(() -> {
try {
boolean online = players.online(vote.playerId()).isPresent();
String latestName = players.latestName(vote.playerId()).orElse(null);
if (latestName != null && !latestName.equals(vote.playerName())) {
throw new IllegalStateException(
"Player identity changed before reward execution");
}
if (plan.requiresOnline() && !online) {
throw new IllegalStateException("Player became unavailable before reward execution");
}
Object player = plan.actions().stream().anyMatch(action ->
action.type() == NeoForgeRewardPlan.ActionType.PLAYER_MESSAGE)
? players.nativePlayer(vote.playerId()).orElseThrow(
() -> new IllegalStateException(
"Player became unavailable before reward execution"))
: null;
for (NeoForgeRewardPlan.Action action : plan.actions()) {
String value = replace(action.value(), vote);
if (action.type() == NeoForgeRewardPlan.ActionType.PLAYER_MESSAGE) sendMessage(player, value);
else executeCommand(value);
}
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("NeoForge reward action failed", failure);
}
});
} catch (Throwable failure) {
CompletableFuture<Void> completion = new CompletableFuture<>();
completion.completeExceptionally(failure);
return completion;
}
}
private void executeCommand(String command) throws ReflectiveOperationException {
Object commands = invokeNoArgs(server, "getCommands");
Object source = invokeNoArgs(server, "createCommandSourceStack");
Object dispatcher = invokeNoArgs(commands, "getDispatcher");
Method parse = findCompatibleMethod(dispatcher.getClass(), "parse", String.class, source.getClass());
Object parsed = invoke(dispatcher, parse, command.startsWith("/") ? command.substring(1) : command, source);
Object exceptions = invokeNoArgs(parsed, "getExceptions");
if (exceptions instanceof java.util.Map<?, ?> errors && !errors.isEmpty()) {
throw new IllegalStateException("NeoForge reward command is invalid");
}
CommandOutcome outcome = new CommandOutcome();
Method withCallback = findMethod(source.getClass(), "withCallback", 1);
Class<?> callbackType = withCallback.getParameterTypes()[0];
if (!callbackType.isInterface()) {
throw new NoSuchMethodException("NeoForge command callback is not an interface");
}
Object callback = Proxy.newProxyInstance(callbackType.getClassLoader(), new Class<?>[] { callbackType },
(proxy, method, arguments) -> commandCallback(proxy, method, arguments, outcome));
Object callbackSource = invoke(source, withCallback, callback);
Method execute = findMethod(commands.getClass(), "performPrefixedCommand", 2);
invokeCommand(commands, execute, callbackSource,
command.startsWith("/") ? command.substring(1) : command);
if (!outcome.reported.get() || !outcome.successful.get()) {
throw new UncertainRewardOutcomeException(
"NeoForge reward command did not report successful completion");
}
}
private static Object commandCallback(Object proxy, Method method, Object[] arguments, CommandOutcome outcome) {
if (method.getDeclaringClass() == Object.class) {
return switch (method.getName()) {
case "toString" -> "NeoForgeRewardCommandCallback";
case "hashCode" -> System.identityHashCode(proxy);
case "equals" -> proxy == arguments[0];
default -> null;
};
}
if (arguments == null || arguments.length != 2
|| !(arguments[0] instanceof Boolean successful)
|| !(arguments[1] instanceof Number)) {
throw new IllegalStateException("Unexpected NeoForge command callback signature");
}
outcome.reported.set(true);
if (successful) outcome.successful.set(true);
return null;
}
private static void sendMessage(Object player, String message) throws ReflectiveOperationException {
ClassLoader loader = player.getClass().getClassLoader();
Class<?> componentType = Class.forName("net.minecraft.network.chat.Component", false, loader);
Object component = componentType.getMethod("literal", String.class).invoke(null, message);
Method send = findAssignableMethod(player.getClass(), "sendSystemMessage", componentType);
invokeMessage(player, send, component);
}
private static String replace(String configured, NeoForgeDeferredVote vote) {
return configured.replace("%player%", vote.playerName()).replace("%Player%", vote.playerName())
.replace("%ServiceSite%", vote.serviceSite()).replace("%servicesite%", vote.serviceSite());
}
private static Object invokeNoArgs(Object receiver, String name) throws ReflectiveOperationException {
return invoke(receiver, receiver.getClass().getMethod(name));
}
private static Method findMethod(Class<?> type, String name, int parameters) throws NoSuchMethodException {
for (Method method : type.getMethods()) {
if (method.getName().equals(name) && method.getParameterCount() == parameters) return method;
}
throw new NoSuchMethodException(type.getName() + "." + name);
}
private static Method findCompatibleMethod(Class<?> type, String name, Class<?>... arguments)
throws NoSuchMethodException {
for (Method method : type.getMethods()) {
if (!method.getName().equals(name) || method.getParameterCount() != arguments.length) continue;
boolean compatible = true;
for (int index = 0; index < arguments.length; index++) {
compatible &= method.getParameterTypes()[index].isAssignableFrom(arguments[index]);
}
if (compatible) return method;
}
throw new NoSuchMethodException(type.getName() + "." + name);
}
private static Method findAssignableMethod(Class<?> type, String name, Class<?> argument)
throws NoSuchMethodException {
for (Method method : type.getMethods()) {
if (method.getName().equals(name) && method.getParameterCount() == 1
&& method.getParameterTypes()[0].isAssignableFrom(argument)) return method;
}
throw new NoSuchMethodException(type.getName() + "." + name);
}
private static Object invoke(Object receiver, Method method, Object... arguments)
throws ReflectiveOperationException {
try {
return method.invoke(receiver, arguments);
} catch (InvocationTargetException failure) {
Throwable cause = failure.getCause();
if (cause instanceof ReflectiveOperationException reflection) throw reflection;
if (cause instanceof RuntimeException runtime) throw runtime;
if (cause instanceof Error error) throw error;
throw new IllegalStateException("NeoForge reward action failed", cause);
}
}
private static Object invokeCommand(Object receiver, Method method, Object... arguments)
throws ReflectiveOperationException {
try {
return method.invoke(receiver, arguments);
} catch (InvocationTargetException failure) {
throw new UncertainRewardOutcomeException(
"NeoForge reward command threw after dispatch began", failure.getCause());
}
}
private static Object invokeMessage(Object receiver, Method method, Object... arguments)
throws ReflectiveOperationException {
try {
return method.invoke(receiver, arguments);
} catch (InvocationTargetException failure) {
throw new UncertainRewardOutcomeException(
"NeoForge player message threw after dispatch began", failure.getCause());
}
}
private static final class CommandOutcome {
private final AtomicBoolean reported = new AtomicBoolean();
private final AtomicBoolean successful = new AtomicBoolean();
}
static final class UncertainRewardOutcomeException extends IllegalStateException {
private static final long serialVersionUID = 1L;
UncertainRewardOutcomeException(String message) { super(message); }
UncertainRewardOutcomeException(String message, Throwable cause) { super(message, cause); }
}
}
@@ -0,0 +1,83 @@
package com.bencodez.votingplugin.neoforge;
import java.lang.reflect.InvocationTargetException;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import com.bencodez.votingplugin.core.vote.SharedVoteIdentity;
/** Tracks identity and online state from NeoForge player lifecycle events. */
public final class NeoForgePlayerDirectory {
private final Map<UUID, OnlinePlayer> online = new ConcurrentHashMap<>();
private final Map<UUID, String> latestNames = new ConcurrentHashMap<>();
public void joined(Object player) {
joinedIdentity(player);
}
SharedVoteIdentity joinedIdentity(Object player) {
SharedVoteIdentity identity = identity(player);
latestNames.put(identity.uuid(), identity.playerName());
online.put(identity.uuid(), new OnlinePlayer(identity, player));
return identity;
}
void joined(SharedVoteIdentity identity) {
Objects.requireNonNull(identity, "identity");
latestNames.put(identity.uuid(), identity.playerName());
online.put(identity.uuid(), new OnlinePlayer(identity, null));
}
public Optional<SharedVoteIdentity> left(Object player) {
OnlinePlayer removed = online.remove((UUID) invoke(player, "getUUID"));
return Optional.ofNullable(removed).map(OnlinePlayer::identity);
}
public Optional<SharedVoteIdentity> online(UUID uuid) {
return Optional.ofNullable(online.get(uuid)).map(OnlinePlayer::identity);
}
public List<SharedVoteIdentity> onlineIdentities() {
return online.values().stream().map(OnlinePlayer::identity).toList();
}
/** Latest UUID-bound name observed during this runtime, including after logout. */
Optional<String> latestName(UUID uuid) {
return Optional.ofNullable(latestNames.get(uuid));
}
Optional<Object> nativePlayer(UUID uuid) {
return Optional.ofNullable(online.get(uuid)).map(OnlinePlayer::player);
}
public void clear() {
online.clear();
latestNames.clear();
}
/** NeoForge's universal API omits Minecraft classes on Maven's compile path. */
static Object playerFromEvent(Object event) {
return invoke(event, "getEntity");
}
private static SharedVoteIdentity identity(Object player) {
UUID uuid = (UUID) invoke(player, "getUUID");
Object name = invoke(player, "getName");
return new SharedVoteIdentity(uuid, (String) invoke(name, "getString"), true);
}
private static Object invoke(Object receiver, String method) {
try {
return receiver.getClass().getMethod(method).invoke(receiver);
} catch (ReflectiveOperationException e) {
Throwable cause = e instanceof InvocationTargetException ? e.getCause() : e;
throw new IllegalStateException("NeoForge player bridge cannot call " + method, cause);
}
}
private record OnlinePlayer(SharedVoteIdentity identity, Object player) { }
}
@@ -0,0 +1,56 @@
package com.bencodez.votingplugin.neoforge;
import java.util.Locale;
import java.util.Objects;
import org.spongepowered.configurate.ConfigurationNode;
/** Validated NeoForge subset of the existing BungeeSettings socket configuration. */
record NeoForgeProxySocketConfiguration(boolean enabled, String server, String proxyName,
String proxyHost, int proxyPort, String backendHost, int backendPort,
String authenticationKeyFile, boolean communicationEncryption, boolean debug) {
static NeoForgeProxySocketConfiguration load(ConfigurationNode root) {
Objects.requireNonNull(root, "root");
boolean useProxy = root.node("UseBungeecord").getBoolean(
root.node("UseBungeecoord").getBoolean(false));
String method = root.node("BungeeMethod").getString("PLUGINMESSAGING");
if (!useProxy || !"SOCKETS".equalsIgnoreCase(method)) {
return new NeoForgeProxySocketConfiguration(false, "", "", "", 0, "", 0, "", false, false);
}
String server = trimmed(root.node("Server").getString("PleaseSet"));
String proxyName = trimmed(root.node("BungeeServer", "Name").getString(""));
String proxyHost = trimmed(root.node("BungeeServer", "Host").getString(""));
int proxyPort = root.node("BungeeServer", "Port").getInt(1297);
String backendHost = trimmed(root.node("SpigotServer", "Host").getString("0.0.0.0"));
int backendPort = root.node("SpigotServer", "Port").getInt(1298);
String authenticationKeyFile = trimmed(root.node("SocketAuthenticationKeyFile").getString(""));
if (server.isEmpty() || "pleaseset".equals(server.toLowerCase(Locale.ROOT))
|| !server.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}")) {
throw new IllegalArgumentException("Server must name a configured backend");
}
if (proxyHost.isEmpty()) {
throw new IllegalArgumentException("BungeeServer.Host is required for SOCKETS");
}
if (proxyName.isEmpty() || !proxyName.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}")) {
throw new IllegalArgumentException("BungeeServer.Name must match ProxyServerName");
}
if (authenticationKeyFile.isEmpty()) {
throw new IllegalArgumentException("SocketAuthenticationKeyFile is required for NeoForge SOCKETS");
}
if (backendHost.isEmpty()) backendHost = "0.0.0.0";
validatePort(proxyPort, "BungeeServer.Port");
validatePort(backendPort, "SpigotServer.Port");
return new NeoForgeProxySocketConfiguration(true, server, proxyName, proxyHost, proxyPort,
backendHost, backendPort, authenticationKeyFile,
root.node("CommunicationEncryption").getBoolean(false),
root.node("BungeeDebug").getBoolean(false));
}
private static String trimmed(String value) {
return value == null ? "" : value.trim();
}
private static void validatePort(int port, String path) {
if (port < 1 || port > 65535) throw new IllegalArgumentException(path + " must be between 1 and 65535");
}
}
@@ -0,0 +1,506 @@
package com.bencodez.votingplugin.neoforge;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.logging.Logger;
import com.bencodez.simpleapi.encryption.EncryptionHandler;
import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope;
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.backendproxy.presence.BackendPlayerPresenceSession;
import com.bencodez.votingplugin.core.vote.SharedVoteIdentity;
import com.bencodez.votingplugin.proxy.VotingPluginWire;
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.util.ServiceSiteValidator;
/** NeoForge receiver for the existing encrypted VotingPlugin SOCKETS proxy transport. */
public final class NeoForgeProxySocketService implements AutoCloseable {
private static final Logger LOGGER = Logger.getLogger(NeoForgeProxySocketService.class.getName());
private static final int SNAPSHOT_CHUNK_SIZE = 100;
private static final int MAX_PENDING_SENDS = 256;
private static final long HEARTBEAT_SECONDS = 30;
private final NeoForgeProxySocketConfiguration configuration;
private final NeoForgeVoteProcessor processor;
private final NeoForgePlayerDirectory players;
private final Consumer<JsonEnvelope> outbound;
private final SharedTransportEnvelopeAuthenticator authenticator;
private final TransportEnvelopeEncryption encryption;
private final Runnable transportClose;
private final ThreadPoolExecutor sender;
private final ScheduledExecutorService heartbeat;
private final Map<UUID, BackendPlayerPresenceSession> sessions = new ConcurrentHashMap<>();
private final AtomicBoolean open = new AtomicBoolean(true);
private final AtomicBoolean presenceDirty = new AtomicBoolean();
private final AtomicBoolean broadcastWarningLogged = new AtomicBoolean();
private final Object presenceLock = new Object();
private final Object sendLock = new Object();
private final Object timestampLock = new Object();
private final UUID incarnationId = UUID.randomUUID();
private final long startedAt = System.currentTimeMillis();
private long lastTimestamp = startedAt;
static NeoForgeProxySocketService start(Path directory, NeoForgeProxySocketConfiguration configuration,
NeoForgeVoteProcessor processor, NeoForgePlayerDirectory players) {
Objects.requireNonNull(directory, "directory");
EncryptionHandler encryption = new EncryptionHandler("VotingPlugin",
directory.resolve("secretkey.key").toFile());
SharedTransportEnvelopeAuthenticator authenticator;
TransportEnvelopeEncryption transportEncryption;
try {
Path root = directory.toAbsolutePath().normalize();
Path keyFile = root.resolve(configuration.authenticationKeyFile()).normalize();
if (!keyFile.startsWith(root)) throw new IOException("Socket key outside data directory");
authenticator = SharedTransportEnvelopeAuthenticator.load(keyFile, Mode.REQUIRED);
transportEncryption = TransportEnvelopeEncryption.load(directory.resolve("secretkey.key"),
TransportEnvelopeEncryption.Domain.PROXY_BACKEND,
configuration.communicationEncryption());
} catch (IOException unavailable) {
throw new IllegalStateException("Socket authentication unavailable", unavailable);
}
verifyListenerPortAvailable(configuration.backendHost(), configuration.backendPort());
ClientHandler client = new ClientHandler(configuration.proxyHost(), configuration.proxyPort(),
encryption, configuration.debug());
SocketHandler listener;
try {
listener = new SocketHandler("vp-neoforge-socket", configuration.backendHost(),
configuration.backendPort(), encryption, configuration.debug()) {
@Override public void log(String message) { LOGGER.info(message); }
};
} catch (RuntimeException failure) {
client.stopConnection();
throw failure;
}
AtomicReference<NeoForgeProxySocketService> service = new AtomicReference<>();
listener.add(new SocketReceiver() {
@Override public void onReceiveEnvelope(JsonEnvelope envelope) {
NeoForgeProxySocketService active = service.get();
if (active != null) active.receive(envelope);
}
});
Runnable close = () -> closeTransport(listener, client);
try {
NeoForgeProxySocketService active = new NeoForgeProxySocketService(configuration, processor, players,
client::sendEnvelope, authenticator, transportEncryption, close, true);
service.set(active);
return active;
} catch (RuntimeException failure) {
close.run();
throw failure;
}
}
NeoForgeProxySocketService(NeoForgeProxySocketConfiguration configuration,
NeoForgeVoteProcessor processor, NeoForgePlayerDirectory players,
Consumer<JsonEnvelope> outbound, SharedTransportEnvelopeAuthenticator authenticator) {
this(configuration, processor, players, outbound, authenticator,
TransportEnvelopeEncryption.disabled(TransportEnvelopeEncryption.Domain.PROXY_BACKEND),
() -> { }, false);
}
NeoForgeProxySocketService(NeoForgeProxySocketConfiguration configuration,
NeoForgeVoteProcessor processor, NeoForgePlayerDirectory players,
Consumer<JsonEnvelope> outbound,
SharedTransportEnvelopeAuthenticator authenticator,
TransportEnvelopeEncryption encryption,
Runnable transportClose, boolean scheduleHeartbeat) {
this.configuration = Objects.requireNonNull(configuration, "configuration");
this.processor = Objects.requireNonNull(processor, "processor");
this.players = Objects.requireNonNull(players, "players");
this.outbound = Objects.requireNonNull(outbound, "outbound");
this.authenticator = Objects.requireNonNull(authenticator, "authenticator");
this.encryption = Objects.requireNonNull(encryption, "encryption");
this.transportClose = Objects.requireNonNull(transportClose, "transportClose");
heartbeat = scheduleHeartbeat ? Executors.newSingleThreadScheduledExecutor(task -> {
Thread thread = new Thread(task, "VotingPlugin-NeoForge-Presence");
thread.setDaemon(true);
return thread;
}) : null;
sender = scheduleHeartbeat ? new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(MAX_PENDING_SENDS), task -> {
Thread thread = new Thread(task, "VotingPlugin-NeoForge-Send");
thread.setDaemon(true);
return thread;
}, new ThreadPoolExecutor.AbortPolicy()) : null;
for (SharedVoteIdentity identity : players.onlineIdentities()) addSession(identity);
announceStarted();
sendHeartbeat();
sessions.values().forEach(this::sendLogin);
if (heartbeat != null) heartbeat.scheduleAtFixedRate(this::sendHeartbeat,
HEARTBEAT_SECONDS, HEARTBEAT_SECONDS, TimeUnit.SECONDS);
}
public void playerOnline(SharedVoteIdentity identity) {
synchronized (presenceLock) {
if (!open.get()) return;
BackendPlayerPresenceSession session = addSession(identity);
announceStarted();
sendLogin(session);
}
}
public void playerOffline(SharedVoteIdentity identity) {
synchronized (presenceLock) {
if (!open.get()) return;
BackendPlayerPresenceSession session = sessions.remove(identity.uuid());
if (session == null) return;
send(VotingPluginWire.logout(session.getPlayerName(), session.getUuid(), configuration.server(),
session.getConnectionId(), incarnationId, startedAt, nextTimestamp()));
}
}
void receive(JsonEnvelope envelope) {
if (!open.get() || envelope == null) return;
String sender = envelope.getFields().get(SharedTransportEnvelopeAuthenticator.K_SENDER);
SharedTransportEnvelopeAuthenticator.Verification verification = authenticator.verify(envelope,
Domain.SOCKET_PROXY_BACKEND, configuration.server());
if (!verification.accepted() || !configuration.proxyName().equals(sender)) {
LOGGER.warning("Rejected unauthenticated socket message");
return;
}
TransportEnvelopeEncryption.Decryption decryption = encryption.decrypt(verification.envelope());
if (!decryption.accepted()) {
LOGGER.warning("Rejected invalid encrypted socket message");
return;
}
envelope = decryption.envelope();
if (envelope.getSchema() != VotingPluginWire.SCHEMA_VERSION) return;
try {
switch (envelope.getSubChannel()) {
case VotingPluginWire.SUB_VOTE, VotingPluginWire.SUB_VOTE_ONLINE -> receiveVote(envelope);
case VotingPluginWire.SUB_STATUS -> receiveStatus(envelope);
case VotingPluginWire.SUB_VOTE_DELIVERY_RECEIPT_RELEASE -> receiveReceiptRelease(envelope);
case VotingPluginWire.SUB_PRESENCE_RESYNC_REQUEST -> receiveResync(envelope);
case VotingPluginWire.SUB_PRESENCE_SNAPSHOT_REQUEST -> receiveSnapshotRequest(envelope);
default -> { }
}
} catch (RuntimeException failure) {
LOGGER.warning("NeoForge proxy message " + safeSubchannel(envelope)
+ " failed: " + failure.getClass().getSimpleName());
}
}
private void receiveVote(JsonEnvelope envelope) {
VotingPluginWire.Vote vote = VotingPluginWire.readVote(envelope);
if (vote.voteId == null || vote.player.isBlank() || vote.player.length() > 64
|| !ServiceSiteValidator.isValid(vote.service)) return;
// Backend broadcast delivery is not supported by the NeoForge replay
// boundary. Leave the occurrence unacknowledged in the proxy durable
// outbox instead of silently completing an omitted side effect.
if (vote.broadcast) {
if (broadcastWarningLogged.compareAndSet(false, true)) {
LOGGER.warning("NeoForge cannot execute delegated vote broadcasts; deliveries remain unacknowledged. "
+ "Exclude this backend from proxy broadcast routing for new votes.");
}
return;
}
UUID playerId;
try {
playerId = UUID.fromString(vote.uuid);
} catch (IllegalArgumentException invalid) {
return;
}
// NeoForge does not yet import a proxy-owned totals snapshot. Fail closed
// instead of silently completing a vote with incorrect totals.
if (vote.manageTotals || !vote.setTotals || vote.numberOfVotes < 1
|| vote.num < 1 || vote.num > vote.numberOfVotes) return;
boolean currentlyOnline = players.online(playerId).isPresent();
boolean wasOnline = vote.wasOnlineKnown ? vote.wasOnline : currentlyOnline;
NeoForgeVoteResult result = processor.process(new NeoForgeVoteRequest(vote.voteId, playerId,
vote.player, vote.service, vote.time, vote.realVote, true,
currentlyOnline, wasOnline, NeoForgeVoteRequest.Scope.COMPLETE));
if (VotingPluginWire.requestsVoteDeliveryAcknowledgement(envelope)
&& (result.durablyRetained() || result.durablyCompleted())) {
send(VotingPluginWire.voteDeliveryAcknowledgement(configuration.server(), vote.voteId,
envelope.getSubChannel()));
}
}
private void receiveStatus(JsonEnvelope envelope) {
if (!isTargetedAtThisBackend(envelope)) return;
String raw = envelope.getFields().getOrDefault(VotingPluginWire.K_REQUEST_ID, "");
if (raw.isBlank()) {
send(VotingPluginWire.statusOkay(configuration.server()));
return;
}
try {
send(VotingPluginWire.statusOkay(configuration.server(), UUID.fromString(raw)));
} catch (IllegalArgumentException invalid) {
// A malformed probe receives no capability response.
}
}
private void receiveReceiptRelease(JsonEnvelope envelope) {
if (!isTargetedAtThisBackend(envelope)
|| !VotingPluginWire.requestsVoteDeliveryAcknowledgement(envelope)) return;
String voteSubchannel = envelope.getFields().getOrDefault(
VotingPluginWire.K_VOTE_DELIVERY_SUBCHANNEL, "");
if (!VotingPluginWire.SUB_VOTE.equals(voteSubchannel)
&& !VotingPluginWire.SUB_VOTE_ONLINE.equals(voteSubchannel)) return;
UUID voteId;
UUID playerId;
try {
voteId = UUID.fromString(envelope.getFields().getOrDefault(VotingPluginWire.K_VOTE_ID, ""));
playerId = UUID.fromString(envelope.getFields().getOrDefault(VotingPluginWire.K_UUID, ""));
} catch (IllegalArgumentException invalid) {
return;
}
NeoForgeDeferredVoteStore.ReleaseResult result = processor.deferredVotes().release(playerId, voteId);
if (result != NeoForgeDeferredVoteStore.ReleaseResult.RELEASED
&& result != NeoForgeDeferredVoteStore.ReleaseResult.ALREADY_RELEASED) return;
send(VotingPluginWire.voteDeliveryReceiptReleaseAcknowledgement(
configuration.server(), voteId, voteSubchannel));
}
private void receiveResync(JsonEnvelope envelope) {
VotingPluginWire.PresenceResyncRequest request = VotingPluginWire.readPresenceResyncRequest(envelope);
if (request.requestId == null || request.requestedAt <= 0L
|| !configuration.server().equalsIgnoreCase(request.server)) return;
announceStarted();
}
private void receiveSnapshotRequest(JsonEnvelope envelope) {
VotingPluginWire.PresenceSnapshotRequest request = VotingPluginWire.readPresenceSnapshotRequest(envelope);
if (request.requestId == null || !configuration.server().equalsIgnoreCase(request.server)
|| !incarnationId.equals(request.backendIncarnationId)
|| request.backendStartedAt != startedAt || request.presenceTimestamp <= 0L) return;
synchronized (presenceLock) {
List<VotingPluginWire.PresencePlayer> snapshot = sessions.values().stream()
.map(session -> new VotingPluginWire.PresencePlayer(session.getPlayerName(), session.getUuid(),
session.getConnectionId().toString()))
.toList();
int chunks = Math.max(1, (snapshot.size() + SNAPSHOT_CHUNK_SIZE - 1) / SNAPSHOT_CHUNK_SIZE);
long timestamp = nextTimestamp();
for (int index = 0; index < chunks; index++) {
int from = index * SNAPSHOT_CHUNK_SIZE;
int to = Math.min(snapshot.size(), from + SNAPSHOT_CHUNK_SIZE);
send(VotingPluginWire.presenceSnapshot(configuration.server(), request.requestId, index, chunks,
snapshot.subList(from, to), incarnationId, startedAt, timestamp));
}
}
}
private BackendPlayerPresenceSession addSession(SharedVoteIdentity identity) {
BackendPlayerPresenceSession session = BackendPlayerPresenceSession.create(
identity.playerName(), identity.uuid().toString());
if (session == null) throw new IllegalArgumentException("Invalid player identity");
sessions.put(identity.uuid(), session);
return session;
}
private void sendLogin(BackendPlayerPresenceSession session) {
synchronized (presenceLock) {
send(VotingPluginWire.login(session.getPlayerName(), session.getUuid(), configuration.server(),
session.getConnectionId(), incarnationId, startedAt, nextTimestamp()));
}
}
private void sendHeartbeat() {
synchronized (presenceLock) {
if (!open.get()) return;
announceStarted();
send(VotingPluginWire.backendHeartbeat(configuration.server(), incarnationId,
startedAt, nextTimestamp()));
}
}
private void announceStarted() {
synchronized (presenceLock) {
send(VotingPluginWire.backendStarted(configuration.server(), incarnationId, startedAt, startedAt));
}
}
private boolean isTargetedAtThisBackend(JsonEnvelope envelope) {
return configuration.server().equalsIgnoreCase(
envelope.getFields().getOrDefault(VotingPluginWire.K_SERVER, ""));
}
private long nextTimestamp() {
synchronized (timestampLock) {
lastTimestamp = Math.max(System.currentTimeMillis(), lastTimestamp + 1L);
return lastTimestamp;
}
}
private void send(JsonEnvelope envelope) {
if (!open.get()) return;
envelope = supportedCapabilities(envelope);
if (sender != null) {
JsonEnvelope queued = envelope;
boolean presence = isPresenceEnvelope(queued);
try {
sender.execute(() -> {
try {
sendNow(queued);
} catch (RuntimeException failure) {
if (presence) presenceDirty.set(true);
LOGGER.warning("Socket send failed: " + failure.getClass().getSimpleName());
} finally {
recoverPresenceIfDrained();
}
});
} catch (RejectedExecutionException full) {
if (presence) presenceDirty.set(true);
LOGGER.warning("Send queue full; delivery rejected");
}
return;
}
sendNow(envelope);
}
private void recoverPresenceIfDrained() {
if (!open.get() || !presenceDirty.get() || !sender.getQueue().isEmpty()
|| !presenceDirty.compareAndSet(true, false)) return;
try {
synchronized (presenceLock) {
sendNow(supportedCapabilities(VotingPluginWire.backendStarted(configuration.server(), incarnationId,
startedAt, nextTimestamp())));
}
} catch (RuntimeException failure) {
presenceDirty.set(true);
LOGGER.warning("Presence recovery send failed: " + failure.getClass().getSimpleName());
}
}
private static boolean isPresenceEnvelope(JsonEnvelope envelope) {
return switch (envelope.getSubChannel()) {
case VotingPluginWire.SUB_LOGIN, VotingPluginWire.SUB_LOGOUT,
VotingPluginWire.SUB_BACKEND_STARTED, VotingPluginWire.SUB_PRESENCE_SNAPSHOT -> true;
default -> false;
};
}
private static JsonEnvelope supportedCapabilities(JsonEnvelope envelope) {
if (!switch (envelope.getSubChannel()) {
case VotingPluginWire.SUB_STATUS_OKAY, VotingPluginWire.SUB_BACKEND_STARTED,
VotingPluginWire.SUB_BACKEND_HEARTBEAT -> true;
default -> false;
}) return envelope;
JsonEnvelope authenticated = VotingPluginWire.authenticatedSocketVoteDeliveryCapability(envelope);
JsonEnvelope.Builder builder = JsonEnvelope.builder(authenticated.getSubChannel())
.schema(authenticated.getSchema());
authenticated.getFields().forEach((key, value) -> {
if (!VotingPluginWire.K_VOTE_DELAY_REJECTION_ACK_VERSION.equals(key)) builder.put(key, value);
});
return builder.build();
}
private void sendNow(JsonEnvelope envelope) {
synchronized (sendLock) {
outbound.accept(authenticator.sign(encryption.encrypt(envelope), Domain.SOCKET_PROXY_BACKEND,
configuration.server(), configuration.proxyName()));
}
}
@Override public void close() {
if (!open.compareAndSet(true, false)) return;
if (heartbeat != null) heartbeat.shutdownNow();
JsonEnvelope stopped;
synchronized (presenceLock) {
stopped = VotingPluginWire.backendStopped(configuration.server(), incarnationId,
startedAt, nextTimestamp());
sessions.clear();
}
if (sender != null) {
try {
sender.execute(() -> sendStopped(stopped));
} catch (RejectedExecutionException full) {
LOGGER.warning("Stopped-presence send rejected");
}
sender.shutdown();
try {
if (!sender.awaitTermination(1, TimeUnit.SECONDS)) sender.shutdownNow();
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
sender.shutdownNow();
}
} else {
sendStopped(stopped);
}
transportClose.run();
}
private void sendStopped(JsonEnvelope stopped) {
try {
sendNow(stopped);
} catch (RuntimeException failure) {
LOGGER.warning("Stopped-presence send failed: "
+ failure.getClass().getSimpleName());
}
}
private static void verifyListenerPortAvailable(String host, int port) {
try (ServerSocket probe = new ServerSocket()) {
probe.setReuseAddress(false);
probe.bind(new InetSocketAddress(host, port));
} catch (IOException unavailable) {
throw new IllegalStateException("Proxy listener unavailable at " + host + ":" + port,
unavailable);
}
}
static void closeTransport(SocketHandler listener, ClientHandler client) {
SocketServer server = listener.getServer();
RuntimeException failure = null;
try {
listener.closeConnection();
} catch (RuntimeException closeFailure) {
failure = closeFailure;
}
if (server != null) {
try {
server.join(1_000L);
if (server.isAlive()) LOGGER.warning("Socket stop timed out; forcing close");
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
LOGGER.warning("Socket stop interrupted; forcing close");
} finally {
try {
server.close();
} catch (RuntimeException closeFailure) {
failure = appendFailure(failure, closeFailure);
}
}
}
try {
client.stopConnection();
} catch (RuntimeException closeFailure) {
failure = appendFailure(failure, closeFailure);
}
if (failure != null) throw failure;
}
private static RuntimeException appendFailure(RuntimeException current, RuntimeException added) {
if (current == null) return added;
current.addSuppressed(added);
return current;
}
private static String safeSubchannel(JsonEnvelope envelope) {
String value = envelope.getSubChannel();
if (value == null) return "unknown";
return value.replaceAll("[^A-Za-z0-9._-]", "?").substring(0, Math.min(value.length(), 64));
}
}
@@ -0,0 +1,8 @@
package com.bencodez.votingplugin.neoforge;
import java.util.concurrent.CompletionStage;
/** Platform action boundary for the initial NeoForge reward subset. */
interface NeoForgeRewardActions {
CompletionStage<Void> execute(NeoForgeDeferredVote vote, NeoForgeRewardPlan plan);
}
@@ -0,0 +1,227 @@
package com.bencodez.votingplugin.neoforge;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import org.spongepowered.configurate.ConfigurationNode;
import com.bencodez.votingplugin.util.ServiceSiteValidator;
/** Parses the first NeoForge-safe subset of existing VotingPlugin reward YAML. */
final class NeoForgeRewardConfiguration {
private static final Pattern UNRESOLVED_PLACEHOLDER = Pattern.compile("%[^%\\p{Cf}]+%");
private static final Pattern LEGACY_COLOR = Pattern.compile("(?i)(?:&[0-9A-FK-ORX]|&#[0-9A-F]{6}|\\u00A7)");
private static final Pattern RICH_MESSAGE = Pattern.compile("\\[Text=\\\"");
private final ConfigurationNode config;
private final ConfigurationNode voteSites;
private final ConfigurationNode specialRewards;
private final boolean ignoreCase;
NeoForgeRewardConfiguration(ConfigurationNode config, ConfigurationNode voteSites,
ConfigurationNode specialRewards) {
this.config = Objects.requireNonNull(config, "config");
this.voteSites = Objects.requireNonNull(voteSites, "voteSites");
this.specialRewards = Objects.requireNonNull(specialRewards, "specialRewards");
ignoreCase = node(config, false, "CaseInsensitiveYMLFiles").getBoolean(false);
}
NeoForgeRewardPlan plan(NeoForgeDeferredVote vote, NeoForgeVoteSite site, boolean online) {
String globalBlocker = unsupportedGlobalBehavior();
if (globalBlocker != null) return blocked(globalBlocker);
if (site.waitUntilVoteDelay()) {
return blocked("WaitUntilVoteDelay completion is not supported by NeoForge replay yet");
}
if (!node(config, ignoreCase, "ProcessRewards").getBoolean(true)) {
return blocked("ProcessRewards is disabled; retained vote remains pending");
}
ArrayList<NeoForgeRewardPlan.Action> actions = new ArrayList<>();
String unsupported = appendReward(node(voteSites, ignoreCase, "EverySiteReward"), actions,
"EverySiteReward");
if (unsupported == null) {
unsupported = appendReward(node(voteSites, ignoreCase, "VoteSites", site.key(), "Rewards"),
actions, "VoteSites." + site.key() + ".Rewards");
}
if (unsupported != null) return blocked(unsupported);
actions.replaceAll(action -> expand(action, vote, site));
if (actions.stream().anyMatch(action -> !supportedValue(action.value(), action.type()))) {
return blocked("Expanded reward action contains unsupported formatting or placeholders");
}
boolean hasMessages = actions.stream().anyMatch(action ->
action.type() == NeoForgeRewardPlan.ActionType.PLAYER_MESSAGE);
boolean hasCommands = actions.stream().anyMatch(action ->
action.type() == NeoForgeRewardPlan.ActionType.CONSOLE_COMMAND);
if (hasMessages && hasCommands) {
return blocked("Mixed command and player-message rewards cannot yet be retried safely");
}
if (actions.size() > 1) {
return blocked("Multiple external reward actions cannot yet be retried safely");
}
boolean needsPlayer = hasMessages;
if (!online && (needsPlayer || (!actions.isEmpty() && !site.giveOfflineRewards()))) {
return new NeoForgeRewardPlan(NeoForgeRewardPlan.Status.WAITING_FOR_PLAYER, actions, true,
"Player is offline; retained reward remains pending");
}
return new NeoForgeRewardPlan(NeoForgeRewardPlan.Status.READY, actions,
needsPlayer || (!actions.isEmpty() && !site.giveOfflineRewards()),
"Supported reward configuration is ready");
}
private static NeoForgeRewardPlan.Action expand(NeoForgeRewardPlan.Action action,
NeoForgeDeferredVote vote, NeoForgeVoteSite site) {
String serviceSite = action.type() == NeoForgeRewardPlan.ActionType.PLAYER_MESSAGE
? ServiceSiteValidator.inertForFormatting(vote.serviceSite()) : vote.serviceSite();
String siteName = action.type() == NeoForgeRewardPlan.ActionType.PLAYER_MESSAGE
&& site.displayNameFallback()
? ServiceSiteValidator.inertForFormatting(site.displayName()) : site.displayName();
return new NeoForgeRewardPlan.Action(action.type(), action.value()
.replace("%SiteName%", siteName).replace("%sitename%", siteName)
.replace("%ServiceSite%", serviceSite).replace("%servicesite%", serviceSite));
}
private String unsupportedGlobalBehavior() {
if (node(config, ignoreCase, "CloseInventoryOnVote").getBoolean(true)) {
return "CloseInventoryOnVote is not supported by NeoForge replay yet";
}
String broadcast = node(config, ignoreCase, "VoteBroadcast", "Type").getString("EVERY_VOTE");
if (!"NONE".equalsIgnoreCase(broadcast)) return "VoteBroadcast is not supported by NeoForge replay yet";
if (node(config, ignoreCase, "PerSiteCoolDownEvents").getBoolean(false)) {
return "PerSiteCoolDownEvents is not supported by NeoForge replay yet";
}
if (node(config, ignoreCase, "UseVoteStreaks").getBoolean(true)) {
return "Vote streak processing is not supported by NeoForge replay yet";
}
if (node(config, ignoreCase, "LimitMonthlyVotes").getBoolean(false)) {
return "LimitMonthlyVotes is not supported by NeoForge replay yet";
}
if (node(config, ignoreCase, "OfflineVotesLimit", "Enabled").getBoolean(false)) {
return "OfflineVotesLimit is not supported by NeoForge replay yet";
}
if (node(specialRewards, ignoreCase, "VoteParty", "Enabled").getBoolean(false)) {
return "VoteParty is not supported by NeoForge replay yet";
}
if (!node(specialRewards, ignoreCase, "AnySiteRewards").empty()) {
return "AnySiteRewards are not supported by NeoForge replay yet";
}
if (!node(specialRewards, ignoreCase, "VoteCoolDownEndedReward").empty()) {
return "Vote cooldown rewards are not supported by NeoForge replay yet";
}
if (hasEnabledChild(node(specialRewards, ignoreCase, "VoteMilestones"))) {
return "VoteMilestones are not supported by NeoForge replay yet";
}
if (hasEnabledRewardDefinition(node(specialRewards, ignoreCase, "VoteStreaks"))
|| hasEnabledRewardDefinition(node(specialRewards, ignoreCase, "VoteStreak"))) {
return "Vote streak rewards are not supported by NeoForge replay yet";
}
if (hasEnabledChild(node(specialRewards, ignoreCase, "Cumulative"))
|| hasEnabledChild(node(specialRewards, ignoreCase, "MileStones"))) {
return "Legacy milestone rewards are not supported by NeoForge replay yet";
}
if (!node(specialRewards, ignoreCase, "FirstVote").empty()
|| !node(specialRewards, ignoreCase, "FirstVoteToday").empty()) {
return "First-vote rewards are not supported by NeoForge replay yet";
}
if (!node(specialRewards, ignoreCase, "AllSites").empty()
|| !node(specialRewards, ignoreCase, "AlmostAllSites").empty()) {
return "Legacy all-sites rewards are not supported by NeoForge replay yet";
}
return null;
}
private String appendReward(ConfigurationNode reward, List<NeoForgeRewardPlan.Action> actions, String path) {
if (reward.empty()) return null;
if (reward.isList()) {
return reward.childrenList().isEmpty() ? null : path + " named reward lists are unsupported";
}
if (reward.childrenMap().isEmpty()) return path + " has an unsupported value";
for (Map.Entry<Object, ? extends ConfigurationNode> entry : reward.childrenMap().entrySet()) {
String key = String.valueOf(entry.getKey());
if (!matchesKey(key, "Commands") && !matchesKey(key, "Messages"))
return path + "." + key + " is unsupported";
}
ConfigurationNode messageNode = node(reward, ignoreCase, "Messages");
if (!messageNode.empty()) {
if (messageNode.childrenMap().isEmpty()) return path + ".Messages has an unsupported value";
for (Map.Entry<Object, ? extends ConfigurationNode> message : messageNode.childrenMap().entrySet()) {
if (!matchesKey(String.valueOf(message.getKey()), "Player"))
return path + ".Messages." + message.getKey() + " is unsupported";
if (!appendActions(message.getValue(), actions, NeoForgeRewardPlan.ActionType.PLAYER_MESSAGE))
return path + ".Messages.Player has an unsupported value";
}
}
ConfigurationNode commands = node(reward, ignoreCase, "Commands");
if (!commands.empty()
&& !appendActions(commands, actions, NeoForgeRewardPlan.ActionType.CONSOLE_COMMAND))
return path + ".Commands has an unsupported value";
return null;
}
private boolean matchesKey(String actual, String expected) {
return ignoreCase ? actual.equalsIgnoreCase(expected) : actual.equals(expected);
}
private static boolean appendActions(ConfigurationNode node, List<NeoForgeRewardPlan.Action> output,
NeoForgeRewardPlan.ActionType type) {
if (node.isList()) {
for (ConfigurationNode child : node.childrenList()) {
String value = child.getString();
if (value == null) return false;
if (type == NeoForgeRewardPlan.ActionType.PLAYER_MESSAGE && value.isEmpty()) continue;
if (!supportedValue(value, type)) return false;
output.add(new NeoForgeRewardPlan.Action(type, value));
}
return true;
}
String value = node.getString();
if (value == null) return node.empty();
if (type == NeoForgeRewardPlan.ActionType.PLAYER_MESSAGE && value.isEmpty()) return true;
if (!supportedValue(value, type)) return false;
output.add(new NeoForgeRewardPlan.Action(type, value));
return true;
}
private static boolean supportedValue(String value, NeoForgeRewardPlan.ActionType type) {
String knownRemoved = value.replace("%player%", "").replace("%Player%", "")
.replace("%ServiceSite%", "").replace("%servicesite%", "")
.replace("%SiteName%", "").replace("%sitename%", "");
if (UNRESOLVED_PLACEHOLDER.matcher(knownRemoved).find()
|| knownRemoved.toLowerCase(java.util.Locale.ROOT).contains("[javascript")) return false;
return type != NeoForgeRewardPlan.ActionType.PLAYER_MESSAGE
|| (!LEGACY_COLOR.matcher(value).find() && !RICH_MESSAGE.matcher(value).find());
}
private boolean hasEnabledChild(ConfigurationNode parent) {
return parent.childrenMap().values().stream()
.anyMatch(child -> node(child, ignoreCase, "Enabled").getBoolean(true));
}
private boolean hasEnabledRewardDefinition(ConfigurationNode parent) {
if (!node(parent, ignoreCase, "Rewards").empty()
&& node(parent, ignoreCase, "Enabled").getBoolean(true)) return true;
return parent.childrenMap().values().stream().anyMatch(this::hasEnabledRewardDefinition);
}
private static NeoForgeRewardPlan blocked(String detail) {
return new NeoForgeRewardPlan(NeoForgeRewardPlan.Status.BLOCKED_UNSUPPORTED, List.of(), false, detail);
}
private static ConfigurationNode node(ConfigurationNode parent, boolean ignoreCase, String... path) {
ConfigurationNode current = parent;
for (String key : path) {
if (ignoreCase) {
ConfigurationNode match = current.childrenMap().entrySet().stream()
.filter(entry -> String.valueOf(entry.getKey()).equalsIgnoreCase(key))
.map(Map.Entry::getValue).findFirst().orElse(null);
if (match != null) {
current = match;
continue;
}
}
current = current.node(key);
}
return current;
}
}
@@ -0,0 +1,16 @@
package com.bencodez.votingplugin.neoforge;
import java.util.List;
/** Immutable ordered reward work selected for one retained NeoForge vote. */
public record NeoForgeRewardPlan(Status status, List<Action> actions, boolean requiresOnline, String detail) {
public NeoForgeRewardPlan {
actions = List.copyOf(actions);
}
public enum Status { READY, WAITING_FOR_PLAYER, BLOCKED_UNSUPPORTED }
public enum ActionType { PLAYER_MESSAGE, CONSOLE_COMMAND }
public record Action(ActionType type, String value) { }
public boolean ready() { return status == Status.READY; }
}
@@ -0,0 +1,384 @@
package com.bencodez.votingplugin.neoforge;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.bencodez.votingplugin.core.vote.SharedVoteIdentity;
import com.bencodez.votingplugin.util.MinecraftUsernameValidator;
/** Bounded storage-worker replay for retained COMPLETE NeoForge votes. */
public final class NeoForgeRewardReplayService implements AutoCloseable {
private static final Logger LOGGER = Logger.getLogger(NeoForgeRewardReplayService.class.getName());
static final int MAX_PER_RUN = 16;
static final int MAX_USERS_PER_RUN = 32;
private static final int MAX_MALFORMED_REPORTS = NeoForgeDeferredVoteStore.MAX_DEFERRED_TOTAL;
private static final long MALFORMED_REPORT_INTERVAL = TimeUnit.MINUTES.toNanos(1);
private final NeoForgeVoteConfiguration configuration;
private final NeoForgeRewardConfiguration rewards;
private final NeoForgeVoteAccountingStore accounting;
private final NeoForgeDeferredVoteStore deferred;
private final NeoForgePlayerDirectory players;
private final NeoForgeRewardActions actions;
private final ScheduledExecutorService worker;
private final AtomicBoolean scanning = new AtomicBoolean();
private final AtomicBoolean open = new AtomicBoolean(true);
private final ConcurrentHashMap<UUID, Long> retryAfter = new ConcurrentHashMap<>();
private final ConcurrentHashMap<UUID, Long> malformedReportAfter = new ConcurrentHashMap<>();
private int userOffset;
private volatile ScheduledFuture<?> periodic;
NeoForgeRewardReplayService(NeoForgeVoteConfiguration configuration,
NeoForgeRewardConfiguration rewards, NeoForgeVoteAccountingStore accounting,
NeoForgeDeferredVoteStore deferred, NeoForgePlayerDirectory players,
NeoForgeRewardActions actions) {
this.configuration = Objects.requireNonNull(configuration, "configuration");
this.rewards = Objects.requireNonNull(rewards, "rewards");
this.accounting = Objects.requireNonNull(accounting, "accounting");
this.deferred = Objects.requireNonNull(deferred, "deferred");
this.players = Objects.requireNonNull(players, "players");
this.actions = Objects.requireNonNull(actions, "actions");
ThreadFactory factory = task -> {
Thread thread = new Thread(task, "VotingPlugin-NeoForge-Replay");
thread.setDaemon(true);
return thread;
};
worker = Executors.newSingleThreadScheduledExecutor(factory);
}
void start() {
periodic = worker.scheduleWithFixedDelay(this::replayScheduled, 1, 1, TimeUnit.SECONDS);
}
void replayScheduled() {
replayOnce().whenComplete((ignored, failure) -> {
if (failure != null && open.get()) {
LOGGER.log(Level.SEVERE, "Scheduled NeoForge reward replay failed", failure);
}
});
}
public CompletableFuture<List<ReplayResult>> replayOnce() {
CompletableFuture<List<ReplayResult>> result = new CompletableFuture<>();
if (!open.get()) {
result.completeExceptionally(new RejectedExecutionException("NeoForge reward replay has stopped"));
return result;
}
if (!scanning.compareAndSet(false, true)) {
result.complete(List.of());
return result;
}
try {
worker.execute(() -> {
try {
deferred.initializeRelevantRowIndexes();
ArrayList<CompletableFuture<ReplayResult>> work = new ArrayList<>();
List<UUID> users = deferred.users();
int visits = Math.min(MAX_USERS_PER_RUN, users.size());
int start = users.isEmpty() ? 0 : Math.floorMod(userOffset, users.size());
int visited = 0;
for (; visited < visits && work.size() < MAX_PER_RUN; visited++) {
UUID playerId = users.get((start + visited) % users.size());
List<NeoForgeDeferredVote> pending;
try {
pending = deferred.pending(playerId);
} catch (NeoForgeDeferredVoteStore.MalformedDeferredVoteData malformed) {
// The durable row stays untouched and observable through the store;
// another user's healthy queue must continue replaying.
reportMalformed(playerId);
continue;
}
malformedReportAfter.remove(playerId);
if (pending.isEmpty()) {
continue;
}
// Accounting decisions and reward effects are order-sensitive. A delayed
// head occurrence therefore blocks only this player, while the outer loop
// continues admitting other players.
NeoForgeDeferredVote vote = pending.get(0);
if (deadlineReached(retryAfter.get(vote.voteId()), System.nanoTime())) {
work.add(replay(vote));
}
}
if (!users.isEmpty()) userOffset = (start + visited) % users.size();
CompletableFuture.allOf(work.toArray(CompletableFuture[]::new))
.whenCompleteAsync((ignored, failure) -> {
try {
ArrayList<ReplayResult> completed = new ArrayList<>(work.size());
for (CompletableFuture<ReplayResult> item : work) completed.add(item.join());
scanning.set(false);
result.complete(List.copyOf(completed));
} catch (CompletionException completionFailure) {
scanning.set(false);
result.completeExceptionally(completionFailure.getCause());
}
}, worker);
} catch (Throwable failure) {
scanning.set(false);
result.completeExceptionally(failure);
}
});
} catch (RuntimeException stopped) {
scanning.set(false);
result.completeExceptionally(stopped);
}
return result;
}
private CompletableFuture<ReplayResult> replay(NeoForgeDeferredVote vote) {
if (vote.quarantined()) {
retryAfter.put(vote.voteId(), Long.MAX_VALUE);
return CompletableFuture.completedFuture(result(vote, Status.REWARD_UNCERTAIN,
"Durably quarantined after an uncertain external effect; operator action is required"));
}
if (vote.accountingDecision() == null) {
return CompletableFuture.completedFuture(delayed(vote, Status.BLOCKED_UNSUPPORTED,
"Legacy retained vote has no accepted accounting snapshot; operator action is required", 60));
}
var onlineIdentity = players.online(vote.playerId());
String currentName = players.latestName(vote.playerId())
.or(() -> accounting.storedPlayerName(vote.playerId())).orElse(vote.playerName());
if (!MinecraftUsernameValidator.isValid(currentName, configuration.bedrockPlayerPrefix())) {
return CompletableFuture.completedFuture(delayed(vote, Status.BLOCKED_UNSUPPORTED,
"Retained player name is invalid and cannot be used in rewards", 60));
}
NeoForgeVoteSite site = configuration.configuredSite(vote.siteKey()).orElse(null);
if (site == null) return CompletableFuture.completedFuture(
delayed(vote, Status.BLOCKED_UNSUPPORTED, "Configured vote site no longer exists", 60));
NeoForgeDeferredVote effectiveVote = vote.withReplayContext(currentName, site.serviceSite());
NeoForgeRewardPlan plan = rewards.plan(effectiveVote, site, onlineIdentity.isPresent());
if (plan.status() == NeoForgeRewardPlan.Status.BLOCKED_UNSUPPORTED) {
return CompletableFuture.completedFuture(delayed(vote, Status.BLOCKED_UNSUPPORTED, plan.detail(), 60));
}
if (plan.status() == NeoForgeRewardPlan.Status.WAITING_FOR_PLAYER) {
return CompletableFuture.completedFuture(delayed(vote, Status.WAITING_FOR_PLAYER, plan.detail(), 5));
}
NeoForgeDeferredVoteStore.ClaimAttempt attempt = deferred.claimForReplay(vote.playerId(), vote.voteId());
if (attempt.status() == NeoForgeDeferredVoteStore.ClaimStatus.RECEIPT_CAPACITY_REACHED) {
return CompletableFuture.completedFuture(delayed(vote, Status.COMPLETION_CAPACITY_UNAVAILABLE,
"Completion receipt capacity is exhausted; retained vote remains pending", 60));
}
NeoForgeDeferredVoteStore.Claim claim = attempt.optionalClaim().orElse(null);
if (claim == null) return CompletableFuture.completedFuture(
result(vote, Status.NOT_CLAIMED, "Vote claim was not admitted: " + attempt.status()));
CompletableFuture<ReplayResult> completion = new CompletableFuture<>();
CompletionStage<Void> action;
boolean externalEffects = !plan.actions().isEmpty();
try {
if (externalEffects) {
NeoForgeDeferredVoteStore.FenceResult fenced = claim.fenceExternalEffects();
if (fenced != NeoForgeDeferredVoteStore.FenceResult.FENCED
&& fenced != NeoForgeDeferredVoteStore.FenceResult.ALREADY_FENCED) {
claim.close();
return CompletableFuture.completedFuture(result(vote, Status.NOT_CLAIMED,
"External-effect fence was not admitted: " + fenced));
}
}
action = !externalEffects
? CompletableFuture.completedFuture(null) : actions.execute(effectiveVote, plan);
} catch (RuntimeException failure) {
return CompletableFuture.completedFuture(actionFailure(claim, vote, failure, externalEffects));
}
action.whenComplete((ignored, failure) -> dispatchCompletion(
claim, effectiveVote, site, failure, externalEffects, completion));
return completion;
}
private ReplayResult rewardFailure(NeoForgeDeferredVote vote, Throwable failure) {
if (containsUncertainOutcome(failure)) {
return quarantine(vote, Status.REWARD_UNCERTAIN, safeFailure(failure));
}
return delayed(vote, Status.REWARD_FAILED, safeFailure(failure), 5);
}
private void dispatchCompletion(NeoForgeDeferredVoteStore.Claim claim, NeoForgeDeferredVote vote,
NeoForgeVoteSite site, Throwable failure, boolean externalEffects,
CompletableFuture<ReplayResult> completion) {
Runnable finish = () -> {
if (failure != null) {
completion.complete(actionFailure(claim, vote, failure, externalEffects));
return;
}
try {
NeoForgeDeferredVoteStore.CompletionOutcome outcome;
try (claim) {
outcome = claim.completeWithAccounting(accounting, site,
vote.wasOnline(), vote.playerName());
}
Status status = outcome.result() == NeoForgeDeferredVoteStore.CompletionResult.COMPLETED
|| outcome.result() == NeoForgeDeferredVoteStore.CompletionResult.ALREADY_COMPLETED
? Status.COMPLETED : Status.NOT_CLAIMED;
if (status == Status.COMPLETED) {
retryAfter.remove(vote.voteId());
completion.complete(new ReplayResult(vote.voteId(), status, outcome.account(),
"Completion result: " + outcome.result()));
} else {
completion.complete(quarantine(vote, Status.COMPLETION_UNCERTAIN,
"Completion result: " + outcome.result()));
}
} catch (Throwable completionFailure) {
if (externalEffects) {
retryAfter.put(vote.voteId(), Long.MAX_VALUE);
LOGGER.severe("Unable to finalize NeoForge deferred vote " + vote.voteId()
+ "; its durable external-effect fence prevents automatic replay");
completion.complete(result(vote, Status.COMPLETION_UNCERTAIN,
safeFailure(completionFailure) + "; durable external-effect fence retained"));
} else {
completion.complete(quarantine(vote, Status.COMPLETION_UNCERTAIN,
safeFailure(completionFailure)));
}
}
};
try {
worker.execute(finish);
} catch (RuntimeException stopped) {
claim.close();
completion.completeExceptionally(stopped);
}
}
private ReplayResult actionFailure(NeoForgeDeferredVoteStore.Claim claim,
NeoForgeDeferredVote vote, Throwable failure, boolean externalEffects) {
if (!externalEffects) {
claim.close();
return rewardFailure(vote, failure);
}
if (containsUncertainOutcome(failure)) {
claim.close();
return quarantine(vote, Status.REWARD_UNCERTAIN, safeFailure(failure));
}
try {
NeoForgeDeferredVoteStore.FenceResult replayable = claim.makeReplayable();
claim.close();
if (replayable == NeoForgeDeferredVoteStore.FenceResult.REPLAYABLE
|| replayable == NeoForgeDeferredVoteStore.FenceResult.ALREADY_REPLAYABLE) {
return delayed(vote, Status.REWARD_FAILED, safeFailure(failure), 5);
}
return quarantine(vote, Status.REWARD_UNCERTAIN,
safeFailure(failure) + "; fence-clear=" + replayable);
} catch (Throwable fenceFailure) {
claim.close();
retryAfter.put(vote.voteId(), Long.MAX_VALUE);
LOGGER.severe("Failed to clear the durable external-effect fence for NeoForge deferred vote "
+ vote.voteId() + "; automatic replay remains stopped");
return result(vote, Status.REWARD_UNCERTAIN,
safeFailure(failure) + "; fence-clear=" + safeFailure(fenceFailure));
}
}
private static ReplayResult result(NeoForgeDeferredVote vote, Status status, String detail) {
return new ReplayResult(vote.voteId(), status, null, detail);
}
private ReplayResult delayed(NeoForgeDeferredVote vote, Status status, String detail, long seconds) {
Long previous = retryAfter.put(vote.voteId(), System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds));
if (previous == null) LOGGER.warning("NeoForge deferred vote " + vote.voteId() + " is " + status);
return result(vote, status, detail);
}
void rememberIdentity(SharedVoteIdentity identity) {
Objects.requireNonNull(identity, "identity");
if (!open.get()) return;
try {
worker.execute(() -> {
try {
accounting.rememberIdentity(identity);
} catch (RuntimeException failure) {
LOGGER.log(Level.SEVERE, "Unable to persist NeoForge player identity", failure);
}
});
} catch (RejectedExecutionException stopped) {
if (open.get()) LOGGER.log(Level.WARNING, "Unable to queue NeoForge player identity persistence", stopped);
}
}
private void reportMalformed(UUID playerId) {
long now = System.nanoTime();
malformedReportAfter.compute(playerId, (ignored, deadline) -> {
if (deadline != null && !deadlineReached(deadline, now)) return deadline;
if (deadline == null && malformedReportAfter.size() >= MAX_MALFORMED_REPORTS) return null;
LOGGER.warning("Malformed deferred NeoForge vote data for player " + playerId
+ "; the row remains durable and replay is isolated");
return now + MALFORMED_REPORT_INTERVAL;
});
}
static boolean deadlineReached(Long deadline, long now) {
return deadline == null || deadline != Long.MAX_VALUE && deadline - now <= 0L;
}
private ReplayResult quarantine(NeoForgeDeferredVote vote, Status status, String detail) {
retryAfter.put(vote.voteId(), Long.MAX_VALUE);
try {
NeoForgeDeferredVoteStore.QuarantineResult outcome = deferred.quarantine(
vote.playerId(), vote.voteId());
if (outcome == NeoForgeDeferredVoteStore.QuarantineResult.ALREADY_COMPLETED) {
retryAfter.remove(vote.voteId());
return result(vote, Status.COMPLETED, "Completion receipt was already durable");
}
if (outcome == NeoForgeDeferredVoteStore.QuarantineResult.NOT_PENDING) {
LOGGER.severe("NeoForge deferred vote " + vote.voteId()
+ " could not be durably quarantined because its pending record is missing");
return result(vote, status, detail + "; quarantine=" + outcome);
}
LOGGER.warning("NeoForge deferred vote " + vote.voteId()
+ " is durably quarantined after an uncertain external effect");
return result(vote, status, detail + "; quarantine=" + outcome);
} catch (Throwable quarantineFailure) {
LOGGER.severe("Failed to durably quarantine NeoForge deferred vote " + vote.voteId()
+ "; automatic replay is stopped for this process");
return result(vote, status, detail + "; quarantine=" + safeFailure(quarantineFailure));
}
}
private static String safeFailure(Throwable failure) {
Throwable cause = failure instanceof CompletionException && failure.getCause() != null
? failure.getCause() : failure;
return cause.getClass().getSimpleName();
}
private static boolean containsUncertainOutcome(Throwable failure) {
for (Throwable current = failure; current != null; current = current.getCause()) {
if (current instanceof NeoForgeNativeRewardActions.UncertainRewardOutcomeException) return true;
}
return false;
}
void stopAdmission() {
open.set(false);
ScheduledFuture<?> task = periodic;
if (task != null) task.cancel(false);
}
@Override public void close() {
stopAdmission();
worker.shutdown();
try {
if (!worker.awaitTermination(5, TimeUnit.SECONDS)) worker.shutdownNow();
} catch (InterruptedException interrupted) {
worker.shutdownNow();
Thread.currentThread().interrupt();
}
}
public enum Status {
COMPLETED, WAITING_FOR_PLAYER, BLOCKED_UNSUPPORTED, REWARD_FAILED, REWARD_UNCERTAIN,
COMPLETION_UNCERTAIN, COMPLETION_CAPACITY_UNAVAILABLE, NOT_CLAIMED
}
public record ReplayResult(UUID voteId, Status status, NeoForgeVoteAccount account, String detail) { }
}
Loaded 100 of 548 files, more files were not shown because too many files have changed in this diff. Show more