Scheduler component plugin: config-declared cron and interval jobs that run once per cluster#1828
Conversation
…l jobs with cluster-wide leader election (#951) Components can declare recurring jobs in their config and core invokes the designated export on schedule, exactly once per cluster: - resources/scheduler/CronExpression.ts: dependency-free five-field cron parser/evaluator (lists, ranges, steps, names, @daily macros, standard dom/dow OR rule) with IANA-timezone evaluation and DST guards - resources/scheduler/engine.ts: leader election (alphabetical, sticky, heartbeat lease in a replicating system table), failover watcher, missed-occurrence catch-up sweep, per-job timers with single-flight - resources/scheduler/scheduler.ts: handleApplication plugin — config validation with descriptive errors, handler resolution via scope.import in the component realm, scope close cleanup Design derived from the @harperfast/scheduler plugin proven in production on a two-node Fabric cluster; timer firing is intentionally decoupled from schedule computation so the durable timer service (#754) can replace the setTimeout layer later without touching the config surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed-error sanitization, interval unit validation - failoverCheck now escalates by roster position when the cluster is leaderless, so a dead or never-started preferred node cannot leave the scheduler idle forever (review: cold-start liveness gap) - persisted job errors strip filesystem paths before replicating - interval strings are validated against the documented unit vocabulary (convertToMS silently treats unknown units as seconds) - document handler idempotency expectations in the schema and plugin JSDoc Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a built-in scheduler component plugin that enables components to declare recurring jobs via cron or interval schedules, executing them exactly once per cluster using a leader-elected engine. The implementation includes timezone-aware cron parsing, a state-replicating failover engine, and comprehensive tests. The review feedback is highly constructive, focusing on enhancing the robustness of the scheduling engine. Key recommendations include preventing split-brain scenarios during database read failures, safely handling untrusted error objects with try/catch blocks, snapshotting jobs before asynchronous iteration to avoid concurrent mutation bugs, wrapping database operations in failover checks to prevent unhandled rejections, and enforcing mutually exclusive schema constraints for cron and interval configurations.
…ver testing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hardened error extraction, catch-up snapshot, schema oneOf - electRole and failoverCheck now treat an unreadable state table as "assume a leader exists" instead of proceeding toward promotion, eliminating a split-brain path when the DB errors during election - safeErrorMessage() guards every catch-site against hostile error objects (throwing getters, primitives, frozen errors); handler module load failures are wrapped with cause instead of mutating the caught value - catch-up sweep snapshots the job list before its awaits and re-checks registration before firing, so a mid-sweep component reload cannot execute an unregistered job - config-app.schema.json enforces exactly one of cron/interval per job Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…built into table expiration) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kriszyp
left a comment
There was a problem hiding this comment.
Really solid work here — the coordination design is thoughtful, the DESIGN.md writeup is exactly the right kind of documentation, and the election/lease core held up impressively well under adversarial verification (see "verified robust" below, which cleared several things that look like bugs). This review ran 8 independent finder angles over the diff, then adversarially verified 12 candidate findings; 8 confirmed (2 empirically, with runnable repros), 4 refuted. Confirmed findings are inline comments — all have cheap, well-understood fixes. The two DST findings and the identity-guard fix are the ones I'd prioritize.
Verified robust (candidates that did NOT survive verification — recorded so nobody re-flags them)
- Cold-boot N-leader race: refuted.
server.nodesis rebuilt synchronously from the persistedhdb_nodestable before applications load, so established clusters share a roster at boot. The only duplicate window is initial cluster formation, which the PR documents as by-design. ✅ - Mutual leader step-down → leaderless gap: refuted. Under timestamp-LWW conflict resolution (
Table.tsprecedesExistingVersion, with its alphabetical node-name tiebreak) the write-order winner always reads its own row as current — only the loser ever steps down. The tiebreaker the lease dance seems to be missing already exists at the replication layer. ✅ - Unbounded audit growth from the 60s heartbeat: refuted — bounded by the default 3d
auditRetentionpurge (~4.3k entries steady-state for the leader row). ✅ - Validation/
scope.importunder the component load lock: refuted — consistent with existing plugin convention (jsResource, dataLoader, graphql all do awaited I/O under the same lock), and the cron constructor search is coarse-jumping (month/day/hour), sub-millisecond. ✅
Design discussion: the config→code binding (from Kris)
The handler: ./jobs.ts#export path-fragment string introduces a third pattern for connecting declarative config to code. We already have two idioms: (1) JS-initiated slot-filling — the declarative side declares the slot, component code registers the implementation by name (computed fields; MCP quotas in #1821); and (2) named resources from the resources registry (how REST binds names to code — considered for cases like this before). The module#fragment approach needs its own resolution machinery (scope.import + fragment parsing), couples config to file layout (moving a file silently breaks config), and its deploy-time validation promise is exactly what the worker-gate finding below undermines.
Suggestion to consider before the config surface ships (it's the hardest part to change later): keep the job declaration in config (name, cron/interval, timezone — the ops-visible part, which is what #951 asked for), but bind the handler the way computed fields and quotas do — component code registers it against the job name at load (e.g. server.scheduler.handle('daily-metrics-snapshot', fn)), with a post-load check that every declared job got a handler. Handlers stay typed and refactor-safe, the path-resolution machinery disappears, and this becomes the third consecutive feature using the same binding idiom instead of the first of a new one. Open to being argued out of it — if config-only self-containment matters more than consistency here, let's decide that explicitly.
Load distribution (deferred — that's fine)
All jobs currently execute on worker 0 of the leader node — worker 0 is just the first HTTP worker, so a heavy handler competes with HTTP traffic on that thread, on the leader. Acceptable for v1. #754 (sharded hierarchical timing wheel) explicitly owns distribution at the shard-to-node assignment layer, so scheduler firing should migrate onto it when it lands; a one-liner in DESIGN.md noting "handlers run on the leader's worker 0" would make the current constraint visible to operators.
On the latest commits (c1f20c9e0..14648ba07)
The Gemini-review hardening is good but doesn't intersect these findings — two near-misses called out inline: runCatchUp's new snapshot adds exactly the identity check that scheduleNextRun still needs, and the electRole/failoverCheck reads are now guarded but heartbeat()'s (the one that matters for dual-leader) is not.
Review by Claude (Fable 5), pairing with @kriszyp — 8 finder angles, 12 adversarially verified candidates, 2 empirical repros (scripts available on request).
| // A DST fall-back overlap can map the computed wall time to an instant | ||
| // at or before `after`; advance until we cross it (the overlap is at | ||
| // most an hour or two, so this converges in a couple of iterations) | ||
| for (let attempts = 0; attempts < 5; attempts++) { |
There was a problem hiding this comment.
[confirmed — empirical] 5-attempt DST loop permanently wedges sub-hourly jobs every fall-back transition.
Each attempt advances one occurrence, but converging through the fall-back repeated hour needs up to ~an hour's worth of occurrences. Reproduced against this exact code: */5 * * * * in America/New_York, after = 2026-11-01T06:10:00Z (inside the repeated 1 AM hour) needs 13 iterations; the loop allows 5, so nextDate returns null for the entire 06:00Z–07:10Z window — and this is hit naturally in live sequence (the post-run reschedule of the 06:00Z occurrence lands inside the window).
Downstream, engine.ts scheduleNextRun treats null as "no future occurrence" and returns without arming a timer — and nothing ever retries (the heartbeat catch-up sweep never re-arms timers). The job is permanently degraded to at most one catch-up run per heartbeat (flagged catchUp: true) until failover/restart, recurring every fall-back for every sub-hourly cron job in a DST timezone.
Fix shape: bound the retry by wall-clock distance rather than attempt count (e.g. retry while nextWall - originalWall <= ~3h, or a generous cap like 200 — each iteration is cheap), and make scheduleNextRun treat null as "retry at next heartbeat" rather than warn-and-abandon, so any residual null is self-healing.
There was a problem hiding this comment.
Fixed in 397e6b3: convergence is now bounded by wall-clock distance (3h horizon, 400-iteration safety cap) instead of 5 attempts — your repro (*/5 at 06:10Z) now converges in ~10 steps and is pinned as a regression test, along with the minutely worst case. Belt-and-braces on the downstream half too: timers clear their handle at fire time and the heartbeat catch-up sweep re-arms any unarmed cron job, so a residual null nextDate self-heals within one heartbeat instead of degrading until failover. (AI-generated response)
| * lands on, and callers guard against the result crossing their reference | ||
| * point (see nextDate/prevDate). | ||
| */ | ||
| export function fromZonedWallTime(wallTime: Date, timezone: string): Date { |
There was a problem hiding this comment.
[confirmed — empirical] Single offset probe maps wall times 02:00–05:59 after fall-back one hour early.
asUtc (the wall time misread as UTC) lands ~4–5h before the real instant, so for the first offset-width hours after the transition the probe still sees the pre-transition offset. Reproduced: NY wall 02:15 on 2026-11-01 → returns 06:15Z (which is wall 01:15 EST); correct is 07:15Z. Round-trip toZonedWallTime(fromZonedWallTime(w)) ≠ w for the whole 02:00–05:59 window.
The instant > after guard in nextDate doesn't mask this — it accepts the wrong instant: a daily 15 2 * * * job fires an hour early at the wrong wall time, and for 30 * * * * one occurrence is silently dropped (wall 05:30 never fires; the retry loop steps past it). The catch-up sweep can't recover the loss because its own previousDate is wrong by the same hour.
Fix shape: the standard second probe iteration — recompute the offset from the first candidate instant and re-resolve (optionally compare both probes to pick the ambiguous-instant side deterministically). Controls confirm the function is exact outside transition windows, so this is strictly additive.
There was a problem hiding this comment.
Fixed in 397e6b3 with the standard two-probe resolution: re-resolve the offset at the first candidate instant and accept the second candidate only when it round-trips exactly (ambiguous times therefore resolve to their first occurrence deterministically; nonexistent spring-forward times keep the shifted instant, preserving the existing pinned behavior). Your 02:15 repro (07:15Z, was 06:15Z), the dropped hourly occurrence (10:30Z now fires), and exact round-trips across the 02:00-05:59 window are all pinned as regression tests. (AI-generated response)
| const lastRun = stateRow?.lastRunAt ? Date.parse(stateRow.lastRunAt) : NaN; | ||
| // An overdue interval (downtime longer than the interval) fires | ||
| // immediately — this is the interval jobs' catch-up path | ||
| fireAt = Number.isNaN(lastRun) ? new Date(now.getTime() + job.intervalMs) : new Date(lastRun + job.intervalMs); |
There was a problem hiding this comment.
[confirmed] Interval jobs hot-loop when state writes fail but reads succeed.
fireAt anchors solely to the persisted lastRunAt, and putStateRow swallows all write errors. On the canonical partial-failure profile — reads fine, writes throwing (ENOSPC/EDQUOT, we've seen exactly this in the fleet) — each run re-anchors to the frozen lastRunAt → delay <= 0 → Math.max(delay, 0) → immediate refire. The handler executes back-to-back continuously (potentially hundreds/sec for a fast handler) instead of once per interval, and the 2+ warn lines per iteration accelerate filling the disk that caused the failure. job.running doesn't help: it's reset in executeJob's own finally before the reschedule fires.
Fix shape: when the previous persist failed (or unconditionally), floor the reschedule at now + minDelay — or keep an in-memory last-attempt timestamp as a fallback anchor so a failed persist can't time-travel the schedule.
There was a problem hiding this comment.
Fixed in 397e6b3 with the in-memory anchor variant: RegisteredJob carries lastAttemptAt (set at execution start), and interval scheduling anchors to max(persisted lastRunAt, lastAttemptAt). A frozen persisted row can no longer time-travel the schedule, while the persisted value still drives the overdue-interval catch-up across restarts/failover. (AI-generated response)
| * handled by the catch-up sweep instead). | ||
| */ | ||
| async function scheduleNextRun(job: RegisteredJob): Promise<void> { | ||
| if (role !== 'leader' || !jobsByComponent.get(job.componentName)?.has(job.name)) return; |
There was a problem hiding this comment.
[confirmed] Name-based re-arm guard → persistent double-fire after component reload.
registerComponentJobs replaces RegisteredJob objects under the same names, so an in-flight executeJob → .finally → scheduleNextRun(oldJob) chain (the window spans the entire handler execution) passes this name-only .has() guard and re-arms the old object — which no map references. unregisterComponentJobs and becomeFollower iterate the current map, so that timer can never be cleared: two independent chains then run every occurrence twice, indefinitely, each .finally re-arming its own object. Trigger is just "redeploy while the leader has a run in flight". (job.running is per-object, so it never dedupes across the two chains.)
Nice part: the fix pattern is already in this file now — runCatchUp's new snapshot check (.get(job.name) !== job) is exactly right. Applying the same identity comparison here, plus re-checking it after the getJobStateRow await (which also closes the concurrent-arm timer leak between registerComponentJobs' fire-and-forget and becomeLeader's loop), closes both legs.
There was a problem hiding this comment.
Fixed in 397e6b3: extracted the runCatchUp pattern into an isRegistered() helper doing object-identity comparison, applied at scheduleNextRun entry AND re-checked after the getJobStateRow await (closing the concurrent-arm leak you noted between registerComponentJobs' fire-and-forget and becomeLeader's loop), plus at executeJob entry. The stale-object chain now dies at its next step in all three places. (AI-generated response)
| database: 'system', | ||
| table: SCHEDULER_STATE_TABLE, | ||
| // Replication of system tables requires auditing | ||
| audit: true, |
There was a problem hiding this comment.
[confirmed] The load-bearing "this table replicates" invariant is enforced nowhere — and an ordinary config silently breaks it today.
The design comment above requires hdb_scheduler_state to replicate, but no replicate option is passed, and replication is decided by absence from NON_REPLICATING_SYSTEM_TABLES plus harper-pro policy. Concretely: when an operator scopes replication.databases (a documented config), the system database is only replicated if listed or if some system table has replicate === true — this table never sets it, so it can't trigger that fallback. Result: the lease/heartbeat view diverges per node and the scheduler degrades to N independent leaders running every job N times, with no error anywhere.
Fix shape: pass replicate: true explicitly here (a positive declaration the pro subscription check honors), and ideally assert the property at engine start so a future policy change fails loudly instead of silently multiplying job executions.
There was a problem hiding this comment.
Fixed in 397e6b3: the table now declares replicate: true positively, and becomeLeader asserts the property at promotion — if a future policy change flips it, the leader logs a loud error naming the exact degradation (jobs running on every node) instead of multiplying silently. (AI-generated response)
|
|
||
| async function heartbeat(): Promise<void> { | ||
| const self = currentNodeName(); | ||
| const leaderRow = await getStateTable().get(LEADER_ROW_ID); |
There was a problem hiding this comment.
[confirmed] Leader lease renewal is fragile in two ways; both end in dual execution.
(a) Ordering in becomeLeader: one heartbeat is written, then scheduleComponentJobs and the full catch-up pass are awaited — user handlers, serially — before the heartbeat interval is created. If the catch-up pass exceeds STALE_THRESHOLD_MS (5 min total across all missed jobs, not per job), the live leader looks stale, the rung-0 follower promotes, and its own catch-up re-runs the same occurrence — job.running is process-local, so both nodes execute it. This is near-deterministic duplication of exactly the occurrence whose handler caused the stall.
(b) This read is unguarded: the recent hardening wrapped electRole and failoverCheck's reads, but this is the one that matters — a read error here aborts the whole heartbeat via the interval's .catch, skipping both the lease renewal and the takeover step-down check (the path that would heal a dual-leader state). Leader-local read errors > 5 min → follower promotes → both fire, and the healing path is itself the broken path.
Fix shape: start the heartbeat interval before (or concurrently with) the promotion catch-up pass, and wrap this get the same way electRole does — a failed read should still attempt renewal and try the step-down check on the next tick.
There was a problem hiding this comment.
Both fixed in 397e6b3: (a) the heartbeat interval is created immediately after the first lease write, BEFORE scheduleComponentJobs and the promotion catch-up pass — runCatchUp's single-flight guard keeps the overlapping heartbeat tick from double-sweeping; (b) heartbeat's read is now guarded like electRole's, and on a failed read it renews blind (using an in-memory initializedAt fallback so the row isn't clobbered) rather than aborting the tick — renewal and the takeover step-down check each degrade by one tick instead of failing together. (AI-generated response)
| // One worker owns scheduling for the whole node; leadership across nodes is | ||
| // decided in the engine. This gate is correct in every threading mode, | ||
| // including threads:0 where the main thread acts as worker 0. | ||
| if (getWorkerIndex() !== 0) { |
There was a problem hiding this comment.
[confirmed] The worker-0 gate defeats the deploy-time validation this plugin promises.
Deploy pre-flight validation (components/operations.js → loadComponent with collectScopes) runs in-process on whichever HTTP worker received the deploy operation — worker 0 with probability 1/N. On any other worker this gate returns before any validation: jobs-array shape, duplicate names, cron parse, timezone, interval bounds, and the eager resolveHandler import (whose documented purpose is failing "at deploy time") are all unreachable. A bad config passes pre-flight nondeterministically, deploys and replicates, then the component fails to load cluster-wide at the next restart. Peers re-validate on arbitrary workers too, so a cluster can even split on accepting the same deploy.
(The dataLoader precedent has the same gate but does no validation behind it — the gate is fine as an activation gate; it's the validation behind it that's new here.)
Fix shape: split validation from activation — run the config checks and buildJob/resolveHandler unconditionally (cheap, side-effect-free), and gate only registerComponentJobs/startSchedulerEngine on worker 0.
There was a problem hiding this comment.
Fixed in 397e6b3 exactly as you outlined: config checks, duplicate-name detection, cron/timezone/interval validation, and the eager resolveHandler import all run unconditionally (every worker, including pre-flight validation loads on arbitrary workers); only registerComponentJobs/startSchedulerEngine sit behind the worker-0 gate. Deploy pre-flight now rejects a bad scheduler config deterministically. (AI-generated response)
| } | ||
|
|
||
| async function executeJob(job: RegisteredJob, scheduledAt: Date, catchUp: boolean): Promise<void> { | ||
| if (role !== 'leader') return; |
There was a problem hiding this comment.
[confirmed] executeJob never re-checks registration — and deploy-validation loads leak into the live engine under appName 'harper'.
Two related issues with one root cause (liveness checked one await too early):
- A timer armed after its scope closed (for interval jobs,
scheduleNextRuncan pass its guard, park at thegetJobStateRowawait, and arm afterunregisterComponentJobsran) fires intoexecuteJob, whose only gates areroleandjob.running— the discarded component's handler executes once against production state and writes a state row for a dead component. - Sharper, race-free variant: the deploy-validation
loadComponentpasses no appName, so the validation scope defaults to'harper'— the same key as root-config jobs. On the leader's worker 0, a deploy validation therefore callsregisterComponentJobs('harper', …), which first unregisters any root-config scheduler jobs, and the validation scope's close then deletes the entry entirely — root-level jobs are silently dead until worker restart. No race needed.
Fix shape: add a registration/identity re-check here in executeJob (same pattern as runCatchUp's new snapshot check), and don't register jobs from validation loads at all (e.g. skip when the scope is a collectScopes validation load, or fix the 'harper' appName default for validation scopes — that default collision is worth fixing regardless).
There was a problem hiding this comment.
Both legs fixed in 397e6b3: (1) executeJob re-checks object-identity registration at entry (and scheduleNextRun re-checks after its await before arming), so a timer that outlives its scope dies at the next step; (2) the loader now marks pre-flight validation scopes with isTransientValidation (one-line marker in componentLoader where collectScopes is handled, declared on Scope), and the plugin validates fully but never registers from them — so a validation load can no longer displace a live component's jobs regardless of the appName collision. Left the 'harper' appName default itself alone to keep this PR's blast radius small, but agreed it's worth fixing on its own — happy to file it. (AI-generated response)
…uling identity guards, lease renewal ordering, validation/activation split CronExpression: - fromZonedWallTime uses the standard two-probe offset resolution, fixing the hour-early mapping of wall times in the offset-width window after a fall-back transition (empirical repro: NY wall 02:15 -> 07:15Z, was 06:15Z) - nextDate/previousDate convergence is bounded by wall-clock distance (3h) instead of a 5-attempt cap that wedged sub-hourly jobs for the entire fall-back window (empirical repro: */5 at 06:10Z now converges in ~10 steps) Engine: - isRegistered() identity checks (object equality, not name) in scheduleNextRun (entry + post-await), executeJob, and the catch-up sweep: a reload during an in-flight run can no longer double-fire via a stale object, and a discarded scope's handler can no longer execute - interval scheduling anchors to max(persisted lastRunAt, in-memory lastAttemptAt) so state-write failures cannot hot-loop the handler - hdb_scheduler_state declares replicate: true positively (an operator-scoped replication.databases config could silently exclude it) and the leader asserts the property loudly at promotion - becomeLeader starts the heartbeat interval before the promotion catch-up pass (a slow catch-up made a live leader look stale), and heartbeat's leader-row read is guarded so a read error cannot skip lease renewal - timers clear their handle at fire time and the catch-up sweep re-arms unarmed cron jobs each heartbeat, making a null nextDate self-healing Plugin: - config validation and handler resolution run on every worker and on deploy pre-flight validation loads (previously gated behind worker 0, making pre-flight validation nondeterministic); only activation is gated - validation scopes are marked isTransientValidation by the loader and never register into the live engine (a validation load could displace a running component's jobs by sharing its identity) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kriszyp
left a comment
There was a problem hiding this comment.
Reviewed — the separation between config validation, worker-local activation, replicated run state, and cron calculation is thoughtful, and the DST coverage is strong. A few things worth addressing (flagging as comments):
1. Oversized/non-finite intervals become a ~1ms hot loop (resources/scheduler/scheduler.ts:153, engine.ts:470/487). The interval validation has a lower bound but no finiteness/upper-bound check. YAML .inf / 1e309 parse to Infinity, and large finite values exceed Date range — both pass intervalMs >= 1000. scheduleNextRun then builds an Invalid Date → delay = NaN → Node coerces setTimeout(…, NaN) to 1ms, and re-arms the same way after each run, turning a "disabled/very-long" interval into continuous user-code + state-table I/O. Reject non-finite intervals and cap/chunk to the representable horizon; armTimer should fail closed on a non-finite target. (Confirmed on Node v26: invalid date → NaN → TimeoutNaNWarning → 1ms.)
2. Overlapping scheduling can arm two timers while keeping one handle (engine.ts:198/299/455/487). becomeLeader publishes role='leader' before its awaits and initial scheduling loop, so a component registering in that window starts its own scheduleComponentJobs while becomeLeader also schedules the same job. For interval jobs, both scheduleNextRun calls clear the handle before awaiting getJobStateRow, so both can pass with no timer and call armTimer; the second overwrites the first handle without cancelling it → an uncancellable live timer, double-execution for one fireAt, or persistent competing timers.
3. The once-per-occurrence property is unproved. The integration test passes under the duplicate-worker behavior it claims to detect (single worker, one node), so the load-bearing central coordination isn't actually exercised — worth a real multi-worker / multi-node case.
Nice work on the DST handling.
— Claude
…worker test coverage - interval values must be finite and between 1s and 365 days; armTimer additionally fails closed on a non-finite delay (YAML .inf / 1e309 / Date-overflowing values previously coerced to a ~1ms setTimeout hot loop) - concurrent scheduleNextRun calls for one job coalesce onto a single computation, closing the window where two interleaved calls both armed timers with only the last handle retained (uncancellable duplicate timer) - the component integration test now runs with threads: 3 (overriding the framework's single-worker default) so its once-per-occurrence assertions actually exercise the worker-0 activation gate; verified 3 worker threads in instance logs with no duplicate fires Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All three round-2 findings addressed in aa0aa0a:
(AI-generated response) |
kriszyp
left a comment
There was a problem hiding this comment.
Requesting changes on the two High findings from my earlier review comment — flagging as blocking:
-
Oversized/non-finite intervals become a ~1ms hot loop (
scheduler.ts:153,engine.ts:470/487): YAML.inf/1e309→Infinityand over-range finite values both passintervalMs >= 1000, producing anInvalid Date→NaNdelay →setTimeout(…, NaN)coerced to 1ms, re-armed after every run. Reject non-finite intervals + cap/chunk to the representable horizon;armTimershould fail closed on a non-finite target. -
Overlapping scheduling can arm two timers while keeping one handle (
engine.ts:198/299/455/487):becomeLeaderpublishesrole='leader'before its awaits, and bothscheduleNextRuncalls clear the handle before awaitinggetJobStateRow, so two timers can be armed with only one tracked → uncancellable timer / double-fire.
Also worth resolving before merge: the integration test passes under the duplicate-worker behavior it claims to detect (single worker, one node), so the once-per-occurrence coordination is still unproved — a real multi-worker/multi-node case would pin it.
— Claude
…ntegrity, orphaned-heartbeat guards, config hardening
From a multipass adversarial audit (two independent runs per domain) ahead
of further review:
CronExpression:
- fromZonedWallTime discovers candidate offsets on both sides of a nearby
transition (probes at the wall-as-UTC instant, the implied candidate, and
+/-2h around it), then resolves deterministically in every zone: existing
wall times map exactly, ambiguous times to their FIRST occurrence,
gap times to the first instant after the gap. Previously eastern zones
fired gap jobs an hour EARLY (Sydney, empirical) and resolved ambiguity
to the second occurrence (Lord Howe); regression tests added for both
- search horizon widened to 8 years: '0 0 29 2 *' was rejected as
never-firing across the 2096->2104 century leap gap (test added)
Engine:
- catch-up baseline now includes the in-memory lastAttemptAt anchor and is
clamped to now: closes cron re-execution every heartbeat under sustained
state-write failure (the interval-path fix had not been mirrored), the
sweep-straddles-a-fast-run duplicate, and clock-skew catch-up suppression;
executeJob also dedups catch-up runs against lastAttemptAt
- catch-up reads the state row strictly (read failure skips the job) and
the first-seen (re)seed preserves existing row fields and skips while a
run is in flight — a transient read error previously full-replaced the
row, destroying run history and shifting the baseline
- double-promotion chain broken three ways: failoverCheck re-checks role
after its await, becomeLeader clears any existing heartbeat interval,
and heartbeat ticks no-op unless still leader (an orphaned interval
could renew the lease forever with no timers armed)
- promotion skips the immediate catch-up pass when the lease shows a fresh
prior incarnation of this same node (overlapping worker restart)
- interval anchor clamped to now (future clock-skewed lastRunAt wedged the
job); persisted lastRunAt and lastAttemptAt cover max(start, occurrence)
so a clock step-back cannot re-deliver an occurrence
- stale threshold clamps to 5x heartbeat when misconfigured at or below it
Plugin/config:
- unknown job-entry keys rejected (a typo'd timezone silently ran the job
in the server timezone); jobs: null (all entries commented out) degrades
like an absent block instead of failing the component
- scheduler block removal in dev-watch now requests a restart (options
'remove' was consumed by nothing, leaving deleted jobs firing)
- schema: interval pattern/minimum, timezone excluded from the interval
branch, quoting guidance for cron ('*'/'@' are YAML syntax) and handler
('#' after a space starts a comment)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Ahead of further review we ran our own multipass adversarial audit (two independent deep-review runs per domain — concurrency, data-integrity, config — with lenses derived from the earlier findings). Result: 9 code findings fixed in 3d9cbbb, the notable ones:
48 unit tests (5 new), single-node + multi-worker integration green; 2-node failover re-validation running. Full audit trail available if useful. (AI-generated response) |
Captured empirically in the 2-node failover harness: a state read/write issued on a freshly promoted leader, while the replication link to the dead peer is still churning, can stall indefinitely. The promotion pipeline wedged behind its await while the already-running heartbeat kept renewing the lease - a healthy-looking leader running zero jobs, with no error logged anywhere (the intermittent failover-test timeout). Bounded operations instead reject into the existing degraded paths: schedule from now, skip this sweep, retry next heartbeat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@kriszyp All three items in the changes-requested review are already addressed — notably, the review is pinned to commit aa0aa0a, which is the commit that fixed them (the finding text describes the pre-aa0aa0a87 code). Concretely, in the current head:
Since that commit, a self-audit added further hardening (3d9cbbb — summary in the comment above) and the 2-node harness caught one real engine bug the unit surface couldn't: a state-table operation stalling on a freshly promoted leader during post-failover replication churn could wedge the promotion pipeline behind its await while the heartbeat kept the lease fresh — a healthy-looking leader running zero jobs. Every state-table operation is now bounded by a 10s timeout that fails into the existing degraded paths (bf40342/d7f202b79); statistical re-validation of the failover loop is running now. Re-requesting review — happy to address anything that survives a look at the current head. (AI-generated response) |
kriszyp
left a comment
There was a problem hiding this comment.
Approving. Heads-up: it's currently CONFLICTING against main (went dirty since my earlier review), so it'll need a rebase before it can merge — I can queue one on your word. — Claude
# Conflicts: # DESIGN.md
|
Thanks @kriszyp. Resolved the conflict by merging (AI-generated response) |
|
Thanks for the review, @kriszyp — all findings addressed and the branch is now current with Since this merges into core Companion docs PR HarperFast/documentation#592 is also approved and green whenever you'd like it to go in. (AI-generated comment on behalf of @jcohen-hdb) |
…ored errors sanitizeStoredError only stripped /Users|home|var|tmp|opt|etc|root paths. hdb_scheduler_state replicates cluster-wide, so a Windows node's job-handler error (or one under /srv, /data, /mnt) leaked the local filesystem layout unredacted. Widen the regex and export the function for direct unit coverage. Refs #1828 review finding 2. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Addressed the two actionable review findings directly on this branch:
🤖 Generated with Claude Code |
…ed occurrence The plugin's JSDoc warned about occasional double-delivery but didn't mention the flip side: an extended outage isn't backfilled occurrence-by-occurrence, only the single most recent miss is caught up. Review finding 4 on #1828. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…oots harper dev <fixture> runs symlinkHarperModule against the component dir, planting node_modules/harper inside integrationTests/fixtures/* — untracked and unignored, it has previously slipped into a commit (#1828 required an amend). Discovered during runtime verification of this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers the orchestration paths that previously had only pure-helper unit tests here (electRole, becomeLeader, heartbeat, failoverCheck, runCatchUp, executeJob) using the same-process simulated-peer pattern: the engine's entire view of other nodes flows through server.nodes and rows in hdb_scheduler_state, so the test supplies both against the real engine module and a real store. Staleness is data-driven, so nothing waits out production thresholds; heartbeat/failover ticks are driven through small ForTests seams (same pattern as setCoolingFunctionForTests). Scenarios: sticky election deferral, no-promotion while the leader beats, stale-leader promotion with catch-up firing the missed occurrence (catchUp: true), lease renewal, step-down when a heartbeat finds a fresh competing lease (racing write), cold-start election, new-job baseline without immediate fire, and registration replacement (previously an assertion-free test). True-network multi-node coverage remains in the harper-pro cluster harness (replication is not part of this repo). Refs #1866, #1828, #951. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oots harper dev <fixture> runs symlinkHarperModule against the component dir, planting node_modules/harper inside integrationTests/fixtures/* — untracked and unignored, it has previously slipped into a commit (#1828 required an amend). Discovered during runtime verification of this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes #951.
Summary
Components can now declare recurring jobs in their config and have core invoke a designated export on that schedule, exactly once per cluster:
resources/scheduler/CronExpression.ts— dependency-free five-field cron parser/evaluator (lists, ranges, steps, names,@dailymacros, standard day-of-month/day-of-week OR rule), evaluated in an IANA timezone with explicit DST-gap/overlap handling. No new npm dependency.resources/scheduler/engine.ts— cluster coordination: deterministic leader election (alphabetical, sticky), heartbeat lease in a new replicating system tablehdb_scheduler_state, failover watcher with an escalation ladder (a dead preferred node can't deadlock the cluster), missed-occurrence catch-up sweep, per-job timers with single-flight. Timing thresholds are env-overridable for multi-node testing.resources/scheduler/scheduler.ts— thehandleApplicationplugin (registered inTRUSTED_RESOURCE_PLUGINS): config validation with descriptive errors at load time, handler resolution viascope.importin the component's realm,scope.on('close')teardown. Registration work only under the loader lock; election/scheduling happens async.The design is a port of the
@harperfast/schedulerplugin (HarperFast/scheduler) that has run in production on a two-node Fabric cluster for months — the election/failover/catch-up semantics are battle-tested; the config surface, cron parser, and core integration are new.interval:is included alongsidecron:because the motivating use case in #951's comments (summary-tree's periodic maintenance pass) is interval-shaped and inexpressible in five-field cron.Relationship to #754: schedule computation is deliberately decoupled from timer firing — the
setTimeoutlayer is one seam that the durable timer service can replace later without touching the config surface, election, or catch-up.Design notes (checked against harper-pro's replication layer)
Before finalizing the election design, we swept harper-pro's replication component for primitives to reuse. Findings, so reviewers don't have to re-derive them:
puttherefore cannot be race-free by construction — concurrent lease writes both commit and converge by version. Sticky leadership + the heartbeat takeover check + idempotent handlers is the design that matches that substrate, not a workaround.subscriptionManager.tssorted-name failover walk).LIVENESS_STALE_MS(120s), and inherits the same bounded-clock-skew assumption the CRDT versioning already makes.systemdatabase and assumes it replicates among scheduler-eligible nodes. On constrained/directional topologies (excludeTables, directionalreplicates), nodes that never see the leader's heartbeat would each self-promote — same class of behavior as any replicated state under partition. Worth documenting; not solvable without consensus.readConnectionTruth) could serve as a liveness accelerant to detect a dead leader in ~120s instead of 5 minutes. Deferred — it's node-local/one-directional so it can't replace the lease, only shorten failover.Where to focus review
engine.ts) — see design notes above; handlers are documented as needing idempotency.hdb_scheduler_state(audit enabled, since replication requires auditing). One lease row + one small row per job.promotionWaitMsescalation ladder (engine.ts) — added after review flagged a cold-start liveness gap; each successive roster node waits2 × watcher intervallonger before claiming a leaderless cluster.firstSeenAtbaseline).Open items from review (not resolved in this PR)
job:<component>:<name>state rows are not reaped when jobs are renamed/removed (TODO inrunCatchUp); rows are tiny but accumulate.Verification
unitTests/resources/scheduler/): cron parsing/evaluation incl. DST spring-forward/fall-back pinned to exact instants, leap day, never-fires detection, election, promotion ladder, catch-up decisions, hostile-error handling.integrationTests/components/scheduler-jobs.test.ts) — passing via the integration framework.schedulerFailover.test.mjs, to be contributed to harper-pro once core syncs) passes both assertions — every tick fires from exactly one leader-elected node, and killing the leader promotes the survivor which resumes firing (verified via env-shortened thresholds; thehdb_scheduler_statelease table replicates between nodes as designed).Docs: companion PR HarperFast/documentation#592 (draft, lands together with this).
Generated by an LLM (Claude Fable 5) pairing with @jcohen-hdb.
🤖 Generated with Claude Code