Repository navigation
Description
Why this is separate from #33
#33 ("Implement periodic reconciliation scan") reconciles DB ↔ NAS originals — soft-delete rows whose file vanished, re-link moved files by hash. This issue is the other direction: proxies on local SSD ↔ DB. Different source of truth, different scan, same epic ("Data Health").
Problem
/data/proxies accumulates files that no row references, and nothing in the solution ever deletes a file. A repo-wide grep for File.Delete / Directory.Delete returns zero hits (the only .Delete( is a cookie). There is no code path that can remove a proxy file, ever.
Two independent sources of orphans:
1. Process death mid-ingestion. Since #188, an in-process failure is compensated by IngestionPipelineRunner (deletes registered proxies + .tmp siblings, removes the directory only if empty), and retries land in the same content-keyed directory. What compensation cannot reach is a hard kill / power loss / OOM: completed proxies with no ProxyFile row, and stray *.tmp files from ProxyStagingWriter. A clean shutdown also deliberately keeps proxies already completed in that run.
2. Cascade deletes. Deleting a UserStorageConfig cascades MediaAsset → ProxyFile rows. Every row disappears; every byte stays on disk permanently. No in-process fix touches this path.
A sweeper is the only thing that covers both.
What makes this tractable
The expected proxy layout is fully derivable without a join:
IProxyDirectoryStrategy/TwoLevelHexShardStrategy(src/Anichron.Worker/Ingestion/Proxy/ProxyShardStrategy.cs):{contentHash[..2]}/{storageConfigId:N}-{contentHash}— one directory per (storage config, content hash), not per asset id.- Expected filenames:
thumbnail.jpg,preview.jpg,blurhash.txt(images) andvideo_720p.mp4(video), from theFileNameof the generators inImageProxyGenerators.cs/VideoProxyGenerators.cs. ProxyFile.ProxyPathstores the path relative to the proxy root (default/data/proxies).- Any
*.tmp(ProxyStagingWriter.TemporaryFileSuffix) older than a grace period is reclaimable.
So a sweep can enumerate shard directories and diff them against the (StorageConfigId, ContentHash) pairs, including soft-deleted rows, plus the ProxyPath set.
MediaAsset rows can share one directory. Keying the diff on (config, hash) rather than on asset id handles that correctly; deleting a directory just because one of its rows is gone does not.
Trap to avoid
Both MediaAsset and ProxyFile carry soft-delete global query filters (AnichronDbContext). A naive EF query returns no proxy rows for soft-deleted assets, so a sweeper would classify their still-valid files as orphans and delete them — breaking the soft-delete/revive path that #33 depends on. The sweep must use IgnoreQueryFilters().
Open questions
- Dry-run / report-only mode first, before it is allowed to delete anything?
- Grace period before deleting a directory (guard against deleting a shard for an asset mid-ingestion by a concurrent consumer)?
- Does it run on the crawl timer, or its own slower schedule?
Related
- Soft-delete missing files after a crawl, and auto-restore them when they return #33 — the DB ↔ NAS direction, same epic
- fix(worker): Ingestion pipeline owns its failures — no orphaned proxy files #188 / feat(worker): Ingestion pipeline owns failure — no orphaned proxy files #175 — the in-process compensation half (done); this issue is the out-of-process remainder
- Code & architecture review — findings tracker + content_hash/multi-user analysis #159 — config-scoped partial unique index on
(StorageConfigId, ContentHash) - feat(worker): Per-item retry with exponential backoff and persistent failure tracking #132 — per-item retry and persistent failure tracking, the adjacent consumer-level concern
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
First caller
The admin endpoints under /api/v1/admin/proxy-sweeps (POST /, GET /{sweepId}/orphans, POST /{sweepId}/deletions) and the Worker's ProxySweep and ProxyDeletion handlers ship together in one PR. The Anichron.UI admin screen is a later caller.
Design
Approved note: #174 (comment). It is amended by the approval comment (#174 (comment)), and the decisions are in #174 (comment) and #174 (comment). The Open questions above are answered by those decisions.
Activity
refactor(worker): Express the staging-artifact name rule once
bitbiter-dev commentedon Oct 17, 2026
A third source: a losing attempt deletes the winner's file (left open by #263)
#263 stopped cleanup from deleting another attempt's files in most races, but one case is left. The fix needs a ref check, which belongs here.
What #263 fixed. Proxies live at content-derived paths, so two concurrent attempts on the same content (or two HEICs sharing one paired MOV) write to the same final path. #263 closed most of the overlap:
<proxy>.<attempt id>.tmp), and cleanup removes only its own;ProxyFiles (AssetId, ProxyType)lets only one row win;LostIngestionRace) releases its paired MOV's registrations before failing;Still open. An attempt can rename its proxy onto the shared path and then fail for some other reason, such as a DB timeout, a dropped connection, or an exception in a later step, while a concurrent attempt has already committed the row for that path.
IngestionPipelineRunnercompensation then deletes every proxy the loser registered, including the file the winner's committedProxyFilerow points to. The row stays and the file is gone, so the API serves a 404 for that proxy until something regenerates it.This was already true for image and video proxies before #263; the paired MOV's
WebVideois just one more instance. It is rare in practice, since it needs a real race plus an unrelated failure in the loser.Why it fits here. Compensation can't tell "my file" from "the same bytes the winner also wrote" without asking the DB. Two options:
ProxyFilerow references that path, usingIgnoreQueryFilters()(see the trap above), and skip it if so.ProxyFilerows whose file is missing, and either regenerate them or drop the row so the next crawl self-heals. (PairedMovProxyWriteralready self-heals a MOV that lacks its row, but not a row that lacks its file.)Note on
*.tmp: temporary files are now named<proxy>.<attempt id>.tmp. They still end inProxyStagingWriter.TemporaryFileSuffix, so the grace-period rule above still applies.bitbiter-dev commentedon Oct 18, 2026
Objection
Asked to: build an orphaned-proxy sweeper in the Worker that deletes files under
/data/proxieswhich no row references (and, per the comment above, also handle a losing attempt deleting the winner's file).Why it does not fit: this is a legacy issue (≤ #261) with no
## First calleror## Designpart. The trigger check indocs/agents/design-process.mdsays a design note is needed:IdempotencyCheckMiddlewarereturns without callingnexton a stored match (IdempotencyCheckMiddleware.cs:28). Only a paired MOV'sWebVideoself-heals./data/proxiesread-write (src/docker-compose.yml:104). Per-worker sweepers would race each other and race other workers' ingestion. A storage config removed byAdminStorageConfigService.DeleteAsynccascades its rows away and leaves no worker that owns its directories.BackgroundServicewith its own schedule and settings.blurhash.txtis no longer written (refactor(api): Narrow read-only storage interfaces instead of ReadOnlyFileSystem #251). Copies written before refactor(api): Narrow read-only storage interfaces instead of ReadOnlyFileSystem #251 are now dead files inside live directories, so a filename allow-list or a directory-level diff misses them.Options: write the design note first (posted below), or implement with the implementer's own answers to the open questions, which settles hard-to-reverse choices without review.
Recommendation: design note first. Moving to
needs-decision, because the note marks the deletion rule as hard to reverse and asks the maintainer three questions.bitbiter-dev commentedon Oct 18, 2026
Design note
Problem and constraints:
Files under
/data/proxiesthat noProxyFilesrow references are never reclaimed. They come from:*.<attempt>.tmpfiles;UserStorageConfig(orUser) deletion;blurhash.txtfiles written before refactor(api): Narrow read-only storage interfaces instead of ReadOnlyFileSystem #251.Every per-user worker mounts
/data/proxiesread-write (src/docker-compose.yml:104), so N workers share one tree.A deleted config's directories belong to no worker. Its id is still encoded in the directory name (
{hash[..2]}/{configId:N}-{hash}), but no row is left.Soft-deleted assets keep their proxies for Soft-delete missing files after a crawl, and auto-restore them when they return #33's revive path. The global query filters on
MediaAssetandProxyFilewould hide those rows, so every read here usesIgnoreQueryFilters().An in-flight attempt makes things appear before its row commits:
ProxyStagingWritercallsCreateDirectory;The sweeper must not delete any of these while that attempt can still commit.
Losing a valid proxy is not self-healing.
IdempotencyCheckMiddlewarenever callsnextfor a stored asset (IdempotencyCheckMiddleware.cs:28); only a paired MOV'sWebVideois regenerated.Options:
Each run, every worker tries
pg_try_advisory_lockon a dedicated id. The pattern isMigrateWithAdvisoryLockAsync, but non-blocking and with its own constant inPostgresConstants. The losers skip the run.The holder walks the proxy root and deletes every file that meets both conditions:
ProxyFiles.ProxyPathmatches its relative path (IgnoreQueryFilters());It then removes directories that are empty and also older than the grace period.
The rows are read per top-level shard (
ProxyPath LIKE 'ab/%'), so memory stays at about 1/256 of the table.Covers: crash debris,
.tmpfiles, cascade orphans and staleblurhash.txt, all with one rule. It needs no filename list and no parsing of directory names.Costs: one directory walk and one indexed read per shard, per run. Correctness depends on
ProxyPathbeing stored exactly as the relative path on disk. That already holds, sinceProxyStagingWriteris the only writer.IProxyDirectoryStrategy, so the path rule would no longer live in one place.FileNamelist.blurhash.txt).DELETE …/storage-configs/{id}deactivates the config instead of removing it. The Worker purges the config's directories, then deletes the rows.Recommendation: option 1.
ProxyPathreuses the rule the writer already applied, instead of re-deriving it.The "third source" (comment above: a losing attempt deletes the winner's file): out of scope here; it belongs in a separate issue.
IdempotencyCheckMiddleware.cs:28).First caller:
ProxySweepService, a WorkerBackgroundServiceshaped likeTokenCleanupService.PeriodicTimeronTimeProvider(so tests drive time), a scoped read ofProxyFilespaths per shard, andIFileSystemfor the walk.WorkerSettings:ProxySweepIntervalHoursandProxySweepGraceHours, plusProxySweepModeif the maintainer picks report-only first.ProxyPathstrings for one shard prefix withIgnoreQueryFilters(). No media type crosses into the sweeper.MockFileSystemand a substituted repository. The lock and theLIKEread are PostgreSQL-only, so they get// Stryker disable alland an ADR 0005 row, per CLAUDE.md.Hard to reverse: yes. Deleted proxies are not regenerated for stored assets, so a wrong orphan rule causes permanent 404s until the asset is re-ingested. No schema or API contract changes.
Decision needed:
ProxySweepMode=Report, which logs what would be deleted, and switch toDeletein a later release;CrawlIntervalHours.ProxySweepIntervalHours; (b) after each crawl.bitbiter-dev commentedon Oct 18, 2026
Decision
Maintainer, 2026-10-18, on the questions in the design note above:
Anichron.UIexists, an admin API endpoint until then. Nothing is ever deleted unattended.IgnoreQueryFilters()) and unchanged in size and mtime since the report. Otherwise it is skipped.These answers supersede the recommendation in the note above (a timer-driven sweep that deletes on its own). A revised design note follows. Because the decision makes the change span the API and the Worker and adds schema and an admin API contract, the revised note needs architect approval before
ready-for-agent, so the issue moves toneeds-design.Out of scope, as the first note says: a losing attempt deleting the winner's file needs a missing proxy regenerated, which is a separate issue.
bitbiter-dev commentedon Oct 18, 2026
Design note (revised after the decision)
Problem and constraints:
An admin requests a sweep, reviews the orphans it found, and deletes selected ones. The orphan rule is unchanged from the first note: files that no
ProxyFiles.ProxyPathreferences (IgnoreQueryFilters()) and that are older than 1 h.The API cannot touch proxy files:
:ro;api-reads-no-file-systemarchitecture rule forbids it, and ADR 0007 records why;IProxyStorehas no write method.So every file operation runs in the Worker, and the API only records requests and reads results.
Write ownership stays split as in CLAUDE.md: the API writes admin data, and the Worker is the only writer of media and of proxy files.
Several workers share
/data/proxies, so exactly one of them must run a given request.Admins get no media access. A report lists only files no row references, so it exposes paths (config id and content hash), never an asset.
Options:
ProxySweepRequests(API writes):Id,RequestedBy,RequestedAt.ProxySweeps(Worker writes):RequestId(PK/FK),Status(Running,Reported,Failed);StartedAt,CompletedAt;OrphanCount,OrphanBytes.ProxyOrphans(Worker writes):Id,SweepId,ProxyPath,SizeBytes,LastWriteTime.ProxyDeletionRequests(API writes):Id,OrphanId(unique),RequestedBy,RequestedAt.ProxyDeletions(Worker writes):RequestId,Outcome(Deleted,SkippedReferenced,SkippedChanged,Missing,Failed);ProcessedAt.ProxySweepsis inserted by the API and updated by the Worker.ProxyOrphanscarriesDeletionRequestedAt(set by the API) andDeletedAt/Outcome(set by the Worker).xmin, as onInvites.LISTEN/NOTIFYinstead of polling for pickup, on top of either of the above.Recommendation: option 1 with polling.
only-worker-writes-proxy-sweep-results).ProxySweepPollSeconds(default 30), which is fine for a manual action.How it runs:
Claim. A Worker claims an unclaimed request by inserting its
ProxySweeps/ProxyDeletionsrow. The primary key makes exactly one worker win, and the others hit the unique violation and skip. This is the same "loser skips on the unique index" pattern asLostIngestionRace, so no advisory lock is needed.Sweep.
ProxyPaths one top-level shard at a time (LIKE 'ab/%').ProxyOrphansrow, then setsReported.Deletion. Per request, the Worker re-checks that
ProxyPathis still unreferenced (IgnoreQueryFilters()) and that size and mtime equal the report:Deleted;Skipped…/Missingoutcome.A
.tmpfile is treated like any other orphan.Admin endpoints under
/api/v1/admin/proxy-sweeps, admin role only:POST /returns202with the request id;GET /{id}returns the status, the totals, and a paged list of orphans with each deletion's outcome;POST /{id}/deletionstakes{ "orphanIds": [...] }or{ "all": true }and returns202.allexists because a deleted storage config can orphan tens of thousands of files.Unknown ids return
404.First caller: the three admin endpoints, and the Worker's
ProxySweepServicethat serves them, ship together.ProxySweepServiceis aBackgroundServiceon aPeriodicTimerdriven byTimeProvider, shaped likeTokenCleanupService. Its pieces are tested withMockFileSystemand substituted repositories:// Stryker disable alland an ADR 0005 row, per CLAUDE.md:LIKEread.Hard to reverse:
Left out on purpose:
bitbiter-dev commentedon Oct 18, 2026
Superseded
The revised design note above (one-off request and result tables for the sweep) is superseded. API → Worker work requests become a general work queue, designed in #273, and #174 is its first caller. The decision of 2026-10-18 (report-only, manual scan, 1 h grace, re-check before delete) stands; only the transport changes. This issue's note gets rewritten on top of #273 once that design is settled.
bitbiter-dev commentedon Oct 18, 2026
The work queue is now specified in #276 (it replaces #273, now closed), and this issue is blocked by #276.
feat(worker): Update the row in place when a file's content changes a…
bitbiter-dev commentedon Oct 23, 2026
Note from the 2026-10-24 architecture review (#302):
StorageConfigSchedules<TPayload>(src/Anichron.Worker/Work/StorageConfigSchedules.cs) schedules every per-config kind atWorker:CrawlIntervalHours. That is right forCrawlandBurstDetection, which the #296 design note put on the crawl interval. A per-config kind added here would inherit it without saying so. When this issue adds one, give the schedule source its interval per registration (for example as a constructor argument) instead of reading the crawl setting.🤖 Generated with Claude Code
bitbiter-dev commentedon Oct 25, 2026
Constraint from the #303 design (#303 (comment), decision 5): proxy files live in a folder derived from the config and the content hash, so an active row and a soft-deleted row can reference the same proxy files. That happens when a path holder takes content a now soft-deleted row already had (
OverwrittenByMove, and #301's path-wins auto-restore). Deleting a soft-deleted row's files (purge, orphan sweep) must first check that no other row references the sameProxyPath.🤖 Generated with Claude Code
bitbiter-dev commentedon Oct 26, 2026
Design note (rewritten on the work queue, #276)
This replaces the revised note of 2026-10-18 (superseded). The maintainer's decision of 2026-10-18 stands:
The #276 spec adds one more decided point: deletion items are scoped to the config their directory belongs to.
Problem and constraints:
:ro, andapi-reads-no-file-system(ADR 0007) forbids file access from the API. So the API only enqueues and reads. Requests travel as work items (IWorkQueue), and the API never writes media or proxy files.StoragePath.ProxiesRootthat noProxyFiles.ProxyPathreferences, withIgnoreQueryFilters(), last written more than 1 h before the scan. Because the rule asks the database about each file and is not a filename allow-list, it also covers stray*.tmpfiles (ProxyStagingWriter.TemporaryFileSuffix) and theblurhash.txtfiles left by refactor(api): Narrow read-only storage interfaces instead of ReadOnlyFileSystem #251.{hash[..2]}/{configId:N}-{hash}(IProxyDirectoryStrategy), so every orphan's config can be read from its path. Orphans of a cascade-deleted config name a config that no longer exists. Since config ids are never reused, no crawl will ever write under that prefix again.ProxyFiles.ProxyPathhas no index today. Both the per-shard scan read and the re-check before each delete need one.Options:
Orphans in a table only the Worker writes, keyed to the sweep's work item.
ProxyOrphans:Id;SweepId, an FK toWorkItems.Idwith cascade, soWorkItemRetentionpurges reports after 30 days with no new setting;ProxyPath,StorageConfigId(parsed from the path, nullable, no FK, because the config may be gone or may go later),SizeBytes,LastWriteTime;DeletionOutcome(null until processed:Deleted,SkippedReferenced,SkippedChanged,Missing,Failed) andProcessedAt;(SweepId, ProxyPath).ProxyFiles.ProxyPath, withtext_pattern_opsso the shard prefixLIKE 'ab/%'uses it.IProxyOrphanReader) depends onProxyOrphan, the same shape aswork-items-change-only-through-queue-or-cancel.Orphans in the sweep item's
ResultJSON, deletion payloads carry paths.Resultis unbounded: a deleted config orphans every file it had, often tens of thousands;WorkItemEvents, every 2 s) carry thatResult, and paging happens in memory;Results, so "what happened to this orphan" becomes a join over JSON.Change the pattern: let a work item own child rows generically, for example
WorkItemEntries(WorkItemId, Key, Data jsonb)for any kind with a large report.Recommendation: option 1. It keeps one writer per table, gives paged reads with an index, and its retention comes for free from the work item FK. Decided by the maintainer on 2026-10-26, see below.
How it runs:
Kinds. Two new kinds,
ProxySweepandProxyDeletion, both admin-requested only. Neither has a schedule, so noIWorkScheduleSourceis registered and the crawl-interval note from Architecture review due (10 PRs since arch-review/2026-10-18) #302 does not apply.Sweep. The sweep is
ProxySweepPayload(): unscoped, with dedup keyproxy-sweep, so a double click coalesces.ProxyPaths withIgnoreQueryFilters()and theLIKE 'ab/%'prefix.ON CONFLICT DO NOTHINGon(SweepId, ProxyPath), so a stalled and re-run attempt converges.Resultis{ orphans, bytes, temporaryFiles, configsGone }.Deletion. The deletion is
ProxyDeletionPayload(Guid SweepId, Guid? StorageConfigId, IReadOnlyList<Guid>? OrphanIds). A nullOrphanIdsmeans every orphan of that sweep and scope.StorageConfigIdwhile that config exists.IgnoreQueryFilters()), elseSkippedReferenced.SkippedChanged.Missing.Deleted.Resultis the count per outcome.Admin endpoints, under
/api/v1/admin/proxy-sweeps, admin role only:POST /enqueues a sweep and returns202 { workItemId }. Status and progress come from the existing work-item endpoints and SSE.GET /{sweepId}/orphans?outcome=&storageConfigId=&page=returns a paged list of orphans with their outcomes. It returns404for an unknown id or one that is not a sweep, and409while the sweep has not succeeded.POST /{sweepId}/deletions, with{ "orphanIds": [...] }(at most 1000) or{ "all": true }:StorageConfigId, nulling configs that no longer exist, and enqueues one deletion item per group.202 { workItemIds }.400, and an unknown sweep404.First caller: the three endpoints and both handlers ship together, so every piece of the interface is exercised.
MockFileSystem, substituted repositories and aTimelineclock, each case with its own expected outcome:.tmpfile;ON CONFLICTinsert, the prefix read using the index (EXPLAIN, asFindByHashesOrPathsAsyncdoes), and the cascade on retention. Those members carry// Stryker disable alland ADR 0005 rows.Anichron.UIadmin screen is a later caller of the same endpoints.Left out on purpose:
Residual risk: a deletion re-checks and then unlinks, so there is a small window between the two. If a second Worker serves the same live config and its crawl renames a new proxy onto that exact path inside the window, that proxy is lost until regeneration exists. The supported deployments have one Worker per config (per-user workers, or one all-user worker), so this needs two Workers serving one config.
Hard to reverse:
ProxyOrphanstable and theProxyFiles.ProxyPathindex;/api/v1/admin/proxy-sweeps;WorkKindvalues. Their payloads are persisted, so renaming them would break queued items.Deletion stays manual and re-checked, so a wrong orphan rule produces a wrong report, not lost proxies.
Decision needed: where the report lives (options 1–3), and how orphans of a deleted config are scoped. Both were decided on 2026-10-26, see the next comment.
🤖 Generated with Claude Code
bitbiter-dev commentedon Oct 26, 2026
Decision
The maintainer decided these on 2026-10-26, on the design note above:
ProxyOrphanstable that only the Worker writes. Its FK to the sweep's work item cascades, soWorkItemRetentionpurges old reports.ProxyDeletionitem that any Worker may run. Orphans of a live config stay scoped to that config, as the spec: Work queue — durable API/Worker work items with scope routing, leases and a live admin view #276 spec says.The 2026-10-18 decision (report only, manual sweeps, 1 h grace period, a re-check before delete) is unchanged.
The note still needs architect approval from another session before
ready-for-agent, so the issue staysneeds-design.🤖 Generated with Claude Code
bitbiter-dev commentedon Oct 27, 2026
Design approved. The work-queue note (#174 (comment)) puts each kind of write with one owner. It keeps deletion manual and re-checked, so a wrong rule produces a wrong report and loses no files. Its first caller, the three endpoints with both handlers, uses every part of the interface.
Checked against master (c0eb001):
ProxyFiles.ProxyPathstill has no index (docs/schema.sql), so the note's index is needed./data/proxiesread-only and the Worker read-write (src/docker-compose.yml:64,:104).ProxyStagingWriteralways writes a fresh temporary file and moves it over the final file (File.Move(..., overwrite: true)). A proxy that is re-ingested therefore always gets a new mtime, so the 1 h grace period and the size/mtime re-check hold.ProxyOrphana navigation property toWorkItemis allowed:work-items-change-only-through-queue-or-cancelexempts theDomainnamespace andAnichronDbContext.ProxyPath, so neither issue affects it.Amendments (the implementer follows these where they differ from the note):
{hash[..2]}/). A deletion removes only the{configId:N}-{hash}folder, and only once it is empty. That folder belongs to a single config, so no other Worker writes into it. A shard folder is shared by every config. An unscoped deletion (of a config that no longer exists) can run while another Worker's crawl is creating a folder in the same shard. If the deletion removed the shard folder at that moment, the crawl would fail. There are at most 256 shard folders, so keeping them costs nothing.POST /{sweepId}/deletionsreturns409while the sweep has not succeeded, the same asGET /{sweepId}/orphans.WorkItems.StorageConfigIdrow cascades on config delete, so deleting a config removes its queued or running deletion item. Orphans it had not processed yet keep a null outcome. When the admin asks again, the API sees the config is gone and enqueues an unscoped item, which processes them. This is accepted; tests cover how the API scopes the second request.The issue's old Open questions section is answered by the decisions of 2026-10-18 and 2026-10-26.
🤖 Generated with Claude Code
feat(worker,api): Orphaned proxy files are swept on request and delet…