18 KiB
fpc-cron Developer Guide
This guide walks through building consumers of cron.runner.TCron
end-to-end: the dependency graph, lifecycle, the typed observer
callbacks (cron.events), the logger plumbing (log.types), the
schema, and the common pitfalls. Read it in addition to the API
table in the top-level README.md.
Table of contents
- What problem fpc-cron solves
- Dependencies and the fpc-* shape
- Quick start
- Lifecycle: Create → Start → Free
- Tasks: interval vs cron
- System tasks via
RegisterSystemTask - Plugin tasks via
TGetExtraTasksFunc - Typed observer callbacks (cron.events)
- Logger plumbing (log.types)
- Schema, table names, drop-in compatibility with Fastway
- The cron expression grammar
- Threading model
- Common pitfalls
- Bridging cron.events to fpc-events for ecosystem-wide pub/sub
What problem fpc-cron solves
A standalone task runner that does two things:
- Fires a callback every N seconds (interval tasks).
- Fires a callback when a 5-field cron expression matches (cron tasks).
Tasks live in two SQL tables (system_scheduler for the schedule,
scheduler_log for the audit log), so state survives restarts.
Consumers can dynamically add tasks, mutate them via the JSON API,
and observe runner activity through typed callbacks.
Origin: this is a verbatim port of TFWScheduler from
Fastway-Server's fw_scheduler.pas. The DB schema is unchanged
from canonical so an existing Fastway database is reusable
without migration.
Dependencies
fpc-cron → fpc-log (log.types — logger shape)
fpc-db (database.pool / database.dialect / database.schema — DB layer)
That's it. No fpc-events dependency: cron.events defines its own
typed observer callbacks (same shape as fpc-binkp's bp.events and
fpc-comet's cm.events). Consumers wanting cross-system pub/sub
fan-out can bridge cron.events handlers to a TEventBus themselves
(see bridging).
build.sh and run_tests.sh expect fpc-log/ and fpc-db/ to
sit alongside this repo under ~/Source Code/.
Quick start
Smallest working example:
program tiny_cron;
{$mode objfpc}{$H+}
uses
{$IFDEF UNIX}cthreads,{$ENDIF}
Classes, SysUtils, fpjson, DateUtils,
log.types,
database.types, database.pool,
cron.types, cron.events, cron.runner;
type
THost = class
procedure RunTask(const APluginName, ATaskName: string);
procedure HandleStart(const APluginName, ATaskName: string);
procedure HandleComplete(const APluginName, ATaskName: string;
ASuccess: Boolean; ADurationMs: Integer;
const AError: string);
procedure HandleLog(Level: TLogLevel; const Category, Msg: string);
end;
procedure THost.RunTask(const APluginName, ATaskName: string);
begin
Writeln(' -> ', APluginName, '/', ATaskName);
end;
procedure THost.HandleStart(const APluginName, ATaskName: string);
begin
Writeln('start: ', APluginName, '/', ATaskName);
end;
procedure THost.HandleComplete(const APluginName, ATaskName: string;
ASuccess: Boolean; ADurationMs: Integer; const AError: string);
begin
Writeln('end: ', APluginName, '/', ATaskName,
' ok=', ASuccess, ' (', ADurationMs, 'ms)');
end;
procedure THost.HandleLog(Level: TLogLevel; const Category, Msg: string);
begin
Writeln('[', LogLevelChar(Level), '] ', Category, ': ', Msg);
end;
var
Pool: TDBPool;
C: TCron;
H: THost;
begin
Pool := TDBPool.Create;
Pool.Init(dbSQLite, '/tmp/tiny_cron.sqlite3');
H := THost.Create;
try
C := TCron.Create(Pool, @H.RunTask, nil, @H.HandleLog);
try
C.OnTaskStart := @H.HandleStart;
C.OnTaskComplete := @H.HandleComplete;
Pool.ExecSQL(
'INSERT INTO system_scheduler ' +
'(task_name, plugin_name, schedule_type, interval_seconds, ' +
' enabled, next_run) VALUES ' +
'(''heartbeat'', ''demo'', ''interval'', 5, 1, ' +
FormatDateTime('''yyyy-mm-dd hh:nn:ss''',
LocalTimeToUniversal(Now) - 1) + ')');
C.RefreshTasks;
C.Start;
Sleep(12000);
C.Terminate;
finally
C.Free;
end;
finally
H.Free;
Pool.Free;
end;
end.
Build it with the same -Fu paths the lib uses:
fpc -O2 -Sh -B \
-Fu../fpc-cron/src \
-Fu../fpc-log/src \
-Fu../fpc-db/src \
-FUbuild -FEbuild tiny_cron.pas
Lifecycle
TCron derives from TThread and is created suspended — you
must call .Start for the wake loop to begin. This matches the
canonical Fastway inherited Create(True) pattern.
C := TCron.Create(Pool, ARunTask, AGetExtraTasks, ALogger);
↓
1. Inherits TThread (suspended).
2. Stores the dependencies.
3. Calls Pool.DeclareTable for system_scheduler + scheduler_log.
4. LoadTasksFromDB — reads every row, recomputes NextRun for
enabled tasks (UTC now + interval, or next cron match).
5. SyncPluginTasks — calls AGetExtraTasks if assigned, inserts
missing plugin task rows, deletes orphans.
6. DoLog(llInfo, '...initialized with N tasks').
C.RegisterSystemTask('cleanup', @MyCleanup); { repeat as needed }
C.OnTaskStart := @MyHandler; { wire observers }
C.Start { begin wake loop }
↓ wake loop, every 1 s:
for each enabled task whose NextRun <= UTCNow:
OnTaskStart(plugin, task) { if assigned }
RunSystemTask(task) | FRunTask(plug, task)
update LastRun / RunCount / FailCount / NextRun
INSERT INTO scheduler_log
OnTaskComplete(plugin, task, success, duration, err)
C.Terminate; { signals stop event; wake loop exits next cycle }
C.Free; { calls Terminate if running, WaitFor, frees lock }
Free is safe to call from any thread. It does:
if FRunning then begin
Terminate; { sets Terminated flag }
RTLEventSetEvent(FStopEvent); { wakes the 1 s sleep early }
WaitFor; { joins the thread }
end;
Tasks: interval vs cron
A row in system_scheduler is one of:
- interval:
schedule_type='interval',interval_seconds=N. Runs every N seconds. After the task returns,NextRun := UTCNow + N seconds. If a 5-minute task takes 6 minutes, the next firing is 5 minutes after it returns, not after it started. - cron:
schedule_type='cron',cron_expr='M H D Mo W'. See cron grammar. After the task returns,NextRun := next minute that matches.
Both kinds may live in the same table; the runner dispatches on
schedule_type.
System tasks
A task with plugin_name='system' is dispatched through a
consumer-registered table. Canonical Fastway hardcoded specific
names (log_cleanup, db_vacuum, wal_checkpoint,
config_watch, plugin_update_check, old_news_cleanup); fpc-cron
keeps the dispatcher shape but moves the names out:
C.RegisterSystemTask('cleanup', @Host.CleanupOldEntries);
C.RegisterSystemTask('vacuum', @Host.VacuumDb);
C.RegisterSystemTask('rotate', @Host.RotateLogs);
Then in the database:
INSERT INTO system_scheduler
(task_name, plugin_name, schedule_type, interval_seconds, enabled)
VALUES
('cleanup', 'system', 'interval', 3600, 1),
('vacuum', 'system', 'cron', '0 3 * * *', 1); -- 3 AM daily
If the runner sees a system task whose name isn't registered,
it logs 'unknown system task: <name>' at level llWarn and
moves on. No exception.
Plugin tasks
For tasks that live in plugin code rather than DB rows, supply
a TGetExtraTasksFunc callback. The runner calls it at Create
and on RefreshTasks:
function THost.GetPluginTasks: TJSONArray;
var
Obj: TJSONObject;
begin
Result := TJSONArray.Create; { runner takes ownership }
Obj := TJSONObject.Create;
Obj.Add('plugin_name', 'fido');
Obj.Add('task_name', 'tic_pickup');
Obj.Add('description', 'TIC inbound pickup');
Obj.Add('interval_seconds', 60);
Result.Add(Obj);
end;
C := TCron.Create(Pool, @MyRunner, @MyHost.GetPluginTasks, @MyHost.HandleLog);
The runner:
- Calls
GetPluginTasks(). - For each entry, inserts a row into
system_schedulerwithcategory='plugin'if not already present. - Deletes any
category='plugin'rows whoseplugin_nameis no longer in the supplier's return value (orphan cleanup). - Frees the array (the consumer must not retain it).
The orphan cleanup only touches rows with user_modified=0 —
once a sysop edits a plugin task via UpdateTask, it's no longer
auto-removed.
Typed observer callbacks
Six observer callback properties on TCron, all of type
procedure(...) of object, all default-nil (no-op):
| Property | Fires when … |
|---|---|
OnTaskStart |
a task is about to run |
OnTaskComplete |
a task returned (success or error) |
OnTaskRegistered |
SyncPluginTasks inserted a previously-unseen plugin task row |
OnPluginOrphaned |
SyncPluginTasks is about to delete rows for an unloaded plugin |
OnThreadStart |
the wake loop entered Execute |
OnThreadStop |
the wake loop is leaving Execute |
Wire by assignment:
C.OnTaskStart := @Host.HandleTaskStart;
C.OnTaskComplete := @Host.HandleTaskComplete;
C.OnThreadStop := @Host.HandleShutdown;
The runner invokes these synchronously on its own thread; if you
need to marshal to a different thread (UI updates, cross-thread
signalling), the handler does that itself. Don't do heavy work
inline — OnTaskComplete running for 200 ms means the next task
gets delayed.
Logger plumbing
TCron.Create's last parameter is a log.types.TLogProc (from
fpc-log) — the canonical fpc-* logger shape:
TLogProc = procedure(Level: TLogLevel; const Category, Msg: string) of object;
The runner passes:
Level:llDebug,llInfo,llWarn,llError.Category: always'cron'.Msg: human-readable, no trailing newline.
nil is valid (no log output).
procedure THost.HandleLog(Level: TLogLevel; const Category, Msg: string);
begin
Writeln('[', LogLevelChar(Level), '] ', Category, ': ', Msg);
end;
This is the same logger shape used by every other fpc-* library (fpc-binkp, fpc-comet, fpc-emsi, fpc-events) — wire one sink across them all.
Schema
Auto-declared in Create via Pool.DeclareTable. Spec helpers
exposed for consumers who want to declare independently:
APool.DeclareTable(BuildSystemSchedulerSpec(APool.Dialect.NowExpr));
APool.DeclareTable(BuildSchedulerLogSpec);
Two tables:
system_scheduler (UNIQUE on plugin_name, task_name)
| Column | Type | Notes |
|---|---|---|
id |
BigInt | PK, auto-increment |
task_name |
Text | |
plugin_name |
Text | 'system' for system tasks |
description |
Text | |
category |
Text | 'general', 'plugin', free-form |
schedule_type |
Text | 'interval' or 'cron' |
interval_seconds |
Int | only for interval tasks |
cron_expr |
Text | only for cron tasks |
enabled |
Int | 0/1 |
last_run |
DateTime | UTC; null until first fire |
next_run |
DateTime | UTC; null when disabled |
last_result |
Text | 'success' / 'error' / 'running' |
last_error |
Text | error message from the most recent failure |
run_count |
Int | |
fail_count |
Int | |
user_modified |
Int | 0=auto, 1=human-edited (gates orphan cleanup) |
created_at |
DateTime | UTC |
scheduler_log (indexed on started_at and (plugin_name,task_name))
Append-only audit row per ExecuteTask call. Every column is
self-explanatory: task_name, plugin_name, started_at (UTC),
finished_at (UTC), duration_ms, result, error_message,
output.
Drop-in compatibility with Fastway
Table names + columns are byte-identical to canonical
fw_schema.pas (BuildSystemScheduler / BuildSchedulerLog).
A Fastway-Server database can be reused as-is — fpc-cron just
operates on the existing rows.
Cron grammar
5-field syntax (POSIX cron without name extensions):
M H Dom Mon Dow
0 3 * * * → daily 03:00 local
*/15 * * * * → every 15 minutes
0 0 1 * * → monthly on the 1st at 00:00
0 9-17 * * 1-5 → weekday business hours, top of every hour
30 * * * * → every hour at :30
0 0 * * 0 → Sundays at 00:00
| Position | Field | Range |
|---|---|---|
| 1 | minute | 0–59 |
| 2 | hour | 0–23 |
| 3 | day-of-month | 1–31 |
| 4 | month | 1–12 |
| 5 | day-of-week | 0–6 (0=Sunday) |
Each field accepts:
*— any value*/N— every Nth value starting at the field minimuma-b— range (inclusive)a-b/N— stepped rangea,b,c— explicit list
Cron expressions are interpreted in local time (so
"0 3 * * *" means 3 AM in the server's TZ). The matched minute
is converted to UTC via LocalTimeToUniversal for storage in
next_run. Consumers wanting UTC cron should set the OS TZ to
UTC.
Threading model
TCron.Execute (the thread body) wakes once per second. The
wake loop:
- Acquire
FLock. - Iterate
FTasks. For each enabled task whoseNextRun ≤ UTCNow, markIsRunning := True, release the lock, callExecuteTask(I), re-acquire the lock, continue. - Release
FLock. - Wait up to 1 s on
FStopEvent.
The lock-release-during-execute pattern means a task callback that takes 5 minutes does NOT block other tasks from being considered for firing — a different task can fire on a parallel wake. Practically though, the iteration is sequential within a single wake; long tasks delay later items in that wake.
UpdateTask / RefreshTasks / RegisterSystemTask are safe to
call from any thread — they all acquire FLock for their
mutations.
Common pitfalls
-
Forgetting
.Start.Createreturns a suspended thread. Tasks won't fire until you call.Start. -
Pre-existing rows missing the schema. If you
INSERT INTO system_schedulerbefore callingTCron.Create, the table doesn't exist yet. Either: (a) constructTCronfirst thenRefreshTasks, or (b) callPool.DeclareTable(BuildSystemSchedulerSpec(...))manually before the insert (seeEnsureSchedulerSchemaintests/test_runner.pas). -
Heavy work in observer callbacks.
OnTaskStart/OnTaskCompleterun on the runner thread. Slow handlers delay subsequent dispatches. -
TJSONArray ownership in the supplier.
SyncPluginTasksfrees the array. Don't return a reference you also store elsewhere; clone it. -
Two TCron instances on one pool. The second
Create'sPool.DeclareTableis a no-op (idempotent), but both runners will read the samesystem_schedulerrows and race to fire each task. Don't do that. -
Cron expressions in UTC vs local. Local-time interpretation is the Fastway design choice, kept verbatim. If your server runs in UTC, your cron expressions are UTC. If it runs in
America/Los_Angeles, cron expressions are Pacific. -
MatchesCronknown leaks. If a cron field past the first raises during parsing, earlierTBitsinstances leak. The port preserves canonical's pre-try-finally allocation order. Real-world impact is negligible (cron expressions in the DB are validated upstream).
Bridging to fpc-events
If you want every TCron event to land on a fpc-events
TEventBus for ecosystem-wide pub/sub fan-out, write a small
bridge class:
uses
events.bus, fpjson, log.types, cron.events, cron.runner;
type
TCronEventBridge = class
Bus: TEventBus;
procedure HandleStart(const Plug, Task: string);
procedure HandleComplete(const Plug, Task: string;
Success: Boolean; DurationMs: Integer;
const Error: string);
end;
procedure TCronEventBridge.HandleStart(const Plug, Task: string);
var Data: TJSONObject;
begin
Data := TJSONObject.Create;
try
Data.Add('plugin', Plug);
Data.Add('task', Task);
Bus.Fire('cron', 'cron.task_start', Data);
finally
Data.Free;
end;
end;
{ ... HandleComplete similarly ... }
C.OnTaskStart := @Bridge.HandleStart;
C.OnTaskComplete := @Bridge.HandleComplete;
The bridge is the consumer's choice — fpc-cron itself doesn't depend on fpc-events, matching the per-library typed-callback pattern across the rest of the fpc-* family.