Skip to content

v1.27.0: APICronScheduler — scheduled calls of API routes - #189

Merged
gmpassos merged 6 commits into
masterfrom
feat/api-cron
Oct 18, 2026
Merged

gmpassos merged 6 commits into
masterfrom
feat/api-cron

Conversation

@gmpassos

@gmpassos gmpassos commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds a built-in scheduler that calls API routes on cron schedules, so apps don't need an external cron hitting a secret-protected route.

Jobs (APICronJob)

  • 5-field cron expression (CronExpression: lists, ranges, steps, names, @daily/@hourly/...) evaluated in an IANA time zone, DST aware (a nonexistent time runs at the next valid instant; a repeated time runs once).
  • Declared by @APICron on reflected route methods, APIRoot.loadCronJobs(), or the cron.jobs config (which can also override or disable code jobs by name).
  • Called through APIRoot.call with APIAuthentication.system (passes APIRouteAuthenticatedRule), requester source internal, and X-Cron-Job / X-Cron-Scheduled-At / X-Cron-Run / X-Cron-Attempt headers.
  • Continuation: while the response has X-Continuation (APIResponse.continuation), the route is called again with continuation=<token>, up to maxCalls.
  • Retries (APICronRetry): exponential backoff (default 3 attempts: 30s, 2m), honors Retry-After; UNAUTHORIZED/BAD_REQUEST/NOT_FOUND aren't retried; per-call timeout.

Scheduler (APICronScheduler)

  • Started by APIRoot.initialize when cron.enabled: true (not in auxiliary worker isolates: one per process), stopped by APIRoot.close.
  • Exactly once per occurrence: the run row is claimed with a unique (job, scheduled time) insert, so it also works across instances.
  • Never in parallel: a per-job lock with a lease, renewed while running. If an instance stops mid-run, another instance resumes the run from its last continuation token once the lease expires.
  • Missed occurrences: the latest runs once within maxLateness (default 1 day); older ones are recorded as skipped.
  • runNow(job), selectRuns(job), history pruning (history_days), logger APICron.

Store (APICronStore)

  • APICronStoreMemory (single instance, tests) and APICronStoreDB (api_cron_job_run, api_cron_job_lock). It uses only inserts with unique constraints and deletes by ID or by expired lease, so it works on PostgreSQL, MySQL and the memory adapters with no adapter-specific locking.
  • APIRoot.loadCronStore() picks the DB store when APICronStoreDB.buildRepositories(adapter) is registered.

Notes

  • New dependency: timezone.
  • APIModule gains cronJobs/addCronJob. Apps with generated module reflection need to regenerate it (build_runner) for the new field.
  • Version 1.27.0 + CHANGELOG + README section "Cron: scheduled routes".

Review fixes (31a1aa8)

  • Start after init: the scheduler starts once APIRoot and its modules are fully initialized, outside the init hook (cronSchedulerReady). Before, the first check could call a route before a module's initialize() finished.
  • Owner-only writes: updateRun only writes a run that is still running and owned by the writer (previousOwner on take-over). An instance that lost its lock, or is stopping, writes nothing more; this is checked after every call. A finished run is never put back to running.
  • Lease loss: if renewals fail until the lease runs out, the lock is considered lost.
  • No overlap on timeout: a slow call is logged and waited for, never overlapped by a retry.
  • No poison runs: a continuation token over 4096 chars, or an error while running, fails the run instead of leaving it running to be resumed forever.
  • Store hardening: lock renew/release verify the row after writing, so no auto-inserted garbage lock is left. Pruning never deletes a running run. lastRun/selectRuns read only the latest rows.
  • Smaller fixes: runNow never takes a scheduled slot; Retry-After is capped at maxDelay; graceful stop() + waitRunning(); a warning when the in-memory store is used.

