feat: on-demand materialization (nudge) for recurring tasks - #109
Conversation
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
left a comment
There was a problem hiding this comment.
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
ACCEPTEDand 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 |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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()) { |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
|
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 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 |
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.
6h dual-backend endurance: passed
All three invariants ( 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 ( Round-2 review fixes ( |
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_aton the task's schedule state. The maintenance master's existing per-task recurring tick observes it alongsidenext_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:next_run_atis 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 noCRON_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.
upsertCronTaskStatedeliberately 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 inafterCommit, 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'sPostgresTransactionBoundary— 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 preferafter_commit.immediate: direct write.Producer-burst protection:
NudgeCoalescersingle-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 injoin_transactionmode, 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'striggerRecurringstays the separate operator force lane, unchanged.Storage
V5__cron_state_nudge.sql(nudge_requested_at TIMESTAMPTZ, deliberately unindexed so nudge writes stay HOT-eligible). Accept is one guardedUPDATE … FROMstatement on the hot path.JobStoreSPI:requestCronNudge(name, at)→ACCEPTED | UNKNOWN_TASK | DISABLED,clearCronNudge(name, observed).CronTaskScheduleStategains a read-onlynudgeRequestedAtcomponent.TracingJobStoreandForwardingJobStoreupdated.Tests
Every acceptance bullet from the issue is a deterministic test (durable state + explicit
materializer.tick(now)— no timing-sensitive signal path exists):nudgeRoundTripsAndSurvivesBlanketStateUpserts,nudgeOnUnknownTaskIsRejectedWithoutResurrectingState,nudgeOnDisabledTaskIsRejected,clearCronNudgeOnlyClearsTheObservedValue.nudgeMaterializesPromptlyWithoutTouchingTheSchedule,nudgeDuringAnInFlightRunProducesExactlyOneFollowUpAfterCompletion(burst during in-flight → exactly one follow-up),nudgeCoalescesIntoADueScheduledFire,nudgeInstanceTakesThePileUpGuardSoAScheduledFireWaits,nudgeUnknownTaskFailsLoudly,nudgeDisabledTaskFailsLoudlyAndDoesNotRun,reEnablingATaskClearsAPendingNudgeFromBeforeThePause.nudgeInsideTransactionTakesEffectOnlyAfterCommit,nudgeInsideRolledBackTransactionIsDiscarded,nudgeValidationFailsFastInsideTheTransactionNotAtCommit, and (Testcontainers, real Postgres)nudgeJoinsTheCallerTransactionAndRollsBackWithIt.manualTriggerStampsTheManualOriginMarker,updateRecurringEnabledFlipClearsAPendingNudge.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 checkgreen, including the Testcontainers-backed Postgres and Redis suites.