Ken Johnson ca1243c396 v0.1.0: consolidate intermediate tags into single fresh release
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.
2026-05-06 11:32:25 -07:00
2026-05-05 18:14:46 -07:00
2026-05-05 18:14:46 -07:00
2026-05-05 18:14:46 -07:00

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_scheduler table and from an optional consumer-supplied callback (TGetExtraTasksFunc).
  • Each task fires either every IntervalSeconds or when its CronExpr (5-field cron) matches the current minute.
  • "System" tasks (PluginName = 'system') dispatch through a consumer-registered RegisterSystemTask(name, proc) table. Other tasks dispatch through the optional TRunTaskProc.
  • Typed observer callbacks (cron.events) — assignable properties (OnTaskStart, OnTaskComplete, OnTaskRegistered, OnPluginOrphaned, OnThreadStart, OnThreadStop). Same per-library typed-callback shape as fpc-binkp's bp.events and fpc-comet's cm.events.
  • Optional log.types.TLogProc callback for log output (the ecosystem-wide logger shape from fpc-log).
  • Schema reconciliation (system_scheduler + scheduler_log) runs in Create via fpc-db's Pool.DeclareTable.

Dependencies

  • fpc-loglog.types.TLogProc for the optional ALogger parameter on Create.
  • 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 via LocalTimeToUniversal. 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 means Create does I/O. Acceptable for a runner (you Create once at startup); document this in your consumer.
  • Two runners on one pool. The second Create's Pool.DeclareTable is a no-op (idempotent). Both runners will read the same system_scheduler rows; they'll race to fire each task. Don't do that — use one runner per pool.
  • SyncPluginTasks orphan cleanup. Tasks with category = 'plugin' and user_modified = 0 whose plugin_name is 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

  • MatchesCron allocates the five TBits field-bit-arrays before its try/finally. If ParseCronField raises on field 2..5, earlier TBits instances leak. Inherited from canonical fw_scheduler.pas; preserved verbatim per feedback_copy_dont_reinterpret.md. Real-world impact is negligible (cron expressions reaching MatchesCron come from the DB and are validated upstream), but flagged for future cleanup.
  • MatchesCron calls DecodeDateFully(ATime, Mn, Hr, Dom, Dow) before the immediately-following MonthOf / DayOf / HourOf / MinuteOf / DayOfTheWeek calls overwrite all five outputs. The DecodeDateFully result 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.

Description
Cron + interval task runner for Free Pascal — verbatim TFWScheduler port as TCron
Readme 126 KiB
Languages
Pascal 96.6%
Shell 3.4%