Files
fpc-cron/docs/DEVELOPER_GUIDE.md

18 KiB
Raw Permalink Blame History

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

  1. What problem fpc-cron solves
  2. Dependencies and the fpc-* shape
  3. Quick start
  4. Lifecycle: Create → Start → Free
  5. Tasks: interval vs cron
  6. System tasks via RegisterSystemTask
  7. Plugin tasks via TGetExtraTasksFunc
  8. Typed observer callbacks (cron.events)
  9. Logger plumbing (log.types)
  10. Schema, table names, drop-in compatibility with Fastway
  11. The cron expression grammar
  12. Threading model
  13. Common pitfalls
  14. 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:

  1. Calls GetPluginTasks().
  2. For each entry, inserts a row into system_scheduler with category='plugin' if not already present.
  3. Deletes any category='plugin' rows whose plugin_name is no longer in the supplier's return value (orphan cleanup).
  4. 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 059
2 hour 023
3 day-of-month 131
4 month 112
5 day-of-week 06 (0=Sunday)

Each field accepts:

  • * — any value
  • */N — every Nth value starting at the field minimum
  • a-b — range (inclusive)
  • a-b/N — stepped range
  • a,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:

  1. Acquire FLock.
  2. Iterate FTasks. For each enabled task whose NextRun ≤ UTCNow, mark IsRunning := True, release the lock, call ExecuteTask(I), re-acquire the lock, continue.
  3. Release FLock.
  4. 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. Create returns a suspended thread. Tasks won't fire until you call .Start.

  • Pre-existing rows missing the schema. If you INSERT INTO system_scheduler before calling TCron.Create, the table doesn't exist yet. Either: (a) construct TCron first then RefreshTasks, or (b) call Pool.DeclareTable(BuildSystemSchedulerSpec(...)) manually before the insert (see EnsureSchedulerSchema in tests/test_runner.pas).

  • Heavy work in observer callbacks. OnTaskStart / OnTaskComplete run on the runner thread. Slow handlers delay subsequent dispatches.

  • TJSONArray ownership in the supplier. SyncPluginTasks frees the array. Don't return a reference you also store elsewhere; clone it.

  • Two TCron instances on one pool. The second Create's Pool.DeclareTable is a no-op (idempotent), but both runners will read the same system_scheduler rows 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.

  • MatchesCron known leaks. If a cron field past the first raises during parsing, earlier TBits instances 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.