Review fixes (2) (890cbb3)

  • Lenient cron config: a cron value that isn't a map no longer breaks APIRoot.initialize. A bool (or bool string) means enabled; any other value disables cron. Scheduling the start never fails initialization.
  • Stop: after stop(), tick()/runNow() do nothing until start() again.
  • No ghost locks: an in-flight lock renewal is awaited before the lock is released.
  • Config entries: a bad or stale cron.jobs entry is skipped and logged; an override of an unknown job is ignored. A top-level attempts key is supported.
  • Validation: positive timeout/maxLateness/leaseDuration, retry.attempts >= 1; the time zone is resolved lazily (a job with an invalid zone is skipped).
  • Module jobs: a duplicate job name in a module is an error, and an @APICron job is only added with its route.
  • Start: only after a successful init. cronSchedulerReady works before initialize and is reset on close; startCronScheduler restarts a stopped scheduler; a rejected final save is reported as interrupted.
  • Docs: fixed retry/timeout, config overrides, resume timing and the skipped-record cap.

Also added since the first description

  • @APICron.at(minute:, hour:, dayOfMonth:, month:, dayOfWeek:, ...); both constructors take timeout, maxLateness, leaseDuration, retry.
  • Fix: history pruning keeps runs still inside their job's maxLateness window (otherwise the occurrence could run again).
  • Tests: CronExpression property tests against a minute-by-minute scan; DB integration through the real APIRoot lifecycle (two instances on one DB run an occurrence once; a closed instance is resumed by another); APIServer integration (forged X-Cron-* headers are refused).

Tests

  • test/bones_api_cron_test.dart: cron parsing and next/previous times (UTC, Europe/Paris DST gap/overlap, America/Sao_Paulo), config/merging, retry/backoff/Retry-After, timeout, continuation and maxCalls, system auth and headers, locks, two schedulers on one store, no-parallel, resume, missed/skipped, history pruning, @APICron on reflected routes, APIRoot integration.
  • test/bones_api_cron_db_*_test.dart: APICronStoreDB on memory, PostgreSQL and MySQL (docker): unique claims, locks (renew/take-over/release), parallel claims and locks with one winner, and two schedulers on the same DB including resume.
  • Local on 890cbb3: unit (-x docker -x concurrency) 2150 pass, -t docker 329 pass, concurrency 86 pass; dart analyze --fatal-infos clean; pub publish --dry-run 0 warnings.
  • CI on 890cbb3: all checks green (unit, docker, postgres, mysql, concurrency, chrome ×3, codecov).

🤖 Generated with Claude Code

gmpassos and others added 2 commits October 18, 2026 02:24
- APICronJob: a route called on a cron expression (CronExpression, 5
  fields + IANA time zone, daylight saving aware), declared by @APICron on
  reflected routes, APIRoot.loadCronJobs() or the cron.jobs config.
- Called through APIRoot.call with APIAuthentication.system, requester
  source internal and X-Cron-* headers; follows the X-Continuation header
  (APIResponse.continuation) up to maxCalls; retries with exponential
  backoff (APICronRetry), honoring Retry-After.
- Each occurrence runs once (claimed in the APICronStore) and a job never
  runs in parallel (a lock with a lease, renewed while running); an
  interrupted run is resumed from its continuation after the lease
  expires; missed occurrences: the latest runs once within maxLateness,
  older ones are recorded as skipped.
- APICronStoreMemory and APICronStoreDB (api_cron_job_run and
  api_cron_job_lock tables), history pruning, APICron logger.
- Started by APIRoot.initialize when cron.enabled (not in auxiliary
  isolates), stopped by APIRoot.close.
- New dependency: timezone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntities

Coverage of the cron files: expression 99%, job/config 100%, store 97%,
scheduler 99%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.33507% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.53%. Comparing base (01c4a84) to head (890cbb3).

