feat(transcode): write transcodes to the derived tier, with negative rows
Step 7 of docs/plan/derived-blobs.md, write path first — the plan is explicit that fixing it before the import means transcode_import only has to handle history, not a moving target. ImageTranscodeService now reads and writes storage.content_derived_blobs under kind='transcode', keyed by the BLAKE3 of the SOURCE content. The hash is threaded in from file_retrieval_service, which already holds it as dto.content_hash; hashing here would be a BLAKE3 over the whole file on every request. Callers without one (external mounts) keep the local cache untouched, which is what the service did before this tier existed. Negative verdicts become rows rather than zero-byte .skip files. A transcode that came out larger is deterministic in the content, so it is worth remembering; the row survives moka eviction, a restart, and the deletion of .transcoded/, none of which the marker does. Only that verdict is persisted — a timeout or a read error returns Err and is recorded nowhere, because a momentary failure written here would mark a perfectly transcodable image hopeless with nothing to retry it. Representation is a NULL blob_hash, per the plan: a sentinel hash would stop blob_hash naming a real Blob and every consumer would need to learn the exception. A CHECK keeps blob_hash and content_type NULL together — a type without bytes describes nothing, bytes without a type cannot be served. Two consumers had to be corrected for NULLs first, both of which would have broken on the first negative row ever written: * satellites_consistency reported them as derived_dangling_blob at data_loss severity. SQL comparison against NULL is NULL, so EXISTS was false and a row correctly pointing at nothing read as an artifact that had gone missing. * blob_reference_sources::list_referenced_blobs decodes blob_hash into String, so the first NULL would have failed the decode and taken the whole enumeration down. It would also have been wrong if it decoded — a negative row holds no reference, which is why the counting forms (WHERE blob_hash = <hash>) already exclude it for free. lookup_derived returns a three-way answer because Option collapses the two cases a caller deciding whether to spend a decode most needs apart: never attempted, versus attempted and known not worth it. DedupService is attached after construction via a OnceLock. DI builds the transcode service ~240 lines before DedupService exists, and the retrieval path that needs it is wired earlier still, so a constructor argument would mean reordering more than this is worth. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -451,6 +451,17 @@ impl AppServiceFactory {
|
||||
);
|
||||
dedup_service.initialize().await?;
|
||||
|
||||
// Hand the transcode service its derived tier.
|
||||
//
|
||||
// Deferred rather than injected at construction because that happens
|
||||
// ~240 lines above this, before `DedupService` exists, and the
|
||||
// retrieval path that needs the transcode service is wired earlier
|
||||
// still. Reordering DI to make the dependency a constructor argument
|
||||
// would move more than it is worth; the service treats a missing
|
||||
// handle as "local cache only", which is exactly its pre-derived-tier
|
||||
// behaviour.
|
||||
image_transcode_service.attach_dedup(dedup_service.clone());
|
||||
|
||||
// One-time background migration: re-chunk pre-CDC whole-file blobs
|
||||
// into chunk manifests so Range reads (and, with encryption, partial
|
||||
// decrypts) stop paying for the entire blob. No-op once converged.
|
||||
|
||||
Reference in New Issue
Block a user