feat(schedule): narrow recurring overlap windows - #115
Merged
Conversation
Closes #110. Threadmill is at-least-once by design, but two of the three windows in which a recurring task can briefly run twice were engineering artifacts rather than distributed-systems necessities. Both are closed here; the third is documented as unclosable. Exclusive recurring tasks (issue item 3). CronTask gains an `exclusive` flag, surfaced as @Recurring(exclusive = true) and as an `exclusive` parameter on Scheduler.defineRecurring. Every materialized instance — scheduled, caught-up, or triggered by hand from the dashboard — is claimed under the derived key `recurring:<name>` in ConcurrencyMode.EXCLUSIVE, so the store refuses to admit a second instance while one is processing. The key is derived rather than user-supplied so the namespace cannot collide with an application's own keys; names past the 256-UTF-8-byte cap truncate on a code-point boundary with a stable hash suffix. This moves the guarantee from a materializer-side check on the maintenance leader to claim-time admission enforced by every store on every node, which is what lets it also cover a manual trigger racing a scheduled instance. Retry-handoff window (issue item 2). JobState.FAILED.isTerminal() is deliberately false — a retry may follow — but the pile-up guard treated every FAILED as finished, so a materializer tick landing between the failure save and RetryInterceptor's reschedule save could create a fresh instance beside a retrying one. A FAILED instance now blocks while its retry budget is not provably spent AND the failure is younger than a 5s handoff grace. Both halves are load-bearing: the budget test keeps the common retry-exhausted failure from delaying the next run at all, and the age bound keeps an instance that is terminal under a per-exception-type policy — which the guard cannot read — from blocking its task until recoverStrandedFailures reaches it. The issue proposed making FAILED -> SCHEDULED atomic instead. That was rejected: it turns JobInterceptor.onProcessingFailed into a decision-returning hook (breaking SPI) and requires legalizing PROCESSING -> SCHEDULED plus non-terminal concurrency-slot release in all three backends, to close a window the guard closes in one file. Shutdown requeue (issue item 1). Declined as specified — the premise does not hold. close() drains via workerPool.shutdown() + awaitTermination(grace) BEFORE shutdownNow(), and the requeue is emitted from recordFailure on the handler's own worker thread, strictly after handler.run returned or threw, so no peer can claim the job while user code is still running. That ordering was implicit in where recordFailure is called; it is now pinned by a regression test so a refactor that moves the requeue off the worker thread turns the build red. Storage: additive Postgres migration V5__cron_task_exclusive.sql; a Redis cron-task hash field with the existing overwrite-on-re-upsert semantics, so an override-less re-registration clears the flag; the in-memory store persists the record directly. DashboardPayloads .CronTaskView gains an `exclusive` component. Docs: docs/transactions.md now names all three overlap windows, states plainly that lease-expiry reclaim is not closable by any job system, and recommends fencing at the consumer's data (compare-and-set transitions, unique constraints, claim columns) for effects that must not happen twice. docs/concurrency.md documents exclusive recurring tasks including the reclaim limitation.
Resolves against the v0.2.0 recurring-nudge work (#108/#109), which touched the same files. - Migration number collision: main shipped V5__cron_state_nudge.sql, so the exclusivity migration is renumbered V5 -> V6__cron_task_exclusive .sql, with SHIPPED_MIGRATIONS, the emitted-SQL assertions, the shipped -migration count (now 6), AGENTS.md, and docs/postgres-schema.md following. - DashboardApiService.updateRecurring: main moved the CronTask rebuild inside the task mutex so the enable-flip and nudge-clear decisions read a post-mutex snapshot. The exclusive-flag preservation moves into that in-mutex construction rather than the old pre-mutex one. - ThreadmillAutoConfigurationTest: both branches added a test at the same offset; kept as separate methods. - CHANGELOG: the issue #110 entries stay under Unreleased, above the released 0.2.0 section. ./gradlew check green on the merge result.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #110.
Threadmill is at-least-once by design, but two of the three windows in which a recurring task can briefly run twice were engineering artifacts rather than distributed-systems necessities. Both are closed here; the third is documented as unclosable.
The issue analysis and the reporter's reply settle the shape: item 3 as proposed, item 2 via a cheaper mechanism than the one proposed, item 1 declined on the merits with a regression test taken from it.
Exclusive recurring tasks (item 3)
CronTaskgains anexclusiveflag, surfaced as@Recurring(exclusive = true)and as anexclusiveparameter onScheduler.defineRecurring. Every materialized instance — scheduled, caught-up, or triggered by hand from the dashboard — is claimed under the derived keyrecurring:<name>inConcurrencyMode.EXCLUSIVE, so the store refuses to admit a second instance while one is processing.The key is derived, never user-supplied, so the
recurring:namespace cannot collide with an application's own keys; names past the 256-UTF-8-byte cap truncate on a code-point boundary with a stable hash suffix.The reason this is worth more than a materializer-side check: it moves the guarantee from what the maintenance leader decides to materialize to claim-time admission enforced by every store on every node. That is what lets it also cover a dashboard manual trigger racing a scheduled instance, and the retry-handoff window below.
It does not close the lease-expiry reclaim window — reclaim releases the concurrency slot as part of the terminal failure save. Documented on the feature, on the annotation, and in
docs/transactions.md.Retry-handoff window (item 2)
JobState.FAILED.isTerminal()is deliberatelyfalse— a retry may follow — but the pile-up guard treated everyFAILEDas finished, so a materializer tick landing between the failure save andRetryInterceptor's reschedule save could create a fresh instance beside a retrying one.A
FAILEDinstance now blocks while its retry budget is not provably spent and the failure is younger than a 5s handoff grace. Both halves are load-bearing, as the reporter argued: the budget test keeps the common retry-exhausted failure from delaying the next run at all, and the age bound keeps an instance that is terminal under a per-exception-type policy — which the guard cannot read, since those policies live on the interceptor — from blocking its task untilrecoverStrandedFailuresreaches it.The issue proposed making
FAILED → SCHEDULEDatomic instead. Rejected: it turnsJobInterceptor.onProcessingFailedinto a decision-returning hook (breaking SPI) and requires legalizingPROCESSING → SCHEDULEDplus non-terminal concurrency-slot release in all three backends — to close a window the guard closes in one file.Shutdown requeue (item 1)
Declined as specified; the premise does not hold.
close()drains viaworkerPool.shutdown()+awaitTermination(grace)beforeshutdownNow(), and the requeue is emitted fromrecordFailureon the handler's own worker thread, strictly afterhandler.runreturned or threw — no peer can claim the job while user code is still executing. The reporter confirmed this was inferred from a comment rather than observed.That ordering was implicit in where
recordFailureis called. It is now pinned byProcessingNodeTest.shutdownRequeueIsNeverPublishedWhileTheHandlerIsStillRunning, which asserts at the exact moment of the SCHEDULED write that the handler has leftrun— plus a positive check that the write was actually observed, so the test cannot pass vacuously.Storage
V6__cron_task_exclusive.sql(BOOLEAN NOT NULL DEFAULT FALSE). Renumbered from V5 in the merge — v0.2.0 shippedV5__cron_state_nudge.sql.DashboardPayloads.CronTaskViewgains anexclusivecomponent.Tests
AbstractJobStoreContractTest.cronTaskExclusiveRoundTripsAndClearsOnReUpsert— runs against all three backends.SchedulingTest:exclusiveRecurringInstancesAreSerializedByClaimTimeAdmission,nonExclusiveRecurringInstancesCarryNoConcurrencyKey, plus the three guard cases (failedInstanceAwaitingItsRetryBlocksTheNextMaterialization,retryExhaustedFailedInstanceDoesNotBlockTheNextMaterialization,failedInstanceStopsBlockingOnceTheRetryHandoffGraceElapses).CronTaskTest.derivedConcurrencyKeyFitsTheKeyCapWithoutSplittingSurrogatePairs.DashboardApiServiceTest.updateRecurringPreservesTheExclusiveFlag+manualTriggerOfAnExclusiveTaskCarriesTheDerivedConcurrencyKey.ThreadmillAutoConfigurationTest.exclusiveRecurringHandlerLandsOnTheRegisteredCronTask.ProcessingNodeTest.shutdownRequeueIsNeverPublishedWhileTheHandlerIsStillRunning.Two traps worth recording, both now in AGENTS.md:
if (!current.isTerminal()) return true;swallows the FAILED case entirely, becauseFAILED.isTerminal()is false. Check== FAILEDfirst.taskFor+reconcileRecurring, and the un-namespaced one callingScheduler.defineRecurringdirectly inThreadmillRecurringRegistrar.registerAll. The first cut wired only the former; the auto-config test caught it. Any newCronTaskfield must go through both.Docs
docs/transactions.mdnames all three overlap windows, states plainly that lease-expiry reclaim is not closable by any job system, and recommends fencing at the consumer's data (compare-and-set transitions, unique constraints, claim columns).docs/concurrency.mddocuments exclusive recurring tasks including the reclaim limitation../gradlew checkgreen on the merge result, including the Postgres and Redis Testcontainers suites.