Wipes v0.1.0/v0.1.1 history. fpc-cron hadn't shipped to any external consumer yet; the rebuild against fpc-db v0.4.0 was inline development noise. This commit + tag is the clean public starting point. CRON_VERSION reset to 0.1.0. CHANGELOG rewritten as a single 0.1.0 entry covering the full library surface as it stands now.
fpc-cron
Centralized cron + interval task runner for Free Pascal.
Verbatim port of TFWScheduler from Fastway-Server's
fw_scheduler.pas — same surface, same semantics, no
Fastway-specific dependencies.
What it is
- One thread (
TCron), one in-memory task table, one DB-backed audit log. - Tasks come from the
system_schedulertable and from an optional consumer-supplied callback (TGetExtraTasksFunc). - Each task fires either every
IntervalSecondsor when itsCronExpr(5-field cron) matches the current minute. - "System" tasks (
PluginName = 'system') dispatch through a consumer-registeredRegisterSystemTask(name, proc)table. Other tasks dispatch through the optionalTRunTaskProc. - Typed observer callbacks (
cron.events) — assignable properties (OnTaskStart,OnTaskComplete,OnTaskRegistered,OnPluginOrphaned,OnThreadStart,OnThreadStop). Same per-library typed-callback shape as fpc-binkp'sbp.eventsand fpc-comet'scm.events. - Optional
log.types.TLogProccallback for log output (the ecosystem-wide logger shape from fpc-log). - Schema reconciliation (
system_scheduler+scheduler_log) runs inCreatevia fpc-db'sPool.DeclareTable.
Dependencies
- fpc-log —
log.types.TLogProcfor the optionalALoggerparameter onCreate. - fpc-db v0.3.0 — connection pool + dialect for the runner's two SQL tables.
The runner takes a TDBPool parameter where canonical Fastway
used a global DB; otherwise the design and behaviour are
identical to the canonical. fpc-cron does NOT depend on
fpc-events — events are emitted as typed observer callbacks
(see Bridging to fpc-events
in the developer guide).
Quick start
uses
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 Cleanup(const ATaskName: string);
procedure HandleStart(const APluginName, ATaskName: string);
procedure HandleComplete(const APluginName, ATaskName: string;
ASuccess: Boolean; ADurationMs: Integer;
const AError: string);
procedure Log(Level: TLogLevel; const Category, Msg: string);
end;
procedure THost.RunTask(const APluginName, ATaskName: string);
begin
Writeln('plugin task: ', APluginName, '/', ATaskName);
end;
procedure THost.Cleanup(const ATaskName: string);
begin
Writeln('system task: ', 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.Log(Level: TLogLevel; const Category, Msg: string);
begin
Writeln('[', LogLevelChar(Level), '] ', Category, ': ', Msg);
end;
var
Pool: TDBPool;
C: TCron;
Host: THost;
begin
Pool := TDBPool.Create;
Pool.Init(dbSQLite, '/var/lib/myapp/cron.sqlite3');
Host := THost.Create;
try
C := TCron.Create(Pool, @Host.RunTask, nil, @Host.Log);
try
C.RegisterSystemTask('cleanup', @Host.Cleanup);
C.OnTaskStart := @Host.HandleStart;
C.OnTaskComplete := @Host.HandleComplete;
C.Start; { canonical creates suspended; caller starts }
{ ...rest of app... }
C.Terminate; { Free does this for you }
finally
C.Free;
end;
finally
Host.Free;
Pool.Free;
end;
end.
See examples/interval_task.pas and examples/cron_task.pas
for self-contained runnable demos, docs/API.md
for the full callable reference, and
docs/DEVELOPER_GUIDE.md for the
consumer-oriented walkthrough (lifecycle, threading, schema,
pitfalls, bridging to fpc-events).
API surface
TCron (class derives from TThread)
| Member | Purpose |
|---|---|
Create(APool, ARunTask=nil, AGetExtraTasks=nil, ALogger=nil) |
Construct in suspended state. Declares schema, loads tasks, runs SyncPluginTasks. Caller must call Start to begin the wake loop. |
Destroy |
Signals stop, waits for the thread, releases lock. |
RegisterSystemTask(AName, AProc) |
Register a callback for system/<AName>. |
RefreshTasks |
Reload from DB and re-run SyncPluginTasks. |
RunTaskNow(ATaskID) |
Synchronously run one task by row id. |
GetTasksJSON: TJSONArray |
Snapshot of every in-memory task. |
GetTaskJSON(ATaskID): TJSONObject |
Snapshot of a single task. |
UpdateTask(ATaskID, AUpdates): Boolean |
Apply enabled / schedule_type / interval_seconds / cron_expr changes. Persists to DB. |
Running: Boolean |
True between thread start and stop. |
OnTaskStart / OnTaskComplete / OnTaskRegistered / OnPluginOrphaned / OnThreadStart / OnThreadStop |
Typed observer callbacks (see cron.events). Assignable; nil = no-op. |
class function ParseCronField(AField, AMin, AMax): TBits |
Standalone cron-field parser; testable without an instance. |
class function MatchesCron(ACronExpr, ATime): Boolean |
Standalone cron expression matcher. |
Free functions
| Function | Purpose |
|---|---|
BuildSystemSchedulerSpec(ANowExpr): TDBTable |
fpc-db TDBTable spec for the system_scheduler table. Use to declare the schema independently of TCron.Create. |
BuildSchedulerLogSpec: TDBTable |
Spec for the scheduler_log table. |
(Spec function names match canonical Fastway fw_schema.pas's
BuildSystemScheduler / BuildSchedulerLog, and the table
names in the DB are unchanged so a Fastway migration drops in
cleanly.)
Callback types (cron.types)
TRunTaskProc = procedure(const APluginName, ATaskName: string) of object;TGetExtraTasksFunc = function: TJSONArray of object;TSystemTaskProc = procedure(const ATaskName: string) of object;- The logger callback type is
log.types.TLogProc(from fpc-log).
Event observer types (cron.events)
TCronOnTaskStart = procedure(const APluginName, ATaskName: string) of object;TCronOnTaskComplete = procedure(const APluginName, ATaskName: string; ASuccess: Boolean; ADurationMs: Integer; const AError: string) of object;TCronOnTaskRegistered = procedure(const APluginName, ATaskName: string; AIntervalSeconds: Integer) of object;TCronOnPluginOrphaned = procedure(const APluginName: string) of object;TCronOnThreadStart = procedure of object;TCronOnThreadStop = procedure of object;
DB schema
Two tables, both auto-declared in Create:
system_scheduler
Holds task definitions. Columns: id (PK), task_name,
plugin_name, description, category, schedule_type
(interval | cron), interval_seconds, cron_expr,
enabled, last_run, next_run, last_result, last_error,
run_count, fail_count, user_modified, created_at.
UNIQUE on (plugin_name, task_name).
scheduler_log
Append-only audit log. Columns: id (PK), task_name,
plugin_name, started_at, finished_at, duration_ms,
result (running | success | error), error_message,
output. Indexed on started_at and (plugin_name,
task_name).
Both table names are kept verbatim from canonical Fastway so
existing fw_scheduler.pas databases can be reused as-is.
Behaviour notes
- All persisted timestamps are UTC. Cron expressions are
interpreted in local time (so
"0 3 * * *"means 3 AM in the server's TZ), matched, and the matching minute is converted to UTC for storage viaLocalTimeToUniversal. Consumers wanting UTC cron set the OS TZ to UTC. - Schedule miss on long tasks. If a 5-minute interval task takes 6 minutes, the next firing is 5 minutes after it returned, not after it started. Canonical behaviour, kept.
- Schema reconciliation runs in
Create. This meansCreatedoes I/O. Acceptable for a runner (you Create once at startup); document this in your consumer. - Two runners on one pool. The second
Create'sPool.DeclareTableis a no-op (idempotent). Both runners will read the samesystem_schedulerrows; they'll race to fire each task. Don't do that — use one runner per pool. SyncPluginTasksorphan cleanup. Tasks withcategory = 'plugin'anduser_modified = 0whoseplugin_nameis no longer present in the supplier's return value are deleted. This matches canonical Fastway's "unloaded plugin → drop its scheduled tasks" sweep.- Magic strings.
PluginName = 'system'routes to the consumer-registered system-task table. Plugin-task category default is'plugin'. Verbatim from canonical.
Known issues inherited verbatim
MatchesCronallocates the fiveTBitsfield-bit-arrays before itstry/finally. IfParseCronFieldraises on field 2..5, earlierTBitsinstances leak. Inherited from canonicalfw_scheduler.pas; preserved verbatim perfeedback_copy_dont_reinterpret.md. Real-world impact is negligible (cron expressions reachingMatchesCroncome from the DB and are validated upstream), but flagged for future cleanup.MatchesCroncallsDecodeDateFully(ATime, Mn, Hr, Dom, Dow)before the immediately-followingMonthOf/DayOf/HourOf/MinuteOf/DayOfTheWeekcalls overwrite all five outputs. TheDecodeDateFullyresult is therefore unused; canonical has it and so does this port. Pure cosmetic; no behaviour consequence.
Build and test
bash build.sh # compile every src/*.pas in dep order
bash run_tests.sh # build + run every tests/test_*.pas, compile every example
build.sh and run_tests.sh expect ~/Source Code/fpc-log/
and ~/Source Code/fpc-db/ to live alongside this repo
(matching fpc-emsi's vendoring pattern). Adjust -Fu paths in
the scripts if your layout differs.
fpc.cfg provides a multi-target FPC config — callers can build
consumers via fpc -Fucfg=fpc.cfg ... to inherit this lib's
search paths.
Versioning
Pin downstream consumers by tag (v0.1.0), not commit hash.
Constants in src/cron.version.pas:
CRON_VERSION_MAJOR = 0;
CRON_VERSION_MINOR = 1;
CRON_VERSION_PATCH = 0;
CRON_VERSION_STRING = '0.1.0';
Semver intent:
- major — breaks callers (API removal, signature change)
- minor — additive features
- patch — bug fixes, internal cleanups
License
Same as Fastway-Server: MIT (see LICENSE if/when added by the consumer). Until then, treat the source as Fastway-Server-licensed.