-
Notifications
You must be signed in to change notification settings - Fork 0
Fix recurring nudge persistence and add failover simulations #116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
28a2232
a21a36a
29bf7ed
54c9e12
02f5f75
17d99e6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,13 @@ | |
| * with carry-over. That cap — not re-registration — is the catch-up-storm | ||
| * defense. | ||
| * | ||
| * <p>If the under-mutex definition reload finds that the schedule state's | ||
| * timing fingerprint belongs to a different definition, the definition wins: | ||
| * timing is recomputed forward from the current tick and the stale schedule | ||
| * produces no firing. This completes a crashed timing edit without running a | ||
| * trigger the user already replaced. {@code CATCH_UP} resumes normally from | ||
| * the repaired timing; it never catches up the obsolete trigger's backlog. | ||
| * | ||
| * <p>If a previously-materialised instance is still un-terminal, no new | ||
| * instance is created until that one finishes. This guard prevents | ||
| * pile-up under long-running recurring work. | ||
|
|
@@ -134,24 +141,72 @@ private void tickOneLocked(CronTask listed, Instant now) { | |
| var state = stateOpt.get(); | ||
| Long nudge = state.nudgeRequestedAt() == null ? null : state.nudgeRevision(); | ||
| boolean due = state.nextRunAt() != null && !state.nextRunAt().isAfter(now); | ||
| if (!due && nudge == null) return; | ||
| boolean listedFingerprintMismatch = | ||
| !CronTaskScheduleState.timingFingerprintOf(listed).equals(state.timingFingerprint()); | ||
| if (!due && nudge == null && !listedFingerprintMismatch) return; | ||
|
|
||
| // About to act — reload the definition now that we hold the task | ||
| // mutex. The listed object was snapshotted by tick() BEFORE the | ||
| // mutex: a re-registration, edit, or disable can commit in between, | ||
| // and materializing from the stale object would insert the old | ||
| // handler/payload (and, for a nudge, consume a request that was made | ||
| // against the new definition). The reload is deliberately done only | ||
| // when a materialization is imminent, so idle ticks stay at one | ||
| // state read per task. | ||
| // when a materialization is imminent or the listed definition already | ||
| // proves the timing state is stale, so ordinary idle ticks stay at one | ||
| // state read per task while future stale schedules self-heal promptly. | ||
| CronTask task = store.findCronTask(listed.name()).orElse(null); | ||
| if (task == null || !task.enabled()) return; | ||
|
|
||
| String fingerprint = CronTaskScheduleState.timingFingerprintOf(task); | ||
| boolean timingStateChanged = false; | ||
| if (!fingerprint.equals(state.timingFingerprint())) { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking — repair mismatches before the due/nudge early return. This comparison is never reached when the stale state has a future
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking — the repair only runs when the stale timing says a run is due.
Concrete miss: task is The fix is free: |
||
| String previousFingerprint = state.timingFingerprint(); | ||
| boolean legacyTiming = previousFingerprint == null && state.nextRunAt() != null; | ||
| // A non-null mismatch is the crash signature for a timing edit | ||
| // that wrote the definition before its schedule state. Finish it | ||
| // by scheduling forward from this tick: firing the stale timing | ||
| // would run a trigger the user already replaced. A legacy null | ||
| // fingerprint does not prove an edit, so adopt the fingerprint | ||
| // without dropping or moving an already-recorded firing. | ||
| Instant next = legacyTiming ? state.nextRunAt() : task.trigger().nextAfter(now, task.zone()); | ||
| if (!legacyTiming) due = false; | ||
| state = new CronTaskScheduleState( | ||
| task.name(), | ||
| state.lastRunAt(), | ||
| state.lastRunJobId(), | ||
| next, | ||
| state.inFlightJobId(), | ||
| fingerprint, | ||
| // These cells are carried in the in-memory record only; | ||
| // upsertCronTaskState deliberately never writes them. | ||
| state.nudgeRequestedAt(), | ||
| state.nudgeRevision()); | ||
| timingStateChanged = true; | ||
| if (previousFingerprint != null) { | ||
| LOG.warn( | ||
| "Repairing stale recurring timing for task {} from fingerprint {} to {}; next run at {}", | ||
| task.name(), | ||
| previousFingerprint, | ||
| fingerprint, | ||
| next); | ||
| } | ||
| if (nudge == null && !due) { | ||
| store.upsertCronTaskState(state); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| // The listed definition may simply be stale relative to an | ||
| // authoritative definition and state that already agree. In that | ||
| // case no scheduled firing or nudge is owed this tick. | ||
| if (!due && nudge == null) return; | ||
|
|
||
| // Pile-up guard: an in-flight instance that is still going to run | ||
| // blocks the next materialization. | ||
| if (state.inFlightJobId() != null) { | ||
| Job inFlight = store.findById(JobId.of(state.inFlightJobId())).orElse(null); | ||
| if (inFlight != null && blocksNextMaterialization(inFlight, now)) { | ||
| if (timingStateChanged) store.upsertCronTaskState(state); | ||
| // Still running — leave the next_run_at where it is so we revisit on the next tick. | ||
| return; | ||
| } | ||
|
|
@@ -166,7 +221,7 @@ private void tickOneLocked(CronTask listed, Instant now) { | |
| // represents no schedule tick), only the nudge origin marker. | ||
| JobId id = materializeNudge(task); | ||
| store.upsertCronTaskState(new CronTaskScheduleState( | ||
| task.name(), now, id.asUuid(), state.nextRunAt(), id.asUuid(), state.timingFingerprint())); | ||
| task.name(), now, id.asUuid(), state.nextRunAt(), id.asUuid(), fingerprint)); | ||
| // Clear AFTER materializing (a crash between the two costs one | ||
| // extra run, never a lost one — see the failure-semantics note in | ||
| // the class Javadoc), and only the observed revision — a nudge | ||
|
|
@@ -176,7 +231,6 @@ private void tickOneLocked(CronTask listed, Instant now) { | |
| return; | ||
| } | ||
|
|
||
| String fingerprint = CronTaskScheduleState.timingFingerprintOf(task); | ||
| if (task.missedRunPolicy() == CronTask.MissedRunPolicy.CATCH_UP) { | ||
| // Materialize every fire from nextRunAt up to and including now, | ||
| // capped per tick so an unbounded backlog cannot occupy the | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ dependencies { | |
| // Per-backend simulation tasks. The base `simulate` runs all three sequentially. | ||
| val simulationMainClass = "com.hemju.threadmill.simulation.SimulationMain" | ||
| val workerChurnMainClass = "com.hemju.threadmill.simulation.workerchurn.WorkerChurnSimulatorMain" | ||
| val nudgeSimulationMainClass = "com.hemju.threadmill.simulation.nudge.NudgeSimulationMain" | ||
|
|
||
| tasks.register<JavaExec>("simulateMemory") { | ||
| group = "verification" | ||
|
|
@@ -71,3 +72,27 @@ tasks.register<JavaExec>("simulateWorkerChurnRedis") { | |
| mainClass.set(workerChurnMainClass) | ||
| args = listOf("--backend", "redis") | ||
| } | ||
|
|
||
| tasks.register<JavaExec>("simulateNudgePostgres") { | ||
| group = "verification" | ||
| description = | ||
| "Run the process-separated leader-kill and producer-kill nudge simulation against PostgreSQL." | ||
| classpath = sourceSets["main"].runtimeClasspath | ||
| mainClass.set(nudgeSimulationMainClass) | ||
| args = listOf("--backend", "postgres") | ||
| } | ||
|
|
||
| tasks.register<JavaExec>("simulateNudgeRedis") { | ||
| group = "verification" | ||
| description = | ||
| "Run the process-separated leader-kill and producer-kill nudge simulation against Redis." | ||
| classpath = sourceSets["main"].runtimeClasspath | ||
| mainClass.set(nudgeSimulationMainClass) | ||
| args = listOf("--backend", "redis") | ||
| } | ||
|
|
||
| tasks.register("simulateNudge") { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion — wire this fixed correctness simulation into a release gate. Neither |
||
| group = "verification" | ||
| description = "Run the process-separated nudge simulation against PostgreSQL and Redis." | ||
| dependsOn("simulateNudgePostgres", "simulateNudgeRedis") | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| package com.hemju.threadmill.simulation.nudge; | ||
|
|
||
| import java.nio.file.Path; | ||
| import java.util.Map; | ||
|
|
||
| import com.hemju.threadmill.core.handler.JobExecutionContext; | ||
| import com.hemju.threadmill.core.handler.JobHandler; | ||
|
|
||
| /** Recurring outbox-pump handler used by the process-separated nudge simulation. */ | ||
| public final class NudgeSimulationHandler implements JobHandler<NudgeSimulationPayload> { | ||
|
|
||
| @Override | ||
| public void run(NudgeSimulationPayload payload, JobExecutionContext context) { | ||
| var trace = Path.of(payload.traceFile); | ||
| var pid = ProcessHandle.current().pid(); | ||
| var jobId = context.jobId().toString(); | ||
| var origin = context.cronOrigin().orElse("unknown"); | ||
| NudgeSimulationTrace.append( | ||
| trace, | ||
| "pump-run-start", | ||
| Map.of( | ||
| "runId", payload.runId, | ||
| "pid", pid, | ||
| "nodeId", context.nodeId().toString(), | ||
| "jobId", jobId, | ||
| "origin", origin)); | ||
|
|
||
| try (var workStore = NudgeSimulationStores.openProcessWorkStore(payload.runId)) { | ||
| for (var sequence : workStore.drain()) { | ||
| NudgeSimulationTrace.append( | ||
| trace, | ||
| "work-drained", | ||
| Map.of( | ||
| "runId", payload.runId, | ||
| "sequence", sequence, | ||
| "pid", pid, | ||
| "nodeId", context.nodeId().toString(), | ||
| "jobId", jobId, | ||
| "origin", origin)); | ||
| } | ||
| } | ||
|
|
||
| NudgeSimulationTrace.append( | ||
| trace, | ||
| "pump-run-finish", | ||
| Map.of( | ||
| "runId", payload.runId, | ||
| "pid", pid, | ||
| "nodeId", context.nodeId().toString(), | ||
| "jobId", jobId, | ||
| "origin", origin)); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Blocking — this fix broke the invariant that guarded the nudge branch.
Before this commit,
if (!due && nudge == null) return;meant that reachingif (!due)at line 210 impliednudge != null. The repair block preserved it with its ownif (nudge == null) return;. AddinglistedFingerprintMismatchas a third way past the guard breaks that: a stale listing alone now admits a tick with neither a due fire nor a nudge, and the repair block doesn't catch it because it only runs when the reloaded fingerprint mismatches.The reachable path is the exact race the reload exists to handle — a concurrent
upsertCronwith a changed trigger during a rolling deploy:tick()lists tasks;listedholds fingerprintF1.upsertCronon another node takes the mutex and commits both the task (F2) and the state (F2,nextRunAt = now + interval).tickOneacquires the mutex; the post-mutex state read returnsF2, sodue = falseandnudge = null.listedFingerprintMismatchis!F1.equals(F2)→ true → past the guard.F2, which matchesstate.timingFingerprint()→ the repair block is skipped entirely, sodueis never forced and nothing returns.if (!due)→materializeNudge(task)→ thenstore.clearCronNudge(task.name(), nudge)unboxes a nullLong.Two consequences, in this order:
origin=nudge, and the state is overwritten withlastRunAt=nowandinFlightJobId=<spurious job>— which then blocks the next legitimate materialization via the pile-up guard until it terminates.NullPointerExceptionat line 225, swallowed bytick()'scatch (Throwable)asRecurring tick failed for task …— i.e. logged after the damage is durable, and looking like a transient blip.Reproduced against
02f5f75with a throwaway test (in-memory store,ForwardingJobStorereturning the stale listing, same technique asmaterializerReloadsTheDefinitionUnderTheTaskMutexBeforeActing):Result:
Fix — one line, immediately after the repair block closes (line 197), i.e. once the authoritative fingerprint has had its say:
It has to sit after the repair block — putting it before would re-open the dormant-schedule case this commit just fixed. The three paths that reach line 210 then all satisfy
due || nudge != nullagain.Worth a named regression test too, since none of the five new tests covers it — they all arrange a mismatch that the reload confirms. Something like
staleListingWhoseDefinitionAlreadyAgreesWithStateMaterializesNothing.