A feature's guide now sits under the menu it is used from, read from the templates rather than from where somebody once put it: Header-Bar, All-Boards, Pages, Board-Header (search and every Board View), Board-Sidebar (Board Settings, Filter, Members, Labels, Archive), Rules, Swimlanes, Lists, Cards, Member-Settings, Admin-Panel and Login-Page. Topics used from several menus (editor, history, theme, translations, email, troubleshooting) are in General. - Each area has a README mapping its menu entries to their guides and to the template or server module that implements them. Board-Header/README.md lists all 40 Board View entries with the template boardBody.jade renders; five views (Table, Calendar Mode, Time, Timeline, Map) have no guide of their own yet and say so. - Board-View.md listed 25 views while BOARD_VIEWS has 40; it now lists all of them with their English labels. - Pages keep their file names. Where a released changelog links an old path, a "# Moved" page stays there and points to the new one; test-menu.sh skips folders holding only those. - Undo was filed under Login; it is General/Undo now. - Every link in docs, code comments, scripts and tests follows the move; docsLinksResolve checks them all. Tests that read a doc follow it to its new place. boardViewSettings now fails when Board-View.md or Board-Header/README.md differs from BOARD_VIEWS, or names a template boardBody.jade does not render. Thanks to xet7 !
28 KiB
Design: SQLite corruption/bloat safety, automatic recovery, and Admin Panel → Problems → Recovery
This page uses the shared Table Page design. The layout, search, pagination, column spec and per-page data loading are defined there and are not repeated here.
Status: Implemented for bundled FerretDB SQLite launch paths · Owner: xet7 · Related (#6492):
releases/ferretdb/sqlite-recovery.mjs, models/lib/recoveryEventsJsonl.js,
models/recoveryEvents.js, server/recovery.js,
server/publications/recoveryReport.js, client/components/settings/adminProblems.*,
snap-src/bin/ferretdb-control, releases/ferretdb/*, and the FerretDB fork
(internal/backends/sqlite/metadata/pool/opendb.go,
internal/handler/handler.go).
The implementation target and safety invariants are defined first in Verified FerretDB SQLite recovery. That document is authoritative for snapshot format, checksums, disk-space gates, automatic source selection, migration fallback and low-load scheduling.
All work that may still be running when the process stops also follows the restart-safe background and external operations design. That contract defines persisted checkpoints and leases, idempotent replay, shutdown behavior, external-service timeouts, rate-limit-aware backoff and the Recovery evidence required when work is reclaimed after a restart.
When WeKan stores its data in FerretDB v1 (SQLite), the text data lives in
wekan.sqlite. Attachments and avatars live on the filesystem, not in the
database. This subsystem keeps that text data safe: it prevents the database from
bloating, detects corruption, keeps a ready-to-use backup, restores or re-migrates
when an operator requests it, and shows the remediation history in Admin Panel →
Problems → Recovery.
Email delivery queue
The email section groups pending and stopped notifications by recipient, with ten recipients per page. Search by a literal user ID and press Enter. It shows the queued and retrying/stopped counts, fixed failure categories, highest lifetime failure count, oldest queued time, next attempt and last operator change. Recipients are identified by username and user ID; mailbox fields and message contents are not returned. Refresh reads the current database state; this is not a live subscription.
- Pause delivery holds both existing and subsequently queued messages for that recipient. A paused recipient remains listed even with an empty queue.
- Resume delivery removes the hold and makes existing queued messages due now. Normal recipient permissions are rechecked before sending. Stopped messages remain stopped.
- Retry failed messages starts a new twelve-attempt cycle for stopped messages, preserving lifetime failure counts and any pause. Fix the reported cause first. Replaying the same control request cannot reset the budget again, even if the restarted cycle has already failed.
- Cancel queued messages asks for confirmation, removes rendered payloads pending or stopped up to the request's timestamp, and retains cancellation receipts. Messages created afterward are kept. Cancelling does not remove a pause.
Only a currently enabled instance administrator can read or change the queue. Controls acquire the same recipient reservation as the sender. If a delivery is already running, the operation reports busy; refresh and retry. A request ID makes a retry safe after an uncertain reply, and older interrupted commands cannot override a newer operator action. Shared delivery reservations cap queued notification workers at four across the deployment. Waiting for capacity does not spend an attempt or mark a message as failed. Reservation loss closes the sender's live SMTP connection, but cannot retract remote acceptance; delivery remains at least once.
Control records retain the latest actor/time/action and per-action counts; private request receipts retain completion or supersession. Queue payloads and control collections have no client publication or direct client writes. Detailed terminal receipt metadata is compacted after 30 days by default. Minimal replay identities remain indefinitely, so an old cancellation cannot affect later messages. Pending or failed work and recipient holds are retained. See receipt retention for configuration and durable operations for remaining activity-to-delivery integration work.
Sync run diagnostics
Recovery also reads the private Sync run reports, without copying them into recovery events or publishing the collection. The default filter shows unfinished outcomes; these may still be running or may have been interrupted. Other filters show failures, completed runs, completed-with-warnings outcomes, review-only results or all outcomes. Nothing here resumes or rolls back an operation. Failed and unfinished runs may have applied some card changes.
Search by a literal board or list ID and press Enter. The shared table loads ten rows per page, sorted by start time and record ID. Expand Details for confirmed successful counts, normalized/source field paths and parser diagnostic counts. Values, raw errors, credentials and source URLs are absent. Reports older than 30 days are excluded. Refresh checks the latest status; this is not a live subscription. The existing recovery-event table retains its separate controls.
Only current instance administrators may read this cross-board view. The server checks that permission before and after reading. The list popup still requires full-list board write permission and the same list lifetime. Instance admins can inspect retained diagnostics for deleted lists here until retention expires. Replay checkpoints, write fencing and automatic restart recovery are separate unfinished work; a diagnostic report is not a recovery plan.
List Sync operations that cannot be replayed
Manual and scheduled Sync of a list write through a saved operation plan
(durable operations).
While a list has an unfinished operation, no new Sync of that list starts: the
wekan-list-sync-replay job resumes it first, every minute. A replay re-checks
what the plan was made for - the list's board, lifetime, settings revision and
source, the stored intent and trigger, and that the user who started it still
has write access to the whole list. When one of those went stale (the list was
removed, recreated or reconfigured, or the user lost access), the operation can
never be finished and the list's Sync stays blocked.
WeKan records such an operation once: it marks the operation and adds one
list-sync-operation-stuck Recovery event naming the list, the operation, the
reason and how many of its steps were applied. Later passes that fail the same
way add no event and no console line. A manual Sync of the blocked list reports
that an administrator can discard the earlier operation here.
The List Sync operations that cannot be replayed section lists every marked operation - board and list ID, when it was found, steps applied of the total, and the reason - checked again against the current list, board and user each time the page loads. Discard is enabled only when that check confirms the operation still cannot be replayed; one whose access was restored is shown as replayable and finishes by itself.
Discarding, after confirmation:
- writes nothing to cards: steps applied before the operation went stale keep their changes, the remaining saved steps are never written, and the next Sync compares the list with its source again;
- first stores one immutable decision (collection
listSyncOperationDiscards, keyed by the operation ID, with the administrator, reason and progress), then removes the operation's saved steps and its marker, reading each removal back; - runs under the list's Sync lease, the same one a replay and a new run hold, so it never interleaves with them on this or another server; a list that is syncing right now reports busy;
- is idempotent: a retry or a second administrator finds the decision and only finishes the removal, or reports it already discarded. A replay or new run that finds a decided but unfinished discard completes it instead of resuming;
- keeps the stored intent and any completion receipts as evidence, and adds one
list-sync-operation-discardedRecovery event with the administrator's address.
Only a currently enabled instance administrator can list or discard; the server checks before reading and again with the lease around every write. Operations still being prepared are not listed - they have written nothing and are discarded automatically.
Board imports that stopped before finishing
Every board import and board copy is recorded before its first write, with the
id its new board will have (details).
One that stops - its server restarted, its writer hung for longer than
WEKAN_IMPORT_RUN_STALE_MS, or it failed after creating its board - is
flagged once by the wekan-import-run-scan job and adds one
import-interrupted Recovery event.
The Board imports that stopped before finishing section lists each one with
its board, source, where it stopped, what the board holds now and its Scrum
checkpoint if any. An instance administrator either keeps the partial board
as it is (import-kept) or discards it (import-discarded): the board the
import created and stamped with its run id is removed as a permanent delete
removes one, and whatever else carries that board id is swept. A board the run
did not create is never touched, a discard that stopped halfway is finished by
discarding again, and a second discard changes nothing. An import cannot be
resumed, because its source file is not kept; discard it and import the file
again.
What each layer does
FerretDB (the database engine)
- Automatic corruption detection. Every time a database file is opened, FerretDB
runs SQLite's fast
PRAGMA quick_check; anything butokis logged prominently. Corruption cannot be repaired in place, so this only reports it — recovery is done by WeKan (restore/re-migrate). - Automatic bloat repair. On open, FerretDB
VACUUMs a file whose free pages dominate it (≥ ~1 MiB and ≥ ¼ free), rebuilding it compactly. Content-preserving. - Bounded OpLog.
local.oplog.rsis capped small (16 MiB) so the transientlocal.sqlitecannot bloat and drive CPU high. - Toggle:
FERRETDB_SQLITE_AUTO_REPAIR=false.
WeKan startup (the launch scripts, files at rest)
Before FerretDB opens the files, every launch path (snap ferretdb-control, the
bundled release start-wekan.sh, the Docker wekan-entrypoint.sh):
- Backs up the text data. Copies
wekan.sqlite*into abackup/subfolder of the same data dir, keeping the previous generation underbackup/prev. It only ever copies the live database — never moves or deletes it — and never copies attachments/avatars. Toggle:WEKAN_SQLITE_BACKUP=false. - Restores on request. If a restore is requested — the
WEKAN_FORCE_RESTOREenv or aRESTORE_REQUESTEDmarker file containingbackup,prevorremigrate— it copies the chosen known-good backup into the live database (dropping the stale WAL side-files so the copy is used cleanly) and records the action. The backup copies are never deleted; the mainwekan.sqliteis only ever overwritten, never removed.remigraterequests a re-migration of the text data from MongoDB (handled bymigration-control); attachments/avatars on the filesystem are untouched. - Resets the transient OpLog (
local.sqlite) so a bloated/corrupt OpLog cannot persist across a restart. Toggle:WEKAN_FERRETDB_RESET_OPLOG=false.
The recovery decision
The policy is the least-invasive recovery - latest good backup → previous backup →
re-migrate → (else) manual - and nothing destructive unless the database is known
corrupt. releases/ferretdb/sqlite-recovery.mjs carries it out, with FerretDB's
read-only check-sqlite command. (A separate model of the policy,
models/lib/recoveryPlan.js, was never run by anything and was removed on
2026-10-16; the script is what decides.) They verify the live file before FerretDB opens it,
then restore and re-check latest or previous compressed snapshots. Snap quarantines
unusable SQLite files and re-runs migration when retained MongoDB source files exist.
Failure coverage and recovery ownership
| Failure | Mitigation / recovery | Problems → Recovery |
|---|---|---|
| Transient OpLog is corrupt or bloated | Removed at startup and recreated automatically | Startup output; text-data events are separate |
| Text database is corrupt | Startup tries latest, previous, then retained MongoDB migration source | corruption-detected, then the automatic outcome |
| Requested backup is missing or mode is invalid | Live data is left in place and the request is retained | manual-required |
| Restore copy fails | No success is reported; marker and request remain for retry | restore-failed |
| Backup copy fails | Startup continues and the prior generation remains available | backup-failed |
| Recovery succeeds but database remains unreadable | Maintenance state remains instead of exposing a broken app | manual-required |
| Bundled WeKan process exits | Bundle supervisor loop starts it again | Process logs; no database remediation is claimed |
| Container or snap process exits | Docker/snap service manager owns restart policy | Service-manager logs; no false Recovery row |
| Background job process exits | An expired persisted lease is reclaimed and execution continues at its last verified unit | job-reclaimed-after-restart and subsequent outcome |
| External service throttles or times out | Persist nextAttemptAt, honor Retry-After, and retry with bounded exponential jitter |
Attempt, provider, checkpoint and next retry |
Verified snapshots are gzip-compressed below <sqlite-dir>/.recovery, carry SHA-256
and byte counts for compressed and uncompressed forms, and are published only after a
staged decompression verifies. Free space is checked without assuming compression.
Non-urgent creation waits for a low-load startup window; corrupt startup recovery is
urgent and does not leave the application serving known-corrupt text data.
What users see during a recovery
Recovery must never look like a broken site. Two layers cover the whole window, on every FerretDB v1 platform (snap, bundled release, Docker):
- In-app maintenance spinner (all platforms). When a recovery is in progress the
server publishes a public status document — everyone, including logged-out users on
the sign-in page, sees it — that drives a full-screen overlay with a spinner in both
the app and sign-in layouts. The launch scripts write a
RECOVERY_IN_PROGRESSmarker when they restore/re-migrate; the server keeps the spinner up until it has health-probed the database (a real read), then clears the marker and hides the spinner — or, if it still cannot read, keeps the spinner and records that manual recovery is required. Admins can also toggle it for a server-initiated re-migration. Because the server owns clearing the marker, the spinner behaves identically on all platforms. - Static bridge page (before the app is up). For the brief window while a
just-restored FerretDB comes back up and before Meteor can serve the client, the
launch scripts serve a tiny standalone "recovering your data" page (HTTP 503) on the
web port so users never hit a bare connection error: the snap reuses
wekan-maintenance-page.mjs(with a recovery wording), and the release/Docker paths serve the portablereleases/ferretdb/recovery-bridge.mjs. The bridge is time-bounded (WEKAN_RECOVERY_BRIDGE_SECONDS, default 20s) and is only a visual bridge — it can never block WeKan from starting, and it hands straight over to the in-app spinner above. It is skipped cleanly if its page file or the marker is absent.
Admin Panel → Problems → Recovery
The startup scripts append one JSON line per action to recovery-events.jsonl in the
data dir. On startup the WeKan server imports the new lines into the recoveryEvents
collection (server/recovery.js), and the Recovery report
(server/publications/recoveryReport.js, admin-only) lists them newest-first with the
same search + pagination as the other admin reports. Admins can also record a manual
event with the recordRecoveryEvent method.
Event types include snapshot-created, snapshot-deferred, snapshot-failed,
backup-created, backup-failed, corruption-detected,
restore-backup, restore-prev, restore-failed, remigrate, bloat-repaired,
integrity-ok, manual-required, each with a severity (info / warning / error).
Recovery also keeps the audit trail for irreversible board deletion. Changing
Admin Panel → Problems → Delete records permanent-delete-setting-changed with
the Global Admin username, user ID and whether the setting was enabled or
disabled. Every successfully purged archived board records
board-permanently-deleted with that actor, the board ID and its title. No-op,
unauthorized and failed operations are not logged as successful actions.
Attempted setting changes and board purges are always recorded with a Boolean
done, the user ID and username when known, and the proxy-aware IPv4 or IPv6
address resolved through HTTP_FORWARDED_COUNT. Board attempts also keep
bounded boardIds and boardTitles arrays; an ID that does not resolve uses an
unknown-title marker rather than disappearing from the audit.
The Recovery table begins with Done. true renders a green check, false a
red warning triangle, and a successful operation that physically deleted data
adds a yellow trashcan. A batch that partly completes therefore shows yellow
successful rows for the boards already removed and a red failed-attempt row for
the whole requested batch.
The filter dropdown above the table selects All, Done, Failed or
Deleted events. Filtering is applied by the server before counting and
pagination, and combines with the search term. Older events written before the
done field existed count as Done because those event types represented completed
recovery actions.
Below the existing database-recovery description, the pane has a second paragraph explaining that Recovery also records permanent-delete setting changes and every successful, failed or unauthorized purge attempt, together with its Done status, actor, trusted address and attempted board IDs and titles.
Admin Panel → Problems → Delete repeats that same paragraph immediately below its existing permanent-delete setting description. Both panes use one shared source sentence, so the explanation at the control and the explanation at its audit trail cannot drift apart.
Manual recovery
To force a restore on the next start, set WEKAN_FORCE_RESTORE=backup (or prev, or
remigrate) in the environment, or create a RESTORE_REQUESTED file containing that
word in the SQLite data dir, then restart WeKan. The action is recorded in the
Recovery report.
Tests
tests/sqliteVerifiedRecovery.test.cjs— the recovery script itself: a healthy database is left alone, a corrupt one is restored from the latest verified snapshot, then the previous one, and only then is manual recovery reported.tests/recoveryEventsJsonl.test.cjs— the JSONL parser (skips junk, normalizes severity, bounds line size; never throws).tests/recoveryReportQuery.test.cjs— the report search and outcome selectors (including combined filters, legacy Done rows and escaped regex metacharacters).tests/recoveryReportWiring.test.cjs— the Recovery report is wired and the publication/count/method are admin-gated.tests/permanentDeleteRecoveryAudit.test.cjs— permanent-delete setting changes and board-purge attempts record status, actor, address and affected boards; it also pins the Done/deletion icons and proves failed operations cannot produce success records.tests/ferretdbTextDataBackup.test.cjs— the backup/restore scripts (critical negatives: never delete the live text data or a backup copy, never copy attachments/avatars, never report a failed copy as success, and retain failed restore requests for automatic retry on restart).- FerretDB:
opendb_test.go(corruption check + bloatVACUUM) andmsg_replset_test.go(OpLog cap).
Pending activity notifications
The pending activity table lists ten summaries per page, with literal searches across intent, activity, board and card IDs. It does not return stored activity content, recipients, email addresses or rendered messages. Missing or changed activities, inconsistent plan metadata and active delivery reservations are shown separately. Completed intents disappear from this table.
An enabled administrator can select Retry now for a pending delivery or an activity waiting for its first recipient plan. The request uses the same renewable reservation and receipt identities as automatic recovery. It rechecks administrator access during delivery, together with current recipient access and notification preferences. A successful retry confirms local tray/email queue writes; SMTP delivery is tracked separately in the email queue.
The table's status is a snapshot of recovery metadata. “Pending delivery” does not certify the complete stored payload or current recipient permissions; retry validates those before each effect and reports a safe failure message. Missing or changed activities are never recreated. An administrator can cancel remaining delivery, including orphaned work, as described below. Pending and cancelled payloads are compacted into permanent receipts as described below.
Pause and resume activity notifications
server/lib/activityNotificationControl.js provides the storage primitive for
activity pause/resume controls. Production delivery now reads the private
activityNotificationControls collection before preparation and local effects.
Both immediate ordinary delivery and manual/background recovery honor holds;
automatic scanning skips held work and continues to later pending intents.
Malformed control state stops delivery. An enabled administrator can select
Pause delivery or Resume delivery in the pending activity table. The
method uses the same delivery reservation and checks current administrator
access before and during the operation. A busy reservation requires retrying
later. Paused rows retain their underlying diagnostic status and disable
Retry now until resumed.
A request carries the intent ID, desired pause state, administrator ID, stable request ID and the revision displayed to the administrator. A conditional write advances that revision once. The last identical request can be retried; an older revision conflicts rather than undoing a later decision. Lost write responses are reconciled by reading the exact persisted row. Missing, completed or malformed recovery state cannot authorize a new hold change. A control row must never be deleted or expired, including after resume: its revision prevents delayed old requests from becoming valid again. There is one row per controlled intent, rather than one row per button click.
A hold applies to future local notification effects only. Messages already in the SMTP outbox require the separate email queue controls. Control rows are not published and member/admin DDP writes are denied. If another operator changes the revision, the UI refreshes and asks you to review the current state; it never silently retries against the newer revision. A failed network response can leave the operation applied: refresh to inspect the actual persisted state. Resume releases the hold for the next automatic scan; it does not guarantee successful delivery if the original activity or recipient access has changed.
The restart regression in activity-hold-restart.e2e.js has two explicit phases:
run with WEKAN_TEST_ACTIVITY_HOLD_RESTART=seed, restart the app against the same
test database with ACTIVITY_NOTIFICATION_RECOVERY_INTERVAL_MS=1000, then run
with WEKAN_TEST_ACTIVITY_HOLD_RESTART=verify. The seed phase retains a held
fixture deliberately; verify checks that automatic scanning has preserved it,
resumes through the UI and removes the fixture afterward.
Cancel remaining activity delivery
Cancel delivery asks for confirmation and permanently stops remaining local notification delivery for that activity. It uses the displayed revision and the same delivery reservation as pause/resume. Cancellation is terminal: neither a delayed old request nor a new Resume request can reopen it. An identical cancel request can be retried after a lost response. Already completed intents cannot be cancelled.
The cancelled row remains visible with its diagnostic metadata and disabled controls. Automatic recovery skips it. An orphan whose original activity is missing can be cancelled without recreating that activity. This is an explicit operator decision to abandon remaining delivery, not proof of delivery.
Cancellation does not recall existing tray notifications or email already in the SMTP outbox; use the separate email queue controls for unsent queued mail. It cannot undo a local effect already authorized by a former worker. Permanent control and delivery receipts remain to prevent replay. Cancellation removes intent snapshots and rendered plans through guarded compaction without deleting cancellation or deduplication evidence. Failed cleanup retains its evidence for retry.
Cancellation payload cleanup
server/lib/activityNotificationCancellationRetention.js implements a two-stage
compactor used by production cancellation. It requires the same delivery
reservation and an unchanged valid terminal cancellation control. It validates the complete
pending intent and any stored recipient plan before replacing payloads.
First, it replaces the plan with a permanent cancellation receipt containing its unique ID, activity hash and checksum. If no plan exists, it inserts a receipt with a null checksum to prevent a delayed initial plan insertion. Only after confirming that receipt does it replace the intent snapshot with cancelled metadata: activity/board/card IDs, creation time, identity hash, original dispatch actor and writer ID. Neither unique row is deleted. The original activity is not required, so an orphan can be compacted too.
Exact conditional replacement and readback handle lost replies, interrupted cleanup and stale writers. A mismatch retains the affected evidence and fails instead of claiming successful removal. Already compacted receipts can be checked repeatedly. Pending work without a terminal cancellation is untouched.
The cancellation method attempts compaction after confirming the terminal
control. If cleanup fails or the process stops, the existing bounded activity
recovery scan retries cancelled pending intents under the delivery reservation.
The scan processes at most 100 IDs per pass and advances past failures. It uses
ACTIVITY_NOTIFICATION_RECOVERY_INTERVAL_MS; no separate cleanup timer is
needed. Once the intent is compacted, its terminal state leaves the pending
scan. The report searches both pending and compact cancelled metadata and
keeps cancelled rows visible without fetching their old activity or plan.
Capture and completion reject compact cancellation receipts instead of recreating a payload or declaring delivery successful. Missing or malformed cancellation evidence keeps report actions disabled. Corrupt/mismatched payloads are retained for investigation rather than erased. Ordinary pending or merely paused work is not expired or automatically cancelled.
Stored rule email attempts
The Rule email delivery table shows ten attempts per page. Search matches literal command, invocation or attempt IDs. Filter by all attempts, unconfirmed attempts or messages accepted by the mail server. Refresh keeps the current page; changing the search or status starts at the first page.
Only enabled administrators can read the report. It contains identifiers, start/confirmation timestamps and delivery status, without message bodies or recipient addresses. Invalid attempt metadata is marked explicitly.
Unconfirmed can mean delivery is still running or was interrupted. It does not prove that the recipient received nothing. Accepted by mail server records transport acceptance, not inbox delivery. This table provides no retry, cancel or acknowledgement control; operator resolution remains unfinished.