276 lines
11 KiB
Markdown
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.
|