Files

276 lines
11 KiB
Markdown

# 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-log](../fpc-log) — `log.types.TLogProc` for the optional
`ALogger` parameter on `Create`.
- [fpc-db](../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](docs/DEVELOPER_GUIDE.md#bridging-to-fpc-events)
in the developer guide).
## Quick start
```pascal
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`](docs/API.md)
for the full callable reference, and
[`docs/DEVELOPER_GUIDE.md`](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
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.