fix(clustering): stop SystemEventsJob dropping the majority of system events - #37288
fix(clustering): stop SystemEventsJob dropping the majority of system events#37288danielsolis-dotcms wants to merge 9 commits into
Conversation
Adds system_event_cursor, one row per cluster node, to hold how far that node has consumed the system_event queue. Nothing reads it yet; the poller is wired up in the following commit. The cursor lives in its own table rather than as a column on system_event so the change stays purely additive and therefore rollback-safe: an older release rolled back onto this schema never queries the table, and the row shape of system_event is byte-identical to main. Refs #36827 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SystemEventsJob tracked its position with an in-memory wall-clock mark, advanced to now() AFTER processing, while selecting rows with created >= mark. But created is stamped when the SystemEvent is constructed, not when its transaction commits, so any poll landing in that gap saw nothing and still moved the mark past the event. The row could then never satisfy created >= mark again and was skipped permanently. Measured at 50-63% loss on a two-node cluster. Three causes of permanent loss are fixed here: 1. Commit-timing race. Each poll now reads from a bounded overlap window behind its cursor, so a late-committing row is re-read instead of skipped. Duplicates the window produces are suppressed by a bounded in-memory dedupe set. 2. Restart loss. The mark was a static AtomicLong, so a restart reset it to "now" and every event committed while the node was down was lost. The cursor is now durable and per node. It also no longer advances when a read fails, so a failed poll retries its range. 3. One unreadable payload destroyed its whole batch. getEventsSince converted a polled batch as a unit; rows are now converted individually, and an unreadable one is logged with its id, type and a running count, then skipped. This lives alongside the cursor DAO because both are in SystemEventsFactory. The third was found by running a real two-node cluster: UserSessionBean on SWITCH_SITE events, emitted during ordinary admin activity, made every poll on both nodes throw - 39 consecutive failed polls per node, delivering nothing. On main that failure is silent, caught and logged at DEBUG. The systemic problem is tracked in #37249. Loss is also no longer silent: warnings fire when commit lag approaches the window, when the poller stalls, and when a stale cursor is clamped, and an authored-vs-observed reconciliation runs on an interval. Delivery is at-least-once and always was - the >= boundary already permitted repeat delivery. It is now stated rather than accidental. Refs #36827, #37249 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Payload records the payload's concrete class name and the receiving node reconstructs into it, so a class with no usable Jackson creator can be written but never read back. UserSessionBean rides SWITCH_SITE events, which are emitted during ordinary admin activity. On a real two-node cluster this made every poll on both nodes throw, delivering nothing, and it recurred continuously because each authenticated request generated another such event. SystemTableUpdatedKeyEvent had the identical defect and was fixed upstream in #37286; this branch defers to main for it. The batch-level resilience that stops the next such class taking its window down with it is in the previous commit. Refs #36827, #37249 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers late-commit delivery, all events from a long transaction, an undeserialisable payload not destroying its batch, cross-node delivery, routing semantics, deliver-once inside the overlap window, cursor durability across restart, retention/purge interaction, and the authored-vs-observed reconciliation. Registered in MainSuite3a. Integration tests only run when listed in a suite, registration is manual, and nothing verifies it - these were initially written unregistered and would never have run in CI. The wider gap, 622 orphaned integration tests, is tracked in #37254. MainSuite2a is smaller but its header asks that no more tests be added to it; MainSuite3a is next smallest and already hosts a runonce test in the clustering area. The migration test is listed first so the cursor table exists before the tests that read it. Refs #36827 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documents the at-least-once delivery contract where a consumer author will meet it, along with which consumer patterns are safe, the audit of all ten existing consumers, the publisher rules including the payload deserialization trap, the agreed loss tolerance with its rationale and its status as a starting point, and the observability signals. The contract always held - the >= boundary already permitted repeat delivery - but it was undocumented, so no consumer was knowingly built for it. Refs #36827 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The batch-resilience test used UserSessionBean as its undeserialisable payload - and UserSessionBean is given a Jackson creator earlier in this same change. The fixture therefore stopped being poison, the row was delivered instead of skipped, and the test asserted nothing. It now names a class that does not exist, so it pins the mechanism rather than one broken class and stays honest as payload classes get fixed. Refs #36827 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Semgrep found 1 The method identified is susceptible to injection. The input should be validated and properly If this is a critical or high severity finding, please also link this issue in the #security channel in Slack. |
Verification detailMoved out of the PR description to keep it readable. Reproduction evidenceThe overlap window was temporarily reverted to Reproduced on all 4 forks, a different lost event id each time. Within the same test, the assertion "Reading from the bare cursor must MISS the event — this is the defect" passed: the event is in the database, the poller is running, and it is never returned. Restoring the window turns both green. The loss is not probabilistic. With a bare cursor an event whose Measurements were captured against a local two-node cluster; the figures quoted here are from those runs. Security
The substantive question is that this fix increases the volume of events actually delivered — previously dropped events now reach consumers — so exposure genuinely changes and was verified rather than assumed. All new SQL is parameterized via Rollback safetyRollback-safe — additive only.
The PerformanceIndex scan on
New write volume is one single-row upsert per node per poll — O(nodes), never O(events × nodes). The publish path gained no work, so no added latency inside caller transactions. Additional InfoDelivery is at-least-once, and that is now written down. It always was — the Known residual limitation. A transaction held open longer than the overlap window can still lose its events. This is bounded, configurable, and now warned about — as opposed to the unbounded silent loss it replaces. It is covered by a test that pins it as a known boundary rather than a surprise. Follow-up filed: #37249. AC-003 verified on a real two-node cluster, across three deployments of the same cluster:
AC-008 measured over a 24-hour window: 0.00% loss on both nodes — 1,158 authored / 1,158 observed per node, confirmed independently by the issue's Step B method and by the in-product reconciliation, which reported 0.00% on every hourly sample. Zero delegate errors and zero undeserialisable payloads across the window. Read that with its caveat. The wall-clock window was 24 hours, but the host (a laptop) entered idle sleep repeatedly overnight, so the cluster was actively running for roughly 9.7 hours of it. Three suspensions exceeded the 60-minute backlog bound and tripped the clamp on both nodes — the observability behaving correctly, not a delivery defect. Loss was nil across those stalls because the traffic generator froze alongside the nodes, so nothing was authored during the skipped spans. Consequently the run does not exercise the clamp's real loss scenario — one node down while other nodes keep authoring — which stays untested on real hardware, along with the 31-day purge boundary and bursty load. An earlier 1-hour run under continuous load gave the same result — 354/354 per node, 0.00% — and covers the continuous-operation case this one does not. (Superseded detail from the 1-hour run:) AC-008 measured over 1 hour: 0.00% loss on both nodes — 354 authored / 354 observed each, confirmed independently by the issue's Step B method and by the new in-product reconciliation ( Taken together the two runs cover ~10.7 hours of active operation across a 25-hour span with zero measured loss, against a ≤1% bar and a 50–63% reported baseline. AC-008 is reported as met for the conditions tested, with the untested scenarios named above rather than implied. Known limitation found by that run: the reconciliation's Note for anyone reproducing this: the example compose files in Two ADRs proposed, not written (Spec-Kit must never author ADRs): the at-least-once delivery contract, and the fact that dotCMS has two cluster-messaging mechanisms — Shared-state note for review. New configuration (all defaulted, none required):
|
Correction: restart recovery does not work yet, and whyAn earlier version of this description claimed the durable cursor means "events committed while a node was down are delivered when it returns." That is not true today, and the description has been corrected. While trying to verify it on a real two-node cluster, the test kept failing for a reason that turned out not to be in this code: // com/dotcms/cluster/business/ServerAPI.java:15
final static Lazy<String> SERVER_ID = Lazy.of(UUIDUtil::uuid);A node's Observed directly: Persistence for this used to exist and was removed in What this does and does not change hereUnaffected: the commit-timing race — the defect #36827 actually reports — is fixed by the overlap window, which does not depend on node identity at all. Same for the batch-conversion fix and the payload creator. The cursor still advances only from a read that completed, and still does not advance when a read fails. Deferred: restart recovery. Once #37291 is resolved it follows from this change with no further work here, because the cursor is already durable and keyed on Also corrected: On the untested scenarioThe "one node down while other nodes keep authoring" case listed under known gaps cannot be tested until #37291 is fixed — a restarted node is a different node, so the scenario cannot be constructed. Two attempts on a live cluster produced results that looked plausible and proved nothing; both were discarded rather than reported. |
Semgrep flags the statement as an injection risk (CUSTOM_INJECTION-2) because it was assembled with + across three lines. The values were always bound through addParam and none of the fragments were variable, so the finding is a false positive - its own explanation says so. Rather than suppress it, the trigger is removed: the statement is now a single literal, which is also how every other query in this class is written. This one was the only concatenated SQL in the file. Refs #36827 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…anges live
Three related corrections to how this feature reads configuration.
1. The retention property was being read by the wrong key. The delegate
declares it as
DELETE_EVENTS_OLDER_THAN = "systemevents.job.deleteevents.olderthan"
and this code used the *constant's name* as the key. That silently
returned the default of 31 days, so isBacklogWithinRetention() could
never see an operator who had shortened retention - the check it
exists to perform was comparing against a constant. The existing test
missed it by setting the property through the same wrong constant, so
it validated the mistake rather than catching it; the new tests use
the literal key.
2. The poller captured the overlap window and backlog once, at tracker
construction, so tuning them required a restart. They are now read on
each poll through suppliers. This matters because the commit-lag
warning tells an operator to raise
SYSTEM_EVENTS_OVERLAP_WINDOW_SECONDS - advice that is close to
useless if acting on it needs a restart, which currently also loses
the cursor (#37291). Only the in-memory dedupe set is carried across
polls; the fixed-value constructors remain for tests that need
deterministic arithmetic.
3. docs/backend/SYSTEM_EVENTS.md now states which configuration tiers
are hot: DOT_-prefixed environment variables are fixed for the life
of the process, system-table values take effect on the next poll, and
.properties files are reloaded by the file watcher rather than on
every read. It also notes the recursion - changing a property in the
system table publishes a SystemTableUpdatedKeyEvent cluster wide
through this very queue, so cluster-wide hot config reload depends on
the delivery this change repairs.
Refs #36827
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #36827
Proposed Changes
SystemEventsJobloses 50–63% of system events in a cluster. Its poll cursor is an in-memory wall-clock high-water mark, advanced tonow()after processing, while rows are selected withcreated >= mark— butcreatedis stamped when theSystemEventis constructed, not when its transaction commits. Any poll landing in that gap sees nothing and still moves the mark past the event, which can then never satisfycreated >= markagain.This fixes four distinct causes of permanent event loss on that path:
The commit-timing race (the reported defect). Each poll now reads from a bounded overlap window behind its cursor, so a late-committing row is re-read instead of skipped. Duplicates the window produces are suppressed by a bounded in-memory dedupe set.
A cursor that lies about what it read. The mark was a
static AtomicLongadvanced from the clock rather than from the data. It is now a durable per-node value in a new additivesystem_event_cursortable, advanced only to the start time of a read that actually completed, and not advanced when a read fails — so a failed poll retries its range instead of skipping it.UserSessionBeancould never be deserialized, soSWITCH_SITEevents — emitted during ordinary admin activity — could not cross nodes. Fixed with an explicit@JsonCreator. (SystemTableUpdatedKeyEventhad the identical defect and was fixed upstream in fix(clustering): propagate system table set() cluster wide #37286; this branch defers tomainfor it.)One unreadable payload destroyed its whole batch.
getEventsSinceconverted a polled batch as a unit, so a single bad payload discarded every event in that window — silently, atDEBUG. Rows are now converted individually and an unreadable one is logged and skipped. Found by running a real two-node cluster, where it caused 39 consecutive failed polls on each node. Tracked systemically in One undeserialisable payload destroys delivery of every event in its poll window #37249.Loss is also no longer silent: warnings fire when commit lag approaches the window, when the poller stalls, and when a stale cursor is clamped, plus an authored-vs-observed reconciliation on an interval.
Delivery is at-least-once and always was — the
>=boundary already permitted repeat delivery. It is now documented indocs/backend/SYSTEM_EVENTS.mdrather than accidental, with an audit of all ten existing consumers.Checklist
MainSuite3aso they run in CIAdditional Info
Verification detail, measurements, rollback classification and known gaps are in the first comment, to keep this description readable.