Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions AGENTS.md

Large diffs are not rendered by default.

43 changes: 42 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,46 @@

## Unreleased

- Fixed Postgres self-owned writes being silently rolled back when the host
`DataSource` hands out connections with `autoCommit=false` (issue #111).
Queue pauses, execution and node heartbeats, maintenance leases, retention,
mutexes, recurring definitions/state, and the expired-dedup fallback now use
explicit Threadmill-owned transactions, restoring the connection's prior
auto-commit mode after commit or rollback. `saveAtomic`, `softDelete`,
`claimReady`, and `replaceJob` now use that same boundary instead of forcing
auto-commit on return. Migration history bootstrap and destructive schema
reset also commit explicitly. The expired-dedup fallback now keeps its
`SELECT … FOR UPDATE` lock through the delete, closing a lost-update window
that also existed on auto-commit pools.
- The recurring materializer now repairs a timing-fingerprint mismatch found
during its under-mutex definition reload (issue #112). The current task
definition wins: Threadmill preserves run bookkeeping and pending nudge
demand, recomputes timing forward from the tick, and does not fire or
`CATCH_UP` the obsolete trigger's overdue backlog. A listed fingerprint
mismatch now triggers that repair even when the stale `nextRunAt` is still in
the future, so a faster replacement schedule cannot remain dormant until the
obsolete fire time. Legacy null fingerprints are adopted without dropping or
moving an existing fire, and detected crashed edits emit an operator-visible
warning.
- Added fixed process-separated nudge simulations for Postgres and Redis
(issue #114). They hard-kill a maintenance leader after an accepted nudge
and prove the standby serves it, then hard-kill a producer after its durable
work write but before its nudge and prove the regular recurring backstop
drains the row. Cross-process JSON-lines traces record process ids,
leadership, trigger origins, and the verified event ordering. Ready markers
are atomically published and trace writes drain their buffers fully; the
fixed real-backend simulation is part of `productionCheck`. The failover
phase uses a one-minute backstop and five-minute leader poll, then edits the
task to an eight-second backstop for the producer-crash phase, eliminating
process-start timing races. Simulation work uses its own datastore namespace
and long-lived per-process connections.
- Recorded the issue #113 decision not to add per-task lifecycle generations
to the `JobStore` SPI. Issue #114 proves hard-kill handoff and backstop
recovery, while the remaining revision-reuse ABA additionally requires an
old materializer to resume after outliving its mutex lease and a same-name
delete/re-registration. A persistent generation would not replace the
existing lifecycle orderings and does not justify its cross-backend storage,
migration, and SPI cost without evidence that sequence occurs.
- Recurring tasks can declare claim-time exclusivity (issue #110). A
`CronTask` gains an `exclusive` flag, surfaced as
`@Recurring(exclusive = true)` and as an `exclusive` parameter on
Expand Down Expand Up @@ -137,7 +177,8 @@
trigger timing its `next_run_at` was computed from, written atomically with
the state row (additive Postgres migration
`V4__cron_state_timing_fingerprint.sql`; a Redis hash field; legacy rows
read as null and simply recompute once). The unchanged-schedule decision
read as null and adopt the current fingerprint without moving their recorded
timing). The unchanged-schedule decision
reads this fingerprint rather than comparing stored task definitions, so a
crash between the separate task-definition and state writes can never pair
a new trigger with old timing undetectably — the retry detects the
Expand Down
8 changes: 7 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,9 @@ tasks.register("productionCheck") {
// The correctness simulation is the gate that caught the C1
// in-memory concurrency bypass — a release candidate must run it.
":threadmill-simulation:simulate",
// The process-separated nudge simulation pins maintenance-leader
// hard-kill handoff and the documented producer crash window.
":threadmill-simulation:simulateNudge",
)
dependsOn(":threadmill-example:run")
}
Expand All @@ -240,6 +243,9 @@ tasks

subprojects {
tasks
.matching { it.name in setOf("jar", "test", "javadoc", "soak", "run") }
.matching {
it.name in setOf("jar", "test", "javadoc", "soak", "run") ||
it.name.startsWith("simulate")
}
.configureEach { mustRunAfter(cleanTask) }
}
7 changes: 4 additions & 3 deletions docs/transactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,9 +258,10 @@ closing the crash window between the caller's commit and the nudge write, and
that window is an explicit non-goal: the backstop schedule bounds the
worst-case latency, and a lost nudge costs one schedule period, never a run.

On the hot path the write is a microseconds-held autocommit update, and an
in-JVM per-task coalescer additionally bounds the store write rate to about
one round trip regardless of producer rate.
On the hot path the write is a short Threadmill-owned transaction that commits
independently of the pool's `autoCommit` default. An in-JVM per-task coalescer
additionally bounds the store write rate to about one round trip regardless of
producer rate.

## Connection-pool sharing

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
* or {@code null} for rows written before the
* fingerprint existed. Written atomically with
* {@code nextRunAt}, it lets {@code Scheduler.upsertCron}
* decide preserve-vs-recompute from this record alone —
* and {@link RecurringMaterializer} decide
* preserve-vs-recompute from this record alone —
* a crash between the separate task and state writes can
* never pair a new trigger with old timing undetectably
* @param nudgeRequestedAt when an on-demand materialization ("nudge") was most
Expand Down Expand Up @@ -83,10 +84,11 @@ public CronTaskScheduleState(
}

/**
* Convenience constructor with no timing fingerprint. A null fingerprint
* is always safe — the next re-registration recomputes the schedule
* instead of preserving it — but production writers that recompute
* {@code nextRunAt} from a task should stamp
* Convenience constructor with no timing fingerprint. A materializer tick
* adopts the current task's fingerprint without moving a non-null
* {@code nextRunAt}; if timing is also null, it initializes the next fire
* from that tick. Production writers that compute {@code nextRunAt} from a
* task should stamp
* {@link #timingFingerprintOf(CronTask)} so unchanged re-registrations
* can preserve overdue state.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Owner Author

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 reaching if (!due) at line 210 implied nudge != null. The repair block preserved it with its own if (nudge == null) return;. Adding listedFingerprintMismatch as 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 upsertCron with a changed trigger during a rolling deploy:

  1. tick() lists tasks; listed holds fingerprint F1.
  2. upsertCron on another node takes the mutex and commits both the task (F2) and the state (F2, nextRunAt = now + interval).
  3. tickOne acquires the mutex; the post-mutex state read returns F2, so due = false and nudge = null.
  4. listedFingerprintMismatch is !F1.equals(F2)true → past the guard.
  5. Reload returns F2, which matches state.timingFingerprint() → the repair block is skipped entirely, so due is never forced and nothing returns.
  6. Falls into if (!due)materializeNudge(task) → then store.clearCronNudge(task.name(), nudge) unboxes a null Long.

Two consequences, in this order:

  • A spurious recurring instance is inserted with origin=nudge, and the state is overwritten with lastRunAt=now and inFlightJobId=<spurious job> — which then blocks the next legitimate materialization via the pile-up guard until it terminates.
  • An NullPointerException at line 225, swallowed by tick()'s catch (Throwable) as Recurring tick failed for task … — i.e. logged after the damage is durable, and looking like a transient blip.

Reproduced against 02f5f75 with a throwaway test (in-memory store, ForwardingJobStore returning the stale listing, same technique as materializerReloadsTheDefinitionUnderTheTaskMutexBeforeActing):

scheduler.defineIntervalTask("racy", Duration.ofMinutes(5), ...);
CronTask stale = store.findCronTask("racy").orElseThrow();
scheduler.defineIntervalTask("racy", Duration.ofMinutes(7), ...);   // task + state both commit
new RecurringMaterializer(staleListing).tick(Instant.now());

Result:

jobs=1 origins=[nudge]
state=CronTaskScheduleState[..., nextRunAt=...+7m, inFlightJobId=019ff532-…, timingFingerprint=interval:PT7M, nudgeRequestedAt=null, nudgeRevision=null]

WARN RecurringMaterializer - Recurring tick failed for task racy
java.lang.NullPointerException: Cannot invoke "java.lang.Long.longValue()" because "nudge" is null
	at RecurringMaterializer.tickOneLocked(RecurringMaterializer.java:225)

Fix — one line, immediately after the repair block closes (line 197), i.e. once the authoritative fingerprint has had its say:

// The listed mismatch was a false alarm: the listing was stale relative to
// a definition+state pair that already agree. Nothing is owed this tick.
if (!due && nudge == null) return;

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 != null again.

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.


// 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())) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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 nextRunAt and no nudge, because line 144 returns first. An interrupted edit from a weekly interval to a one-minute interval therefore remains on the old fingerprint and can stay dormant until the obsolete weekly fire. I reproduced that shape: the state remained interval:PT168H instead of repairing to interval:PT1M. Please make a fingerprint mismatch trigger the under-mutex reload/repair even when the stale schedule is not yet due, and add a regression with a future stale nextRunAt.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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.

tickOneLocked returns at line 144 (if (!due && nudge == null) return;) before this check ever executes. So a crashed edit is only repaired if the obsolete nextRunAt happens to be in the past.

Concrete miss: task is 0 3 1 1 * (yearly), operator edits it to */5 * * * *, crash between the task write and the state write. The stale nextRunAt is next January — never due — so the mismatch is never observed and the new every-5-minutes trigger stays dormant until something re-registers the task. That is precisely the "leave the task dormant until some future re-registration" outcome the comment below says this block exists to prevent; today it's prevented in the shorten-the-interval direction only.

The fix is free: tick() already holds the fully populated CronTask from listCronTasks(), so CronTaskScheduleState.timingFingerprintOf(listed) costs nothing. Use a listed-vs-state mismatch as an additional reason to proceed past the early return; the under-mutex reload still re-checks against the authoritative definition, so a stale listed only ever costs a wasted findCronTask — the "idle ticks stay at one state read" property is preserved for the common case.

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;
}
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ void manualTriggerDoesNotStealThePileUpGuardFromARunningScheduledInstance() {

// A scheduled instance materialized earlier is still PROCESSING.
var materializer = new RecurringMaterializer(store);
store.upsertCronTaskState(
CronTaskScheduleState.initial("report", Instant.now().minusSeconds(60)));
store.upsertCronTaskState(CronTaskScheduleState.initial(
"report", Instant.now().minusSeconds(60), CronTaskScheduleState.timingFingerprintOf(task)));
materializer.tick(Instant.now());
var scheduledInstance =
store.claimReady(NodeId.newId(), "default", 1, Instant.now()).getFirst();
Expand All @@ -107,7 +107,8 @@ void manualTriggerDoesNotStealThePileUpGuardFromARunningScheduledInstance() {
afterTrigger.lastRunAt(),
afterTrigger.lastRunJobId(),
Instant.now().minusSeconds(1),
afterTrigger.inFlightJobId()));
afterTrigger.inFlightJobId(),
afterTrigger.timingFingerprint()));
materializer.tick(Instant.now());
assertThat(store.findByHandlerSignature("com.example.ReportHandler", 10))
.hasSize(2); // scheduled instance + manual trigger, nothing else
Expand Down
40 changes: 40 additions & 0 deletions threadmill-simulation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,46 @@ attempts, and periodic store snapshots. It is a production-behaviour trace
generator, not a replacement for the contract tests. The task exits non-zero
when the queue does not drain within `--drain-timeout`.

