Skip to content

feat: on-demand materialization (nudge) for recurring tasks - #109

Merged
hemju merged 8 commits into
mainfrom
feat/108-recurring-nudge
Aug 11, 2026
Merged

feat: on-demand materialization (nudge) for recurring tasks#109
hemju merged 8 commits into
mainfrom
feat/108-recurring-nudge

Conversation

@hemju

@hemju hemju commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Closes #108.

What this adds

Scheduler.nudgeRecurring(taskName) (Spring: JobScheduler.nudgeRecurring) — on-demand materialization for registered recurring tasks, so outbox-style pollers can run wake-driven with a slow self-healing backstop schedule instead of polling every few seconds.

Design (as resolved in the issue discussion)

Durable flag consumed by the materializer — never a bypass lane. A nudge is one write of nudge_requested_at on the task's schedule state. The maintenance master's existing per-task recurring tick observes it alongside next_run_at (zero extra steady-state store cost), materializes one instance through the normal machinery, and clears the cell with a compare-and-clear on the observed timestamp. That single mechanism carries all four behavioral requirements:

  • Run-after-wake: the pile-up guard defers a nudge while an instance is in flight; the flag survives to the tick after that instance terminates, so the in-flight run — which may have read the work table before the nudge's triggering write committed — never counts as satisfying it. A nudge accepted between the materializer's read and its clear has a newer timestamp and survives (the client-go workqueue rule). Ordering is materialize-then-clear: a crash between the two costs one extra run, never a lost one.
  • Coalescing: one cell per task — a burst overwrites one value and produces at most the current run plus one follow-up (Temporal's BufferOne semantics). A tick that materializes a due scheduled fire also satisfies an observed nudge.
  • Durability over signaling: there is no transient signal at all — acceptance from any node is a store write; the maintenance tick (default 1 s) bounds latency. Nothing can be dropped.
  • Schedule non-interference: next_run_at is never touched by a nudge. Cron keeps its wall-clock grid; an interval's phase is preserved (every-6h task last fired 06:00, nudged 07:00 → next regular run stays 12:00). Nudged instances carry no CRON_FIRE_TIME_META (they represent no schedule tick).

Edges: unknown task → IllegalArgumentException; disabled task → IllegalStateException (explicit pause wins); an enabled-flip clears any pending nudge (consistent with #106's re-enable-does-not-catch-up); a nudge racing task removal cannot resurrect schedule state (Redis: task-existence check in the same Lua script; Postgres: FK).

Clobber safety: the nudge cell is written only by the two new store ops. upsertCronTaskState deliberately preserves it on every backend — Postgres leaves the column out of the upsert, Redis's DEL+HSET overwrite script carries the field across, in-memory merges — so re-registrations, materializer bookkeeping, and dashboard edits cannot clobber a concurrently accepted nudge.

Spring transaction modes (threadmill.spring.enqueue-mode):

  • after_commit (default): validation fails fast at call time; the write fires in afterCommit, a rollback discards it. The residual crash window is covered by the backstop schedule (explicit non-goal in the issue).
  • join_transaction: the nudge write rides the caller's SQL transaction via the store's PostgresTransactionBoundary — committed with the work row, no crash window. Documented caveat: it holds the schedule-state row lock until the caller commits, so hot-path producers should prefer after_commit.
  • immediate: direct write.

Producer-burst protection: NudgeCoalescer single-flights nudge writes per task per JVM. Callers arriving during an in-flight write share one follow-up write that starts after they arrived — joining the in-flight write would be the classic single-flight-without-rerun bug (its commit can predate a joiner's own triggering commit). Failures propagate to every covered caller, so acceptance is never reported for a write that did not commit. Bypassed in join_transaction mode, where coalescing across open transactions would bind one caller's nudge to another's commit.

Observability: all three materialization paths now stamp threadmill.cron.origin = schedule / nudge / manual; JobExecutionContext.cronOrigin() reads it. The dashboard's triggerRecurring stays the separate operator force lane, unchanged.

Storage

  • Postgres: additive migration V5__cron_state_nudge.sql (nudge_requested_at TIMESTAMPTZ, deliberately unindexed so nudge writes stay HOT-eligible). Accept is one guarded UPDATE … FROM statement on the hot path.
  • Redis: a field in the schedule-state hash; accept and compare-and-clear are single Lua scripts; the state-upsert script preserves the field across its overwrite.
  • New JobStore SPI: requestCronNudge(name, at)ACCEPTED | UNKNOWN_TASK | DISABLED, clearCronNudge(name, observed). CronTaskScheduleState gains a read-only nudgeRequestedAt component. TracingJobStore and ForwardingJobStore updated.

Tests

Every acceptance bullet from the issue is a deterministic test (durable state + explicit materializer.tick(now) — no timing-sensitive signal path exists):

  • Contract suite (identical on in-memory, Postgres, Redis): nudgeRoundTripsAndSurvivesBlanketStateUpserts, nudgeOnUnknownTaskIsRejectedWithoutResurrectingState, nudgeOnDisabledTaskIsRejected, clearCronNudgeOnlyClearsTheObservedValue.
  • SchedulingTest: nudgeMaterializesPromptlyWithoutTouchingTheSchedule, nudgeDuringAnInFlightRunProducesExactlyOneFollowUpAfterCompletion (burst during in-flight → exactly one follow-up), nudgeCoalescesIntoADueScheduledFire, nudgeInstanceTakesThePileUpGuardSoAScheduledFireWaits, nudgeUnknownTaskFailsLoudly, nudgeDisabledTaskFailsLoudlyAndDoesNotRun, reEnablingATaskClearsAPendingNudgeFromBeforeThePause.
  • NudgeCoalescerTest: burst-against-in-flight collapses to one follow-up write; failure propagates and the slot recovers.
  • Spring: nudgeInsideTransactionTakesEffectOnlyAfterCommit, nudgeInsideRolledBackTransactionIsDiscarded, nudgeValidationFailsFastInsideTheTransactionNotAtCommit, and (Testcontainers, real Postgres) nudgeJoinsTheCallerTransactionAndRollsBackWithIt.
  • Dashboard: manualTriggerStampsTheManualOriginMarker, updateRecurringEnabledFlipClearsAPendingNudge.
  • Migration-count regression tests updated for V5.

Docs: new "Nudging a recurring task (wake-driven pollers)" section in docs/transactions.md, docs/handlers.md (cronOrigin()), docs/postgres-schema.md, Spring README, CHANGELOG, and AGENTS.md (vocabulary, design decision, regression-matrix rows).

Full ./gradlew check green, including the Testcontainers-backed Postgres and Redis suites.

Closes #108. Scheduler.nudgeRecurring(taskName) — and its Spring
counterpart honoring threadmill.spring.enqueue-mode — lets producers ask
a registered recurring task to materialize an instance as soon as
possible, so outbox-style pollers run wake-driven with a slow backstop
schedule instead of polling every few seconds.

The nudge is a durable nudge_requested_at cell on the schedule state,
consumed by the materializer's existing per-task tick through the normal
machinery (pile-up guard, missed-run policy) and cleared with a
compare-and-clear on the observed value. That yields run-after-wake (a
nudge during an in-flight run produces exactly one follow-up after it
completes), structural burst coalescing (current run + one follow-up),
durability without any transient signal (worst case one
maintenancePollInterval), and schedule non-interference (next_run_at is
never touched; interval phase preserved).

Storage: additive Postgres migration V5__cron_state_nudge.sql; a Redis
schedule-state hash field preserved across the state-upsert overwrite
script; new JobStore SPI ops requestCronNudge / clearCronNudge with
UNKNOWN_TASK / DISABLED outcomes (explicit pause wins; enabled-flips
clear pending nudges; a nudge racing removal cannot resurrect state).
Producer bursts are additionally bounded by an in-JVM per-task
single-flight coalescer whose joiners share a follow-up write that
starts after they arrived. All three materialization paths now stamp
threadmill.cron.origin (schedule / nudge / manual).

@hemju hemju left a comment

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.

Deep review of all 31 changed files at 23fa69a, including the issue design, all three stores, core scheduling, Spring transaction modes, dashboard/API, metrics, tests, and docs.

I would not merge this revision yet. The main blockers are:

  • the timestamp-based compare-and-clear token can erase a newer accepted nudge;
  • materialization can consume a nudge using a task definition captured before its mutex;
  • enabled flips and task lifecycle changes are not linearizable/crash-safe;
  • PostgreSQL's slow path can poison a joined host transaction;
  • the promised dashboard + metrics origin visibility is not implemented;
  • the in-memory implementation can return ACCEPTED and then lose the request during delete/re-register ABA.

Important follow-ups are failure atomicity versus the hard coalescing guarantee, coalescer leader starvation, and the breaking JobStore SPI addition.

CI and the affected local suites are green, but the problematic paths are race/failure interleavings not exercised by the current tests. Suggested deterministic regressions: same persisted token around read → materialize → second nudge → clear; task edit between list and mutex; nudge versus enable/disable/delete/re-register; PostgreSQL no-state deletion under join_transaction; failure injection after insert/state/clear; real cross-client Postgres/Redis execution; dashboard/Micrometer origin visibility; and sustained coalescer arrivals.

* Clear a pending nudge, but only if its current
* {@link CronTaskScheduleState#nudgeRequestedAt()} still equals
* {@code observed} (compare-and-clear). A nudge accepted between the
* caller's read and this clear has a newer timestamp and survives, so the

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.

[P1] Use a unique generation, not the timestamp, as the CAS identity. This assumes every later accepted nudge has a different/newer stored timestamp. Scheduler uses Instant.now(), Redis truncates it to epoch milliseconds, PostgreSQL has finite timestamp resolution, and in-memory accepts identical caller-supplied instants. If nudge B is accepted with the same stored value after materialization but before clearCronNudge, this clear erases B even though the inserted job predates it, violating run-after-wake. Keep requestedAt for observability, but compare-and-clear a store-generated monotonic revision or collision-free nonce; add a same-token contract regression.

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.

Fixed — the CAS identity is now a store-generated nudge_revision: strictly monotonic, advanced on every acceptance, never reset (a clear removes only the pending flag), so no two acceptances ever share an identity even at identical timestamps. clearCronNudge(name, observedRevision) compares the revision; nudge_requested_at is retained for observability only. Postgres generates it via COALESCE(nudge_revision, 0) + 1 in the single conditional statement, Redis via HINCRBY in the accept script, in-memory under the lifecycle lock. Same-token contract regression added on all three backends: nudgeAcceptancesWithIdenticalTimestampsAreDistinguishable (same-instant double accept; the first observation's clear must not erase the second).

// regular wall-clock match and an interval trigger's phase is
// preserved. The instance carries no nominal fire time (it
// represents no schedule tick), only the nudge origin marker.
JobId id = materializeNudge(task);

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.

[P1] Reload the task after acquiring its mutex. tick() snapshots each CronTask before tickOne takes the per-task mutex. Definition A can therefore be listed, definition/state B can be committed under the mutex, and a nudge for B can be accepted before this line executes with the stale A object. This inserts A's handler/payload and then clears B's request. Reload findCronTask, recheck enabled, and validate/repair the timing fingerprint after locking and before materialization.

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.

Fixed — tickOneLocked now reloads findCronTask under the mutex and rechecks enabled before any materialization (nudge or scheduled), so a stale listed snapshot can never be inserted nor consume a nudge made against the new definition. The reload happens only when a materialization is imminent, keeping idle ticks at one state read per task. Deterministic regressions simulate the exact interleaving with a stale-listing ForwardingJobStore: materializerReloadsTheDefinitionUnderTheTaskMutexBeforeActing (fresh handler materialized, not the listed one) and materializerRechecksEnabledUnderTheTaskMutexBeforeActing. One scope call: I did not add fingerprint validation/repair here — a mismatched fingerprint is the pre-existing crashed-edit signature and choosing skip-vs-recompute changes scheduled-fire behavior beyond this PR; happy to take that as a follow-up issue.

// enable direction is legitimate new demand.
boolean enabledFlipped =
existingTask.isPresent() && existingTask.get().enabled() != task.enabled();
if (enabledFlipped

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.

[P1] Make the enabled flip and nudge reset atomic/crash-safe. A nudge can commit after existingState was read but before disable commits, so this only clears the older observation. In the other direction, a crash after upsertCronTask(enabled=true) but before this clear/recompute leaves stale pre-pause demand executable; retry sees the task as already enabled and loses the flip information. This needs a store-level transition or persisted definition generation. For re-enable, a safe ordering is to clear/recompute while the task is still disabled and enable it last.

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.

Fixed via crash-safe ordering rather than a new store transition. Re-enable now clears the pending nudge and writes the recomputed state WHILE THE TASK IS STILL DISABLED, and flips enabled last — a crash anywhere before the final task write leaves the task disabled, so the retry re-detects the flip and repeats; stale pre-pause demand can never become executable. Disable persists the disabled definition first; a crash before the clear leaves the nudge parked on a disabled task, which the materializer's new under-mutex enabled recheck refuses to run and the re-enable path clears. The nudge-after-state-read race in the disable direction resolves the same way (parked, unrunnable, cleared before re-enable); in the enable direction a post-read nudge can only have been accepted once the task was already enabled, which makes it legitimate new demand. I chose ordering over a persisted definition-generation because it needs no SPI growth — if you want lifecycle generations as a general mechanism, I'd rather design that in its own issue.

// An enabled-flip clears a pending nudge: disabling wins over a
// nudge, and re-enabling must not fire stale demand from before
// the pause (consistent with re-enable-does-not-catch-up).
if (existing.enabled() != task.enabled()) {

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.

[P1] Determine the flip from state read inside the mutex. existing is loaded before withTaskMutex. Two concurrent enable requests can both read disabled; the first enables, a producer's nudge is accepted, and the second later enters the mutex and clears that legitimate post-enable request because its snapshot is stale. Reload/build the effective task and decide the flip inside the mutex, ideally through the same atomic transition used by Scheduler.

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.

Fixed — updateRecurring now loads the existing definition, builds the effective task, reads prior state, and decides the flip entirely INSIDE withTaskMutex (only a fail-fast not-found probe remains outside). Two concurrent enable requests serialize on the mutex and the second sees enabled=true, so it no longer misclassifies itself as a flip and cannot clear a legitimate post-enable nudge. The same enable-last / disable-first ordering as Scheduler.upsertCron applies (see the sibling thread).

ps.executeUpdate();
return NudgeOutcome.ACCEPTED;
} catch (SQLException e) {
if (DeadlockRetry.hasSqlState(e, "23503")) return NudgeOutcome.UNKNOWN_TASK;

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.

[P1] Do not translate this FK violation without restoring the transaction. PostgreSQL has already marked the transaction aborted when 23503 is caught. Under join_transaction, this is the caller's host connection, so returning UNKNOWN_TASK leaves unrelated business writes doomed to roll back. The preceding SELECT enabled can also race a disable before the insert. Prefer error-free conditional SQL (INSERT … SELECT … WHERE enabled) or locking; if an exception remains necessary, use a savepoint and roll back to it before translating.

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.

Fixed — the accept is now one error-free conditional statement: INSERT INTO threadmill_cron_task_state (…) SELECT t.name, ?, 1 FROM threadmill_cron_tasks t WHERE t.name = ? AND t.enabled ON CONFLICT (task_name) DO UPDATE SET …. Sourcing the insert from the parent row itself makes an FK violation unreachable, and the ON CONFLICT arm is the plain-UPDATE hot path (columns still unindexed, HOT-eligible), so no exception is ever caught inside a joined host transaction. Zero rows → a follow-up SELECT enabled decides UNKNOWN vs DISABLED; if a racing flip re-enabled the task between the two statements, the conditional write is retried (bounded at 3) instead of misreporting. The disable-races-insert window is gone with it — the enabled check and the write are one statement. Covered by the join-mode Testcontainers test plus the contract suite on real Postgres.

Comment thread CHANGELOG.md Outdated
component.
- Recurring instances now carry `threadmill.cron.origin` metadata
(`schedule` / `nudge` / `manual`) so schedule-fired, nudged, and
dashboard-force-triggered instances are distinguishable in the dashboard

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.

[P1] This observability claim is not implemented under normal configuration. The PR stamps metadata, but threadmill-metrics still uses untagged processed/failed/timing meters; JobSummary has no origin field; and secure-default dashboard reads redact metadata entirely. The UI only dumps raw metadata for privileged detail reads. Please add a non-sensitive origin field/badge plus a bounded-cardinality origin=schedule|nudge|manual|none metric dimension, with API/UI/Micrometer tests, or narrow this claim.

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.

Implemented rather than narrowed, with one honest exception. Added: JobSummary.cronOrigin — a closed three-value field deliberately visible on redacted read-level views (nothing outside schedule|nudge|manual passes the filter, so arbitrary metadata can never leak through it; test redactedJobSummariesStillCarryTheCronOrigin) — and Micrometer threadmill.jobs.recurring.runs{origin=schedule|nudge|manual|other} with unrecognized values clamped to other (test recurringRunsCounterTagsTheTriggerOriginWithBoundedCardinality). The RecurringTaskView also now carries the pending nudgeRequestedAt/nudgeRevision via the embedded schedule state. The CHANGELOG states exactly this and notes the shipped React UI does not render the new field yet — I kept the npm-built UI out of this PR deliberately; say the word if you want the badge included here.

// deleteCronTask removes the task before the state, so re-check and
// undo the write if the task vanished mid-flight. Only our own write
// is undone — a concurrent re-registration's fresh state survives.
if (!cronTasks.containsKey(taskName)) {

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.

[P1] This name-only recheck does not prevent delete/re-register ABA. deleteCronTask removes the task and state from separate maps. A deletion can remove the old task, a same-name replacement can be registered, this request can write its nudge, and the old deletion can then remove that state; containsKey sees the replacement and returns ACCEPTED even though the request was lost. Make task/state lifecycle operations linearizable with one lock or generation-tagged entries.

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.

Fixed — cron task/state lifecycle mutations (task upsert, delete, state upsert, nudge accept, nudge clear) are now linearized under a single cronLifecycleLock; the name-only recheck/undo is gone. The existence+enabled check and the nudge write are one atomic step, so the delete/re-register ABA can no longer strand an ACCEPTED nudge — and, incidentally, a slow delete can no longer erase a same-name re-registration's fresh state either (the same pre-existing ABA, one level up). Reads stay lock-free on the concurrent maps; these are control-plane operations, so the lock adds no data-path contention.

JobId id = materializeNudge(task);
store.upsertCronTaskState(new CronTaskScheduleState(
task.name(), now, id.asUuid(), state.nextRunAt(), id.asUuid(), state.timingFingerprint()));
// Clear AFTER materializing (a crash between the two costs one

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.

[P2] The public at-most coalescing bound is stronger than these failure semantics. Job insert, state upsert, and compare-clear are independent durable operations. A crash/ambiguous outage after insert leaves the nudge pending, so recovery can insert another job; repeated partial failures can exceed ‘current run plus one follow-up.’ Either combine insertion, state advancement, and generation clearing into an atomic store operation, or qualify the bound as failure-free/best-effort under Threadmill's at-least-once model.

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.

Qualified everywhere rather than made atomic: RecurringMaterializer class Javadoc (new "Nudge failure semantics" paragraph), Scheduler.nudgeRecurring Javadoc, docs/transactions.md, CHANGELOG, and AGENTS.md now state the bound is failure-free — a crash or outage between the follow-up's insert and the request's clear produces an extra run, never a lost one, consistent with at-least-once. Plus a deterministic failure-injection regression: aFailedNudgeClearProducesAnExtraRunNeverALostOne (clear throws once → nudge stays pending → exactly one extra run after the instance terminates, then the retried clear lands). I kept the three independent writes: a combined atomic materialize-and-advance-and-clear store operation would be significant SPI surface to close a window whose worst case is one duplicate run of an already-required-idempotent handler.

}
}
if (writer) {
drive(taskName, slot, write);

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.

[P2] Sustained traffic can retain the original caller indefinitely. The first caller synchronously drives every promoted generation; as long as each store round trip collects another follow-up, drive never returns, even though this caller's own future completed after the first write. That can retain an application request or Spring afterCommit thread until a lull. Transfer leadership to a waiter or decouple the serialized driver from producer threads, and add a continuous-arrival fairness test. Also note that the current scope is per Scheduler instance, not per JVM.

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.

Fixed — drive now executes exactly one generation on the calling thread; a promoted follow-up is handed to a dedicated virtual thread (threadmill-nudge-coalescer), so no application request or Spring afterCommit callback is ever retained past its own covering write, no matter how sustained the arrivals. Fairness regression added: theFirstCallerIsNotRetainedToDriveFollowUpGenerations — the first caller joins successfully while generation 2's write is still latched open and a generation-3 joiner is already queued. The Javadoc now states the per-scheduler-instance (not per-JVM) scope explicitly and why that only loosens the write-rate bound, never correctness.

* {@link NudgeOutcome#DISABLED} when the task is disabled — an explicit
* pause wins over a nudge.
*/
NudgeOutcome requestCronNudge(String taskName, Instant requestedAt);

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.

[P2] This is a breaking public SPI addition. Existing third-party JobStore implementations no longer compile until both methods are added, and old binaries can fail with AbstractMethodError when nudging. If that is intentional for the pre-1.0 release, mark it explicitly as breaking and provide migration guidance; otherwise consider a versioned capability/default compatibility strategy.

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.

Documented as explicitly breaking — the CHANGELOG now carries a dedicated "Breaking (SPI)" entry with the failure mode for stale binaries (AbstractMethodError on nudge) and the implementation contract for store authors: atomic existence+enabled check on accept, store-generated monotonic never-reset revision, revision-compare-and-clear that leaves the revision untouched, upsertCronTaskState preservation of both nudge fields, and no state resurrection for deleted tasks — all pinned by the shared contract suite. I deliberately kept the methods abstract rather than defaulted: a throwing/no-op default would compile everywhere and then silently break run-after-wake on third-party stores at runtime, which is strictly worse than a loud compile error pre-1.0.

…tx-safe accept

Addresses all ten findings from the first review of #109:

- Compare-and-clear identity is now a store-generated, never-reset
  nudge_revision (Postgres column, Redis HINCRBY field, in-memory under
  lock) instead of the collision-prone wall-clock timestamp; same-instant
  acceptances stay distinguishable (new contract regression).
- The materializer reloads the task definition under the per-task mutex
  before acting, so an edit or disable committing between the tick's
  listing and the mutex can neither materialize a stale definition nor
  consume a nudge made against the new one (stale-listing regressions).
- Enabled flips are crash-safe by ordering: disable persists the task
  first and clears after; re-enable clears and recomputes state while
  still disabled and flips enabled last. The dashboard decides flips
  from reads taken inside the task mutex.
- Postgres accept is one error-free INSERT..SELECT..ON CONFLICT
  statement — no FK violation can poison a join_transaction caller's
  host transaction; a racing enable retries instead of misreporting.
- In-memory cron lifecycle mutations are linearized under one lock,
  removing the delete/re-register ABA that could strand an ACCEPTED
  nudge.
- Origin visibility is implemented, not just claimed: JobSummary
  carries cronOrigin on redacted reads (closed value set) and
  threadmill.jobs.recurring.runs{origin=...} counts runs with clamped
  cardinality.
- The coalescer hands follow-up generations to a dedicated virtual
  thread (no caller retention; fairness regression), documents its
  per-scheduler-instance scope, and the coalescing bound is documented
  as failure-free with a clear-failure injection regression.
- The JobStore SPI addition is marked explicitly breaking in the
  CHANGELOG with the implementor contract.
@hemju

hemju commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Review round 1 addressed in f6960c1 — all ten findings were valid; each thread has a detailed reply. The load-bearing changes: compare-and-clear now uses a store-generated never-reset nudge_revision instead of the timestamp; the materializer reloads the definition under the task mutex before acting; enabled flips are crash-safe by ordering (re-enable flips enabled last); the Postgres accept is a single error-free INSERT…SELECT…ON CONFLICT (no FK catch inside a joined host transaction); in-memory cron lifecycle is linearized under one lock; origin visibility is actually implemented (JobSummary.cronOrigin on redacted reads + threadmill.jobs.recurring.runs{origin=…}); the coalescer never retains a caller past its own write; the failure-free coalescing bound and the breaking SPI addition are documented as such.

Every suggested deterministic regression is in: same-persisted-token clear race, task-edit/disable between list and mutex (stale-listing store), nudge vs enable/disable/delete/re-register, clear-failure injection, dashboard + Micrometer origin visibility, and sustained coalescer arrivals — plus the join-transaction rollback test on real Postgres. Full ./gradlew check green including the Testcontainers suites.

hemju added 6 commits August 10, 2026 23:21
The harness had no coverage of the nudge path, so an endurance run would
have proven only that the pre-existing paths still work. The new
nudge-pump scenario models the outbox shape the feature exists for: a
recurring task whose own schedule is a slow ten-minute backstop,
producers appending work rows and nudging it, plus background load so
the recurring machinery competes with a busy dispatcher.

Two invariants encode the issue's guarantees directly. nudgeRunAfterWake
holds the engine to at least one run starting after every accepted
nudge, judged on handler-emitted brackets and with O(1) state because
satisfaction is monotone in time. outboxDrainedByLaterRun is the
end-to-end statement — a row appended before a run started must be
drained by the time that run finishes — which is what a swallowed nudge
would break. Both ship with red-path tests, since a checker that cannot
fail is worse than none.
…rved

The 90s dual-backend pre-flight failed the run-after-wake completeness
check on a healthy engine: the harness drain phase waits for active
jobs, and a nudge that has not been materialized yet is not a job, so
the nodes stopped 204ms after the final nudge — well inside the one
second the maintenance tick needs. The scenario now counts pump
executions and waits (bounded, inside its drain budget) for a run that
starts after its final nudge. The check keeps its teeth: if no run
arrives within the window the invariant still fails.
The first 6h endurance run died with the laptop battery: both backend
JVMs froze at 23:27:39 and resumed 5h54m later, identical to sub-second
precision. Postgres happened to have a nudge outstanding at that instant,
so the staleness clock charged the whole outage to the engine and
reported a violation on a healthy run; Redis passed only because its
last nudge had already been served. The outbox invariant passed on both,
and the pending nudge was served on resume — no work was ever lost.

Trace silence longer than a minute cannot happen while the harness is
alive (the producer emits continuously), so such a gap is now recognised
as a frozen process and excused by shifting the outstanding nudge's clock
forward rather than clearing it: service is still required after resume.
A stall with steady background events still fires, which the superseded
two-event test could not distinguish.
…saction

Two ergonomics/scaling defects surfaced while explaining the API.

A joined nudge held the task's single schedule-state row lock for the
whole business transaction, so every concurrent producer of that task
serialized behind it — silently, correct at low rate and collapsing
under load, with new lock-ordering deadlock shapes. All it bought was
closing the crash window between commit and the nudge write, which
issue #108 explicitly declares a non-goal because the backstop schedule
bounds it. Nudges are now after-commit in every enqueue mode
(DeferredNudge), which keeps rollback semantics identical and holds the
row lock for microseconds. The join-mode test now asserts both halves,
including that a second connection can take the row lock with NOWAIT
while the nudging transaction is still open.

A @Recurring task's durable identity defaults to the handler's
fully-qualified class name, so nudging by string forced callers to
hard-code it and broke on renames — contradicting this module's own
handler-class-first API principle. Added
nudgeRecurring(Class<? extends JobHandler<?>>), which resolves the
registered name through the registry and fails loudly for a handler
that is not @Recurring.

Also renders the origin badge in the operations console, so issue #108's
requirement 8 (distinguishable in dashboard + metrics) is met in the UI
and not only in the API.
Answering "is this clear in terms of usage?" honestly: it was not.
Everything about nudging lived inside the transactions deep-dive, which
is where someone reads about atomic boundaries, not where they look to
build a wake-driven poller — and recurring tasks had no usage page at
all, appearing only in the comparison, migration, and configuration
docs.

docs/wake-driven-pollers.md now carries the pattern end to end: why it
beats a frequent poller, the Spring and core shapes, and the part most
likely to be got wrong — the backstop interval is insurance against a
nudge lost to a crash, not a run cadence, so it is chosen by asking how
stale the work may get in that rare case. Seconds-level intervals
recreate the churn nudging removes; the origin metric tells you when
the backstop is firing more than it should. Also spells out what
coalescing demands of handler code: drain everything visible, never
count runs.

Linked from the docs index, cross-linked with the transactions section
(which keeps the transactional contract), and summarised on
@Recurring(interval) itself, where a Spring user is actually standing
when they choose the number.
Three independent reviewers over the feature diff; two converged on the
same P1.

P1: the Spring layer defers nudges to afterCommit, but the Postgres
store still routed them through the joining boundary. Spring commits the
caller's transaction before it unbinds resources, so the nudge executed
in a fresh transaction on the caller's connection with nobody left to
commit it. Unpooled PGSimpleDataSource hides this — restoring autoCommit
at cleanup makes pgjdbc commit implicitly — but on a pool configured
autoCommit=false (Hikari with JPA defaults, a supported Spring Boot
setting) HikariCP rolls the write back on release and every nudge is
lost silently. Nudge reads and writes now use an owned transaction that
commits explicitly; the new regression fails against the old routing and
passes against this one. Owning the transaction also makes the
concurrent-deletion FK conflict and a lock-order deadlock with
deleteCronTask harmless, so 23503 is translated to UNKNOWN_TASK again
and DeadlockRetry covers 40P01.

P2: the documented "nudge once per work item" pattern registered one
synchronisation and one store round trip per call, so a 500-row batch
importer paid 500 validations and 500 serial post-commit writes for
byte-identical work. Nudges are now batched per transaction. The batch
hangs off the registered synchronisation rather than a bound resource:
Spring's suspend() does not unbind custom resources, so a resource-held
batch would leak into a REQUIRES_NEW inner transaction and an inner
commit under an outer rollback would drop the nudge.

Also: the coalescer could wedge a task's slot forever if the follow-up
thread failed to start (uninterruptible joins, no recovery); the
recurring-runs meter counted retry attempts rather than instances,
inflating the ratio operators diagnose with; dashboard operator input is
parsed before taking the task mutex so a malformed zone answers 400
rather than 409 under contention; and the JobScheduler Javadoc, the
never-reset-revision claim, the missed-run-policy claim, and a stale
AGENTS.md matrix row all said things the code does not do.
@hemju

hemju commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

6h dual-backend endurance: passed

nudge-pump scenario, 6h × 50 jobs/s × 4 producers × 3 nodes, node churn every 10 min, against real PostgreSQL 18 and Redis 7 (AOF, noeviction) in parallel.

Postgres Redis
Verdict / exit passed / 0 passed / 0
Enqueued / succeeded 1,510,426 / 1,531,722 1,552,970 / 1,574,346
Failed 4 0
e2e p99 45 ms 43 ms
Failed invariants

All three invariants (atLeastOnce, nudgeRunAfterWake, outboxDrainedByLaterRun) passed on both backends.

Feature-specific numbers: 755,219 → 21,296 nudge-to-pump-run on Postgres (35:1) and 776,491 → 21,376 on Redis (36:1), i.e. coalescing held under sustained load and master handover. Exactly 36 schedule-origin runs per backend — the 10-minute backstop firing precisely on grid across 6 hours while nudges carried all the real work, which is the wake-driven shape doing what it claims.

Wall clock was 360 min with zero execution gaps >60 s on either backend, so this was an uninterrupted 6 hours (the earlier attempt died with the laptop battery and is not comparable). 35 churn cycles completed. The 4 Postgres failure events are the documented shutdown-release shape (attempts: 0, final: false, all retried, atLeastOnce clean) from a churn cycle.

Round-2 review fixes (074379d) are included in this run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

On-demand materialization for recurring tasks (wake-now) — wake-driven pollers with a slow schedule as backstop

1 participant