Document the built-in scheduler component plugin (5.2)#592
Conversation
New reference page for the scheduler (config surface, cron syntax, intervals, timezone/DST semantics, handler contract and idempotency, cluster leader election and catch-up behavior), a row in the built-in components table, sidebar entry, and a 5.2 release-notes entry. Companion to HarperFast/harper#1828. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🚀 Preview DeploymentYour preview deployment is ready! 🔗 Preview URL: https://preview.harper-documentation.harperfabric.com/pr-592 This preview will update automatically when you push new commits. |
There was a problem hiding this comment.
Code Review
This pull request introduces the new scheduler component, which enables recurring jobs to be defined via cron or interval schedules within a component's configuration. The changes include adding a new documentation page, updating the components overview, adding release notes, and registering the new page in the documentation sidebar. A review comment suggests improving the readability of the provided JavaScript example by extracting the time calculation into a named constant.
Record cleanup is built into Harper (table expiration/eviction), so it was a misleading flagship example; point readers there instead and demonstrate the scheduler with jobs only it can do: a daily dataset snapshot (which also demonstrates idempotent handler design) and a scheduled external-API pull. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🚀 Preview DeploymentYour preview deployment is ready! 🔗 Preview URL: https://preview.harper-documentation.harperfabric.com/pr-592 This preview will update automatically when you push new commits. |
kriszyp
left a comment
There was a problem hiding this comment.
The scheduler reference has three behavior-contract mismatches with the companion core implementation. Please address these before merge.
|
|
||
| <VersionBadge version="v5.2.0" /> | ||
|
|
||
| The scheduler is a built-in plugin that runs recurring jobs declared in a component's configuration. Harper invokes a designated export from your component on a cron or interval schedule, and in a cluster each job fires exactly once per occurrence - on a single, automatically elected node - rather than once per node or worker. |
There was a problem hiding this comment.
The companion implementation has no distributed lock/CAS and explicitly allows duplicate delivery during failover, DST fall-back, and split-brain recovery. Calling this "exactly once per occurrence" contradicts the idempotency warning below and may lead users to omit deduplication. Describe this as leader-coordinated single execution under normal operation with possible duplicates, and update the same absolute claim in the 5.2 release notes.
There was a problem hiding this comment.
Fixed in af1a837: the intro now describes leader-coordinated single execution under normal operation, with explicit duplicate-delivery cases (failover, DST fall-back, split-brain recovery) tied to the idempotency requirement. The 5.2 release-notes entry carried the same overstatement and is aligned too. (AI-generated response)
|
|
||
| **Handlers should be idempotent.** Harper's clustering has no distributed lock, so leadership failover and daylight-saving fall-back can occasionally deliver the same logical occurrence twice. Design handlers so that running twice for one occurrence is harmless. | ||
|
|
||
| Runs of the same job never overlap: if a run is still going when its next occurrence arrives, that occurrence is skipped (with a log entry) rather than stacked. |
There was a problem hiding this comment.
The implementation does not consistently skip an occurrence when a handler outlasts its cadence. It arms the next timer only after the handler settles; an overdue interval then runs immediately, while the cron catch-up sweep can run the latest missed occurrence afterward. This matters because a slow interval handler can execute back-to-back. Either align the implementation with this statement or document the actual late/catch-up behavior.
There was a problem hiding this comment.
Fixed in af1a837, documenting the implementation as-is: runs never overlap; missed time is not queued; an overdue interval job runs back-to-back after a slow handler; cron makes up at most its single most recent missed occurrence (context.catchUp = true). (AI-generated response)
|
|
||
| ### `interval` | ||
|
|
||
| Type: `string` (duration) |
There was a problem hiding this comment.
The schema accepts interval as either a duration string or a number of seconds, so Type: string is incomplete. Runtime validation also rejects intervals longer than 365 days, but only the one-second minimum is documented. List both accepted types and both bounds so configurations do not fail unexpectedly.
There was a problem hiding this comment.
Fixed in af1a837: the field now reads 'Type: string (duration) or number (seconds)' and documents both bounds (1 second to 365 days). (AI-generated response)
Unquoted leading-asterisk crons are YAML aliases and @macros are reserved characters; a space before # in a handler reference silently drops the export name. Surfaced by audit of the feature PR. Also fixes a leftover cleanupOldRecords reference from the example rework. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🚀 Preview DeploymentYour preview deployment is ready! 🔗 Preview URL: https://preview.harper-documentation.harperfabric.com/pr-592 This preview will update automatically when you push new commits. |
- 'exactly once per occurrence' -> leader-coordinated single execution under normal operation, with possible duplicate delivery during failover/DST fall-back/split-brain recovery (consistent with the idempotency requirement); release-notes entry aligned too - overlap semantics corrected: runs never overlap, but an overdue interval job runs back-to-back after a slow handler, and cron makes up at most its most recent missed occurrence - interval documents both accepted types (duration string or number of seconds) and both bounds (1s to 365 days) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🚀 Preview DeploymentYour preview deployment is ready! 🔗 Preview URL: https://preview.harper-documentation.harperfabric.com/pr-592 This preview will update automatically when you push new commits. |
heskew
left a comment
There was a problem hiding this comment.
Verified the documented config surface against #1828's implementation: job keys (name/cron/interval/timezone/handler) match scheduler.ts's KNOWN_JOB_KEYS exactly, the "exactly one of cron/interval" rule and the module#export handler format match, and the context props (jobName/scheduledAt/catchUp) match engine.ts's handler call. Accurate as written — just worth landing this alongside/after #1828. LGTM.
🤖 Reviewed via cross-model pipeline; approved by @heskew.
kriszyp
left a comment
There was a problem hiding this comment.
Approving — clean, thorough addition; the clustering/idempotency edge-case coverage is great.
One fix worth making to the example (reference/components/scheduler.md:97): spread the untrusted external payload before the explicit keys, so an id in the API response can't silently override our 'latest' id and redirect the write:
await tables.ExchangeRates.put({ ...rates, id: 'latest', fetchedAt: context.scheduledAt });(As written — ...rates last — a rogue id key from the external API would win.) Always spread untrusted objects first so explicit keys override them.
— Claude
Review nit (kriszyp): as written, a rogue `id` in the external API response would override the explicit 'latest' id and redirect the write. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🚀 Preview DeploymentYour preview deployment is ready! 🔗 Preview URL: https://preview.harper-documentation.harperfabric.com/pr-592 This preview will update automatically when you push new commits. |
|
|
||
| ```javascript | ||
| // Nightly snapshot: roll the current state of a table into a dated snapshot record | ||
| export async function snapshotMetrics(context) { |
There was a problem hiding this comment.
Nit, non-blocking: tables.Devices.search([]) is valid (Table.ts accepts a bare array as conditions directly), but resource-api.md only documents the search({}) object form for an unconditioned scan. Consider using search({}) here to match the idiom taught elsewhere, so a reader who cross-references resource-api.md finds the exact form used in this example.
There was a problem hiding this comment.
Done in 20b6a4b — switched the example to search({}) to match the documented unconditioned-scan form in resource-api.md. Leaving the thread for you to resolve. (AI-generated response)
|
Following up from review finding 4 on the companion PR (HarperFast/harper#1828): the Missed Occurrences section on the main scheduler page already documents this correctly ("Only the single most recent missed occurrence is made up, not every occurrence missed during an outage") — good. The one spot that's still double-fire-only is the "What's New" changelog blurb:
Suggest appending the flip side for consistency with the main page, e.g.:
Low priority wording nit, not blocking. (Note: matching JSDoc clarification landed on the harper side in cd2c2b961.) 🤖 Generated with Claude Code |
Matches the unconditioned-scan form documented in resource-api.md (review nit from kriszyp). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Review nit addressed (20b6a4b) — this is otherwise ready. Holding the merge intentionally: this documents the (AI-generated comment on behalf of @jcohen-hdb) |
🚀 Preview DeploymentYour preview deployment is ready! 🔗 Preview URL: https://preview.harper-documentation.harperfabric.com/pr-592 This preview will update automatically when you push new commits. |
kriszyp
left a comment
There was a problem hiding this comment.
I think these are some small nits, but maybe can help refine the docs.
|
|
||
| ### Run State | ||
|
|
||
| Each job's last run time, status, duration, and last error (if any) are recorded in the `hdb_scheduler_state` system table, alongside the leader lease. This state replicates across the cluster so a newly promoted leader knows what has already run. |
There was a problem hiding this comment.
The companion implementation's DESIGN.md explicitly notes that, on constrained/directional replication topologies, the scheduler lease may not reach every node. In that supported configuration, nodes can each elect themselves and execute every occurrence continuously, rather than only duplicating occasionally during failover. Please qualify the cluster-once guarantee here (and in the intro/release-note wording) as requiring hdb_scheduler_state to reach every participating node, and call out the topology limitation alongside the idempotency requirement.
There was a problem hiding this comment.
Addressed in 004cec8: new 'Replication Topology' section under Cluster Behavior states the guarantee holds only where hdb_scheduler_state replicates, describes the every-node-self-elects failure mode on constrained/directional topologies (distinct from the occasional failover duplicate), and points at replication.databases scoping; the intro now carries the same qualification with an anchor link. (AI-generated response)
|
|
||
| // Scheduled pull from an external API into a Harper table | ||
| export async function syncExchangeRates(context) { | ||
| const response = await fetch('https://api.example.com/rates'); |
There was a problem hiding this comment.
Could we give this request an application-level deadline and document the failure behavior? Core records a rejected handler as an attempted run (lastRunAt/lastStatus: error) and does not retry or catch it up, so one transient 503 on a daily job loses that occurrence. If the response body never settles, the stronger failure is that the single-flight job never reschedules at all. Please use something like signal: AbortSignal.timeout(...) and state that handlers needing delivery guarantees must implement bounded retry/backoff.
There was a problem hiding this comment.
Addressed in 004cec8: the example fetch now uses AbortSignal.timeout(10_000) with a comment explaining why (single-flight — an unsettled request blocks rescheduling), and a new 'A failed run is not retried' paragraph documents the failure semantics (lastStatus: error, occurrence not made up) and tells handlers needing delivery guarantees to implement bounded retry/backoff. (AI-generated response)
|
|
||
| ## Configuration | ||
|
|
||
| In your component's `config.yaml`, use the `scheduler` key to declare jobs: |
There was a problem hiding this comment.
Please warn that creating config.yaml replaces Harper's default component config rather than merging with it. A previously config-less app copying this scheduler-only sample loses rest, graphqlSchema, roles, jsResource, fastifyRoutes, and static; its endpoints can disappear and the example's expected table globals may no longer load. A link to the Components overview's Default Configuration section plus an instruction to retain/redeclare the plugins the app uses would prevent that copy/paste failure.
There was a problem hiding this comment.
Addressed in 004cec8: the Configuration intro now warns that creating a config.yaml replaces the default component config (no merging), links to the Components overview's Default Configuration section, and names the plugins to keep declared. (AI-generated response)
|
|
||
| Type: `string` | ||
|
|
||
| An IANA timezone (for example `America/Chicago`) the cron expression is evaluated in. Defaults to the server's timezone. Only valid with `cron`. |
There was a problem hiding this comment.
In core, this default is resolved from the local runtime on whichever node is currently scheduler leader. If cluster nodes use different host timezones, failover can shift a 0 2 * * * job by hours and can create an extra or missed expected wall-clock run. Please recommend an explicit timezone for clustered deployments unless every participating node is guaranteed to use the same timezone.
There was a problem hiding this comment.
Addressed in 004cec8: the timezone default is now described as the current leader's host timezone, with an explicit recommendation to set timezone in clusters whose nodes may differ — including the concrete failure (a failover shifting '0 2 * * *' by hours). (AI-generated response)
| const rates = await response.json(); | ||
| // Spread the untrusted API payload first so our explicit keys win - otherwise | ||
| // a rogue `id` in the response could redirect the write to another record | ||
| await tables.ExchangeRates.put({ ...rates, id: 'latest', fetchedAt: context.scheduledAt }); |
There was a problem hiding this comment.
Putting explicit keys after ...rates correctly protects id and fetchedAt, but this still persists every other field from a payload the example calls untrusted. Harper schemas are flexible by default, so undeclared properties are not automatically rejected; a malformed upstream can write arbitrary extra fields or unexpected shapes into latest. Could the example validate/destructure the expected fields before put() while retaining the current explicit-key-last ordering?
There was a problem hiding this comment.
Addressed in 004cec8: the example now destructures and type-checks the expected fields before put() (explicit keys still last), with a comment noting Harper's flexible schemas would otherwise persist whatever shape the upstream sent. (AI-generated response)
…nded external calls, config-replacement warning, leader-local timezone caveat, payload validation - Qualify the cluster-once guarantee: it requires hdb_scheduler_state to reach every participating node; new Replication Topology section covers the constrained/directional-topology failure mode - Bound the example's external fetch with AbortSignal.timeout and document failure semantics (failed runs are recorded, not retried) - Warn that creating config.yaml replaces the default component config - Recommend explicit timezone for clusters (default resolves on the current leader's host) - Validate/destructure the API payload in the example instead of spreading an untrusted shape - Release notes: note catch-up backfills only the most recent miss Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Changelog nit also addressed in 004cec8 — took your suggested wording verbatim (catch-up backfills only the single most recent missed occurrence). All five inline threads from today's review are addressed in the same commit; left them open for you to resolve. (AI-generated comment) |
🚀 Preview DeploymentYour preview deployment is ready! 🔗 Preview URL: https://preview.harper-documentation.harperfabric.com/pr-592 This preview will update automatically when you push new commits. |
kriszyp
left a comment
There was a problem hiding this comment.
I think we are down to some small minor nits.
|
|
||
| **A failed run is not retried.** If a handler throws (or its outbound request times out), the run is recorded with `lastStatus: error` and the occurrence is not made up - the job simply fires again at its next scheduled time. Handlers that need delivery guarantees should implement their own bounded retry/backoff inside the handler. | ||
|
|
||
| Runs of the same job never overlap. Missed time is not queued up, but it is also not always skipped outright: when a run outlasts its cadence, an interval job's next run starts promptly after the previous one finishes (a slow handler can therefore run back-to-back), and a cron job makes up at most its single most recent missed occurrence (delivered with `context.catchUp` set to `true`). |
There was a problem hiding this comment.
Could we qualify this as "On a single scheduler leader, runs of the same job never overlap" and mention that duplicate deliveries across leaders can overlap? The implementation's job.running guard is process-local. If node A is partitioned while a slow handler is in flight and node B promotes, B can invoke the same logical occurrence before A finishes. The existing idempotency warning covers duplicate results, but the absolute wording here could make readers assume concurrent external side effects or read/modify/write work are impossible.
There was a problem hiding this comment.
Addressed in c01c5d6: now reads 'On a single scheduler leader, runs of the same job never overlap' with an explicit note that the guard is per-leader and the duplicate-delivery cases can overlap in time across nodes — so idempotency must cover concurrent execution, not just repeated execution. (AI-generated response)
|
|
||
| One node in the cluster - the scheduler leader - runs all scheduled jobs; the others watch. Leader election is automatic and requires no configuration: | ||
|
|
||
| - The leader maintains a heartbeat in the replicated `system` database. If it stops heartbeating (crash, shutdown, partition) for more than five minutes, the next node in line promotes itself; with default timings, failover completes within about six and a half minutes. |
There was a problem hiding this comment.
Could we scope the ~6.5-minute estimate to the case where the first eligible successor is alive? The 5-minute stale threshold plus the 75-second watcher interval supports that case, but promotionWaitMs adds a 150-second escalation rung for each earlier eligible node that does not promote. For example, with [A, B, C], if stale leader A and preferred successor B are both unavailable, C can take materially longer than this sentence suggests.
There was a problem hiding this comment.
Addressed in c01c5d6: the estimate is now scoped to 'when the first eligible successor is up,' with the escalation ladder called out (each earlier down successor adds ~2.5 minutes before the next node claims). (AI-generated response)
… to first-successor-alive Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🚀 Preview DeploymentYour preview deployment is ready! 🔗 Preview URL: https://preview.harper-documentation.harperfabric.com/pr-592 This preview will update automatically when you push new commits. |
|
|
||
| **A failed run is not retried.** If a handler throws (or its outbound request times out), the run is recorded with `lastStatus: error` and the occurrence is not made up - the job simply fires again at its next scheduled time. Handlers that need delivery guarantees should implement their own bounded retry/backoff inside the handler. | ||
|
|
||
| On a single scheduler leader, runs of the same job never overlap. Note this guard is per-leader: in the rare duplicate-delivery cases above (a partitioned leader with a slow handler still in flight while its successor promotes), the two deliveries of one occurrence run on different nodes and CAN overlap in time - so idempotency must cover concurrent execution, not just repeated execution. Missed time is not queued up, but it is also not always skipped outright: when a run outlasts its cadence, an interval job's next run starts promptly after the previous one finishes (a slow handler can therefore run back-to-back), and a cron job makes up at most its single most recent missed occurrence (delivered with `context.catchUp` set to `true`). |
There was a problem hiding this comment.
This guarantee also has a reload/redeploy boundary. unregisterComponentJobs() removes the old registered object and clears its timer, but it does not cancel a handler already awaited by executeJob(); registration then creates a new object with running: false, so the replacement can fire while the old handler is still in flight on the same leader. Could we qualify this as non-overlap for one unchanged registration and explicitly warn that reload/redeploy can overlap an in-flight run? Otherwise a user may rely on this sentence for non-idempotent mutations and see concurrent execution during a normal deploy.
There was a problem hiding this comment.
Addressed in 7031a07: the guarantee now reads 'For a single, unchanged job registration on one scheduler leader' with both piercing boundaries called out — cross-node duplicate delivery and the reload/redeploy in-flight overlap — framed as reasons idempotency must cover concurrent execution. (AI-generated response)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🚀 Preview DeploymentYour preview deployment is ready! 🔗 Preview URL: https://preview.harper-documentation.harperfabric.com/pr-592 This preview will update automatically when you push new commits. |
| total++; | ||
| if (device.status === 'active') active++; | ||
| } | ||
| // Keyed by day, so a duplicate delivery of the same occurrence just rewrites |
There was a problem hiding this comment.
Using the date as the primary key prevents duplicate rows, but it does not make this handler concurrency-idempotent. The duplicate deliveries described below can overlap: one run can scan 100 active devices, another scan 101 and write first, then the slower older run can overwrite the record back to 100. Could we use a genuinely deterministic/atomic example, or call this duplicate-row avoidance and explain that concurrent executions need stable input, reconciliation, or an application-specific atomic claim?
|
|
||
| ### Missed Occurrences | ||
|
|
||
| If the most recent occurrence of a cron job was missed - the leader was down, a failover was in progress, or daylight-saving time skipped the slot - the scheduler fires one catch-up run for it (with `context.catchUp` set to `true`). Only the single most recent missed occurrence is made up, not every occurrence missed during an outage. Interval jobs resume their cadence from their last recorded run. |
There was a problem hiding this comment.
Could we reconcile this DST example with line 63? A spring-forward gap occurrence is mapped to the shifted instant rather than skipped, so a normally armed run fires there with catchUp: false. It can still be missed because the leader was down, but DST itself does not turn that normal run into catch-up. Removing daylight-saving time skipped the slot (or explaining the distinct missed-shifted-occurrence case) would keep the context.catchUp contract unambiguous.
| | [`loadEnv`](../environment-variables/overview.md) | Load environment variables from `.env` files | | ||
| | [`rest`](../rest/overview.md) | Enable automatic REST endpoint generation | | ||
| | [`roles`](../users-and-roles/overview.md) | Define role-based access control from YAML files | | ||
| | [`scheduler`](./scheduler.md) | Run recurring jobs on a cron or interval schedule | |
There was a problem hiding this comment.
This new entry is explicitly a plugin, but the table is headed Built-In Extensions Reference; the same page distinguishes plugins from deprecated extensions above. Could we rename the heading to Built-In Components Reference or Built-In Plugins and Extensions Reference so the navigation does not classify scheduler as an extension?
Documents the new built-in
schedulercomponent plugin shipping in HarperFast/harper#1828 (issue HarperFast/harper#951):reference/components/scheduler.md: thescheduler:config surface, supported cron syntax,intervaldurations, timezone/DST semantics, the handler contract (JobRunContext, idempotency requirement), and cluster behavior (leader election, failover timing, missed-occurrence catch-up, run state).reference/components/overview.mdand a sidebar entry.Draft until the feature PR itself leaves draft; the two should land together.
npm run buildandnpm run format:checkpass locally (the one broken-anchor warning on the 5.1 release-notes page is pre-existing on main).Generated by an LLM (Claude Fable 5) pairing with @jcohen-hdb.
🤖 Generated with Claude Code