Archive writer lifecycle: evict writers on webhook deletion and sweep idle archives #89
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Tracking issue for two non-blocking findings carried through the PR #84 (issue #43) review rounds, so they stop being untracked.
Problems
archiveWriterregistry (writersmap ininternal/delivery) is never evicted when a webhook is deleted: the writer (and any open archive DB handle within its debounce window) lingers for the process lifetime.Definition of done
archiveWriterfrom the registry (and the reaper/deletion path is covered by a test).Implementation requirements
Baseline:
main@4f5ecb1(#84 merged asee7c626, so the archiving target is in place).Current state, confirmed by reading
databaseTarget.writers map[string]*archiveWriterguarded bydatabaseTarget.mu(internal/delivery/target_database.go:22-27), populated lazily bywriterForand never deleted from.archiveWriter(internal/delivery/target_database_archive.go:156) holdsmu,db,lastReopen,debounce, and already has aclose()method that nils the handle. Pruning happens only insideopen(), which is only reached fromreopen(), which is only reached fromwrite().Handlers.deleteWebhookResources(internal/handlers/source_management.go:492), which soft-deletes entrypoints/targets/webhook and hard-deletes the event DB viaWebhookDBManager.DeleteDB. Nothing in that path knows the delivery engine exists.WebhookDBManager.DeleteDBremovesevents-{id}.dbplus its-wal/-shmsiblings. It does not toucharchive-{id}.db.1. Evict the writer on webhook deletion
Add an explicit eviction entry point on the delivery side that closes the writer's handle under the writer's own mutex (so it cannot race an in-flight
write) and removes it from thewritersmap underdatabaseTarget.mu. Call it fromdeleteWebhookResources.Plumbing:
HandlersParamscurrently injectsNotifier delivery.Notifier. Do not widenNotifier— archiving lifecycle is not notification. Add a separate narrow interface (e.g.delivery.WebhookEvictorwith a singleEvictWebhook(webhookID string)method), provide it from the delivery fx module, and inject it intoHandlersParams. A one-method interface keeps the handler package from depending on the engine's internals and keeps it trivially fakeable in tests.Eviction must be idempotent and must not error when no writer exists for that webhook (the common case — a webhook with no database target never creates one).
Also consider
HandleTargetDelete: deleting the lastdatabasetarget for a webhook leaves a live writer behind. Either evict there too or state in the PR body why you did not (a subsequent webhook deletion will collect it, and a re-added target would just recreate the writer). Make it a deliberate choice, not an omission.2. Do NOT delete the archive file
Eviction closes the handle and drops the map entry. It must not delete
archive-{webhookID}.db. The archive is explicitly long-term storage that an operator may move away for offline retention; destroying it as a side effect of deleting a webhook would be surprising and unrecoverable. Document this in the README next to the existing database-target documentation: deleting a webhook releases the archive but leaves the file on disk for the operator to handle.@sneak — this is the one judgement call in this issue and it is easy to reverse. I chose "keep the data" because silent, unrecoverable data destruction is never the right default. Say the word if you would rather webhook deletion remove the archive file too.
3. Sweep idle archives
Follow the
RetentionReaperpattern ininternal/database/retention.goexactly: an fx-managed component withOnStart/OnStophooks, a cancellable context, async.WaitGroup, and a ticker loop that exits cleanly on cancellation.Reuse the existing
Config.RetentionSweepInterval. Do not add a new environment variable. Both are retention sweeps, the semantics match, and — importantly — PR #92 (#80) is concurrently rewriting every helper ininternal/config. Adding a config key here would create a pointless merge conflict. If you believe a separate interval is genuinely required, say so in the PR body rather than adding one unilaterally.Sweep behaviour:
databasetarget whose config carries a positive expiry. Skip missing/empty/neverexpiry entirely — the issue requires no behaviour change for those.open()usesmode=rwc, so a naive reuse of it will conjure emptyarchive-*.dbfiles for every webhook that has a database target but has never received an event. Check for file existence first, or open read-write-without-create. Call out in the PR body which approach you took, and add a test that a webhook with a database target and no archive file has no file created by a sweep.archiveWriterso itsmuorders the sweep against concurrentwritecalls. Do not leave a handle open after an idle sweep — an idle archive should end the sweep closed, so the operator's move-the-file-away workflow keeps working.prunealready treats errors as non-fatal.4. Tests
neveris untouched by a sweep.OnStop(no goroutine leak); the repo runs tests with-race, so concurrent write-plus-sweep coverage is worth having.5. Docs
TODO.mdupdated in the same commit as the code.Definition of done
Everything in the issue's own Definition of done, plus: no new config key, no archive file deleted, no archive file created by a sweep,
make checkgreen via the repo's own entrypoints only,.golangci.ymluntouched, single commit whose title ends with(closes #89), no attribution trailers.Implementation plan
Branch
issue-89-archive-lifecycleoffmain@4f5ecb1. Single commit ending in(closes #89).1. Eviction plumbing
internal/delivery/engine.go: new one-method interfaceNotifierstays untouched — archiving lifecycle is not notification.EnginegainsEvictWebhook, andinitTargetsretains the*databaseTargetin a field (same pattern as the existinghttpTargetfield) so the engine can reach the registry.internal/delivery/target_database.go:(*databaseTarget).evict(webhookID)removes the map entry underdatabaseTarget.mu, releases that lock, then closes the handle under the writer's ownmuso it cannot race an in-flightwrite. Idempotent: unknown webhook id is a no-op.archiveWritergains anevictedflag set undermuat eviction. Awritethat was already blocked onmuwhen eviction happened completes, but a subsequentwriteon the detached writer returns an error instead of reopening the file behind the registry's back (the handle would otherwise leak on an object nobody holds).cmd/webhooker/main.goprovides*delivery.Engineasdelivery.WebhookEvictor;HandlersParamsgainsEvictor delivery.WebhookEvictor.deleteWebhookResourcescallsh.evictor.EvictWebhook(webhook.ID)after the config-deletion transaction commits. No archive file is deleted.2.
HandleTargetDeleteI will evict there too, via the shared
deleteChildResourcehelper gaining an optional after-delete hook. The hook counts the webhook's remaining (non-soft-deleted)databasetargets and evicts only when the count reaches zero. That is correct for both child types without inspecting what was deleted: deleting a non-database target while a database target remains leaves the writer alone, and deleting a non-database target on a webhook that has no database target evicts nothing because no writer exists. Rationale goes in the PR body.3. Idle sweep
New
internal/delivery/archive_sweeper.go, modelled directly oninternal/database/retention.go: fx params struct,OnStart/OnStop, cancellable context,sync.WaitGroup, ticker loop. Interval is the existingConfig.RetentionSweepInterval— no new config key.Per tick: list non-deleted
databasetargets from the main DB;parseArchiveExpiryeach config; skip anything non-positive (sonever/empty/missing behave exactly as today); then, per webhook:archive-{id}.dband stat it first — a missing file is skipped before any writer or handle exists.archiveWriterfrom the registry and callw.sweepExpired(expiry), which takes the writer'smufor the whole operation, so the sweep is ordered against concurrentwritecalls.mode=rw(notrwc) so SQLite cannot create the file, prune, and close again. The archive ends the sweep closed, preserving the operator's move-the-file-away workflow.openis refactored into a mode-parameterised helper; the write path keepsmode=rwc.Per-webhook failures are logged and the loop continues, matching
prune's existing non-fatal treatment.main.goprovides and invokes the sweeper.4. Tests
HandleSourceDeletewith a recording fake evictor (deletion path, not a direct call).EvictWebhookcloses the handle and drops the map entry; eviction of an unknown webhook is a no-op.{"expiry":"1h"}and a row stamped older than the expiry loses that row to a sweep with no intervening write; a fresh row survives. Fails without the fix.{"expiry":"never"}archive is untouched.archive-*.db(nor-wal/-shm) created by a sweep.-race) and cleanOnStopwith no leaked goroutine.5. Docs
README archive section: idle sweep semantics, and that deleting a webhook releases the archive handle but deliberately leaves
archive-{id}.dbon disk for the operator.TODO.mdin the same commit.Verification:
make fmtthenscript/cibuild.clawbot referenced this issue2026-08-11 14:48:08 +02:00