Files with missing lines Patch % Lines
lib/src/bones_api_cron_store.dart 97.69% 6 Missing ⚠️
lib/src/bones_api_cron_scheduler.dart 98.44% 5 Missing ⚠️
lib/src/bones_api_base.dart 95.94% 3 Missing ⚠️
lib/src/bones_api_authentication.dart 83.33% 1 Missing ⚠️
lib/src/bones_api_cron_expression.dart 99.35% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #189      +/-   ##
==========================================
+ Coverage   95.44%   95.53%   +0.09%     
==========================================
  Files          69       73       +4     
  Lines       24215    25174     +959     
==========================================
+ Hits        23111    24049     +938     
- Misses       1104     1125      +21     
Flag Coverage Δ
unittests 95.53% <98.33%> (+0.09%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

gmpassos and others added 4 commits October 18, 2026 02:49
…o overlap

- The scheduler starts once APIRoot and its modules are fully initialized
  (outside the init hook; cronSchedulerReady), not inside initialize().
- A run is only written by its owner while running (updateRun checks the
  owner/status; previousOwner on take-over); an instance that lost the
  lock (or is stopping) writes nothing more, checked after every call.
- The lock is considered lost when its lease runs out unrenewed.
- A timed-out call is waited for (logged), never overlapped by a retry.
- A continuation token over 4096 chars, or an error while running, fails
  the run instead of leaving it running (no endless resume).
- Lock renew/release verify the row after writing (no auto-inserted
  garbage lock); pruning never deletes a running run.
- runNow never takes a scheduled occurrence slot; Retry-After capped by
  maxDelay; graceful stop (waitRunning); warning for the in-memory store;
  lastRun/selectRuns read only the latest rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ration, retry options

- @APICron.at(minute:, hour:, dayOfMonth:, month:, dayOfWeek:, ...): the
  schedule by its cron fields (default '*'); the default constructor keeps
  the cron expression.
- Both constructors take timeout, maxLateness, leaseDuration and retry
  (attempts overrides the attempts of retry).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the lateness window

- Fix: history pruning deleted runs still in their job's maxLateness
  window, so a long-lateness occurrence could run again after pruning.
- CronExpression property tests (random expressions and instants against
  a minute by minute scan: UTC, Europe/Paris, America/Sao_Paulo).
- DB integration (memory, PostgreSQL, MySQL) with the real APIRoot
  lifecycle: two instances with cron.enabled on one DB run an occurrence
  once (DB store auto-resolved); a root closed mid-run is resumed by
  another.
- APIServer integration: the jobs run as the system at start; HTTP clients
  with X-Cron-* headers are refused; stopping the server stops the
  scheduler.
- Scheduler: several jobs in one check, a failing job doesn't stop the
  others, POST jobs, config override of an @APICron.at job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lock, bad config entries

- A non-map `cron` config no longer breaks APIRoot.initialize: a bool (or
  bool string) is `enabled`, any other value is disabled; scheduling the
  start never fails the initialization.
- After stop(), tick() and runNow() do nothing (no junk running runs).
- A lock renewal in progress is awaited before releasing the lock (no
  ghost lock re-inserted by a late renewal).
- A bad or stale `cron.jobs` entry is skipped (logged), an override of an
  unknown job ignored; a non-map `retry` ignored; top-level `attempts`.
- APICronJob validates positive timeout/maxLateness/leaseDuration and
  retry.attempts >= 1; the time zone is resolved lazily (skipped if invalid).
- Duplicated cron job names in a module are an error; an @APICron job is
  only added with its route.
- The scheduler starts (and checks) only after a successful initialization;
  cronSchedulerReady works before initialize and is reset by close;
  startCronScheduler restarts a stopped scheduler; a rejected final save is
  reported as interrupted.
- Docs: timeout/retry, config overrides, resume timing, skipped cap.
- Tests for each, `_waitUntil` fails on timeout, no fixed real-time waits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gmpassos
gmpassos merged commit 94aa7ab into master Oct 18, 2026
11 checks passed
@gmpassos
gmpassos deleted the feat/api-cron branch October 18, 2026 08:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant