Motivation
A knowledge base accretes cruft: soft-deleted rows past retention, stale embedding-cache entries, orphan files on disk, archived document versions, failed jobs. Left alone, storage grows without bound and the graph drifts from the markdown. AskMyDocs ships a config-driven scheduler that runs the retention sweeps, rebuilds the canonical graph, and computes daily insights — each slot independently toggleable and re-timeable without touching code.Design: one cron entry, many config-gated slots
You register one system cron line. Everything else is data:bootstrap/app.php ->withSchedule() delegates to
App\Scheduling\TierOneSchedulerRegistrar, which walks a fixed slot list and
reads each slot’s cron and enabled flag from
config('askmydocs.schedule.<slot>'). Every registration is hardened with
onOneServer() (one host fires it in a cluster) and withoutOverlapping() (a
long run never collides with the next tick).
Each slot reads two env vars: SCHEDULE_<SLOT>_ENABLED (default true) and
SCHEDULE_<SLOT>_CRON (a default cron string). Set the enabled flag to false
to disable a slot, or override the cron to re-time it — no deploy required.
The scheduled slots
Defaults fromconfig/askmydocs.php. Times are UTC unless your APP_TIMEZONE
says otherwise.
kb:prune-orphan-files is scheduled with --dry-run by default — it
reports orphans nightly without deleting. Run it manually without --dry-run
once you have reviewed the report. Two slots carry an extra upstream gate on
top of their SCHEDULE_* toggle: eval:nightly honours EVAL_NIGHTLY_ENABLED,
and ai-act:regulatory-poll is only registered at all when
AI_ACT_REGULATORY_FEED_ENABLED=true (a composite gate in bootstrap/app.php) —
when that env is false the slot never runs regardless of its SCHEDULE_* value.What the maintenance commands do
kb:prune-embedding-cache— evictembedding_cacherows older thanKB_EMBEDDING_CACHE_RETENTION_DAYS(LRU bylast_used_at). Returns early when--days=0. Not a full flush — see the dimension gotcha.kb:prune-deleted— hard-delete documents soft-deleted longer thanKB_SOFT_DELETE_RETENTION_DAYS, cascading chunks + graph + file on disk. Since v8.36 it also printsfiles_kept=Nper tenant when non-zero: rows whose source file was NOT removed — still referenced by another version, or (with conversion artifacts on) its storage key held by a writer, its cache store unable to lock, or the lock lapsed mid-section. The rows are gone either way and it is not an error;kb:prune-orphan-filestakes those bytes once nothing references them. A clean run prints no such line.kb:prune-archived-versions— drop old archived document versions beyond the per-family retention cap, each through the deleter’s row path (chunks, graph node, deprecation audit); since v8.36 each pruned version’s conversion artifact goes with it (and its OCR run, when no row whose recorded disk + prefix resolve to that run directory still names it), and the run sweeps stale.tmpfiles (older thanKB_CONVERSION_ARTIFACTS_TMP_MAX_AGEand no longer leased by a live writer —KB_CONVERSION_ARTIFACTS_TMP_LEASE; an aged temp still leased is reportedartifact_temps_in_flightand kept) and artifacts no row references on that disk; every removal re-checks the references under the artifact path’s lock (an identical ingest that recreated the path meanwhile keeps it:artifacts_kepton the per-tenant line,artifact_orphans_kepton the sweep line — the orphan sweep reports the kept candidates in--dry-runtoo, the per-tenant prune reports nothing in a dry run). Every refused removal is counted (*_failed) and makes the command exit non-zero.kb:prune-staging-batches— purge stale drag-and-drop upload staging batches and their staged files on thekb-stagingdisk once pastKB_STAGING_RETENTION_HOURS(default 24;--hours=Noverrides). Keeps the staging area from accumulating abandoned review sessions.kb:prune-orphan-files— remove markdown files on the KB disk with no matchingknowledge_documentsrow; each deletion re-checks the references first, so a row that took the path between the snapshot and the delete keeps its file (kept_meanwhile, printed when non-zero); while conversion artifacts are on the re-check and the delete also run under the storage key’s lock (the lock themarkdown_onlydrop and non-Markdown row commits hold — a key a writer holds right now is kept as in flight, a lock that lapsed is a reported failure).--dry-runlists the snapshot’s candidates previews the in-flight grace (a modification time is deterministic) but notkept_meanwhilefrom a held key: who holds a key at that instant is not something a preview can know. The candidate lists are bounded byKB_ORPHAN_SCAN_MAX_ITEMS(default 50 000; not switchable off — a non-positive value is the default, since an unbounded walk would OOM the worker on a large disk): a sweep that hits the cap reportsscan_truncated=1and exits non-zero rather than presenting a partial pass as a complete one, and the next run continues where it stopped. A source younger thanKB_ORPHAN_SOURCE_GRACE_SECONDS(default 3600 s,0disables) is kept whatever else is true: an ingest reads and converts its source before it takes the storage key’s lock, so in that window a perfectly ordinary-looking orphan may be the input of a conversion in progress. Since v8.36 it is also the reaper of the OCR run store: a{source}.ocr/tree whose source is gone from the disk and from every row of any tenant (trashed included) is dangling, and a run inside a live tree that no row names any more is stale — both are removed once older thanKB_OCR_PURGE_GRACE_SECONDS(a younger run is an in-flight reservation and is kept, reported asin_flight). The reference decision isDocumentDeleter’s gate, the same the hard delete andkb:prune-archived-versionsuse. The disk is walked once, lazily, and sources are judged in batches of 1000: a scan the disk refuses deletes nothing and exits non-zero, and an OCR tree whose source the disk refuses to probe is kept and counted (tree_probe_failed, exit non-zero).kb:rebuild-graph— rebuildkb_nodes+kb_edgesfrom canonical docs. No-op when no canonical docs exist. See canonical & promotion.kb:health-recompute/kb:stale-review-sweep— recompute KB health snapshots; flag documents past the staleness window for reviewer notification.chat-log:prune/notifications:prune/admin-audit:prune/admin-nonces:prune/widget:prune-sessions— retention sweeps for their respective tables.insights:compute— the daily AI-insights snapshot (one row per tenant).eval:nightly— the eval-harness regression run; alerts onmacro_f1drop.
--days=N (override the retention window; 0 disables that
rotation), --tenant= (scope to one tenant), and --dry-run. See the command
usage in self-hosting.
Worked example: re-time and disable slots in production
Move the graph rebuild to 02:15, and turn off the weekly digest entirely:schedule:list prints the resolved schedule — use it to confirm overrides
landed before trusting the next tick.
Gotchas & operations
- Register the cron line once. Forgetting
* * * * * schedule:runmeans nothing runs — there is no fallback timer. onOneServerneeds a shared cache/lock store. In a multi-node cluster, pointCACHE_STOREat a shared backend (database/redis) or every node fires every slot.config:clearafter editingSCHEDULE_*. A cached config keeps the old cron.--dry-runfirst for destructive sweeps.kb:prune-orphan-filesships dry-run on the schedule; keep it that way until you have audited the report.
Self-hosting
Wire the cron + worker into your process manager.
Troubleshooting
Diagnose stalled queues, retention, and health.