## Process-Separated Nudge Simulation

This fixed simulation starts real Postgres and Redis datastores with
Testcontainers, then runs the maintenance leader, standby, and producers in
separate JVMs. Run both backends:

```bash
./gradlew :threadmill-simulation:simulateNudge
```

The combined real-backend task is also part of the root `productionCheck`
release-candidate gate so the crash paths cannot silently rot.

Or select one:

```bash
./gradlew :threadmill-simulation:simulateNudgePostgres
./gradlew :threadmill-simulation:simulateNudgeRedis
```

The leader first registers an exclusive one-minute recurring outbox pump, with
its own maintenance poll set to five minutes, so process startup and lease
handoff cannot race the backstop or the leader's next tick. After failover is
proved, the supervisor edits the backstop to eight seconds for the producer
crash phase. It verifies two crash windows:

- A producer commits durable work and an accepted nudge. The supervisor
hard-kills the maintenance leader before its next materializer tick; the
standby must acquire the expired maintenance lease and run the pump with
`origin=nudge`.
- A second producer commits durable work, stops immediately before the nudge,
and is hard-killed. No nudge may appear for that row; the regular recurring
fire must run with `origin=schedule` and drain it.

The verifier reads a cross-process JSON-lines trace and proves the event
ordering, distinct process ids, standby ownership, trigger origins, and final
drain. Artifacts live under
`build/simulation/nudge-cross-node-<timestamp>-<backend>/`, including
`trace.jsonl` and one output log per child JVM.

## Why a separate module

`threadmill-soak` is about sustained load and operational performance
Expand Down
25 changes: 25 additions & 0 deletions threadmill-simulation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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") {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion — wire this fixed correctness simulation into a release gate. Neither check nor the root productionCheck depends on simulateNudge, so this process-separation coverage can silently rot. Both real-backend tasks completed locally in about 23 seconds; adding :threadmill-simulation:simulateNudge to productionCheck would make the new guarantee part of release-candidate validation.

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));
}
}
Loading
Loading