v5→v7 migration only: carry Archive-tier chunk metadata in a sidecar¶
Context and Problem Statement¶
The v5→v7 migration must stamp every chunk blob with its v7 metadata — the arius_type sentinel (the commit point of ADR-0017) plus original_size / chunk_size / parent_chunk_hash — so that ChunkIndexService.RepairAsync can rebuild the index from the authoritative chunk blobs. This metadata is normally written with Set Blob Metadata. But a real v5 repository legitimately has chunks already in Azure's Archive access tier, and Azure rejects Set Blob Metadata on an archived blob with 409 BlobArchived. The migration's Stage 3 hits exactly this against ariusciuse/v5migrationtest.
Rehydrating every archived chunk just to attach metadata is slow and costly, and defeats the migration's "non-destructive, in-place, no re-upload" premise. Native v7 never has this problem: it writes this metadata while the blob is still Hot/Cool, before it is tiered.
The question for this ADR is where to carry the v7 metadata of an Archive-tier chunk so that repair can read it, without rehydrating the chunk and without burdening the native v7 write path.
Decision Drivers¶
- The metadata carrier must be writable while the chunk blob is in the Archive tier (so: not the chunk blob's own metadata).
- Native v7's free, in-place metadata path must stay unchanged — this is a migration-only problem.
- It must be reproducible in the test stack (Azurite), since real Archive-tier behaviour cannot be exercised there.
- No new recurring cost proportional to the whole repository.
- Repair stays the single source of truth: it must resolve metadata for every committed chunk, archived or not.
Considered Options¶
- Set metadata in place — the failing baseline (
409 BlobArchived). - Rehydrate → set metadata → re-archive each archived chunk.
- Blob index tags — "read metadata, else read tags".
- Switch all v7 metadata to tags (one path for every chunk).
- A zero-byte Cool-tier chunk metadata sidecar carrying the metadata, under a separate sibling prefix.
Decision Outcome¶
Chosen option: a zero-byte Cool-tier metadata sidecar under a separate prefix, because it is the only carrier that is archive-writable, ~free, fully Azurite-testable, and reuses a blob shape (the thin stub) the codebase already trusts — while leaving the native v7 write path untouched.
Stage 3 branches on the chunk's tier (MigrateV5.WriteMetadataAsync): a non-Archive chunk keeps the in-place SetMetadataAsync upsert; an Archive chunk gets a zero-byte sidecar at BlobPaths.V5LegacySideCarPath(hash) — prefix chunks-v5legacy-metadata/, content type ContentTypes.V5LegacyMetadataSideCar, Cool tier, overwrite: true. The chunk metadata lives entirely in the sidecar's own blob metadata; the body is empty.
ChunkIndexService.RepairAsync reads it back in GetRepairEntriesAsync: it lists BlobPaths.V5LegacySideCarPrefix once into a hash→metadata map, then for each chunk resolves the metadata as the chunk's own blob metadata if that carries arius_type, otherwise the sidecar. A chunk with neither is skipped with a warning. The tier is always taken from the live chunks/ listing, never from the sidecar — a sidecar is written once at migration time and cannot track later rehydration.
Two sub-decisions:
- Separate prefix, not colocated in
chunks/. A sidecar cannot bechunks/{hash}(the data blob owns that name), so colocating would force an artificial name (chunks/{hash}.d) and pollute a namespace that several paths iterate assuming "everything here is a chunk" — theContentHash.Parse+arius_typeswitch inGetRepairEntriesAsync, archived-chunk enumeration for tier-stats — each of which would need a new skip-filter, and anarius_type=largesidecar would look exactly like a real large chunk. A separate sibling prefix keepschunks/untouched and is unambiguous 1:1 on the identical hash. - Name is origin-based (
chunks-v5legacy-metadata/), not function-based (chunk-descriptors/). Branding the prefix as v5-migration scaffolding discourages native v7 from ever leaning on it; the trade-off is that "v5legacy" is baked into the durable layout.
This sidecar is not the "sidecar manifest per tar" that ADR-0017 rejected. That was a per-tar member manifest proposed to replace thin chunks (and rejected because it breaks uniform
HEAD chunks/<hash>lookup). This is a per-archived-chunk metadata blob that feeds repair only. Same word, different mechanism.
Confidence: high. The mechanism is small, implemented, and unit-tested against the in-memory blob fake; native v7 is provably unaffected (it never reaches the Archive branch).
Before — Stage 3 sets metadata in place for every chunk:
After — the carrier is chosen by tier:
tier == Archive ? Upload chunks-v5legacy-metadata/<hash> (0 bytes, Cool, chunk metadata in blob metadata)
: SetMetadata chunks/<hash> (merge metadata onto existing v5 metadata)
repair: metadata = own blob metadata (has arius_type) ?? sidecar[hash] // tier always from live chunks/ listing
Consequences and Tradeoffs¶
- Good, because the carrier is writable while the chunk is archived — no rehydration, no re-upload, migration stays in-place.
- Good, because it costs ~$0 recurring: zero-byte Cool blobs are billed for their actual (zero) bytes, unlike per-tag monthly charges.
- Good, because it is fully Azurite-testable (just blobs + metadata), which the tags option is not.
- Good, because it reuses the thin-stub shape (
ChunkStorageService.UploadThinAsync) rather than introducing a new blob abstraction. - Good, because native v7 is entirely unaffected — a freshly created v7 repo has zero sidecars, so repair pays only one empty prefix listing.
- Neutral, because repair does one extra
chunks-v5legacy-metadata/listing and a hash-join — the same shape as the existing thin→tar enrich pass, and repair is rare. - Bad, because it is +1 object per archived chunk, doubling the object count (not the storage) for the migrated archived set.
- Bad, because a sidecar can be orphaned if chunk pruning/GC is ever added — any future delete of a chunk must also delete its sidecar (flagged in
BlobConstants.cs). - Bad, because the prefix name bakes "v5legacy" into the durable layout, a misnomer if the mechanism is ever reused for a non-v5 purpose.
Confirmation¶
The read-back contract is confirmed by unit tests against the in-memory blob fake (real Archive-tier behaviour is not reproducible in Azurite):
ChunkIndexServiceRepairTests.RepairAsync_ArchivedLargeChunk_ReadsMetadataFromSidecar— an Archive large chunk with no own metadata is rebuilt from its sidecar, and the Archive tier is preserved from the live listing, not the sidecar.ChunkIndexServiceRepairTests.RepairAsync_ArchivedTarWithSidecar_ThinInheritsArchiveTierAndSize— a thin chunk inherits its archived parent tar's tier and size resolved via the tar's sidecar.
End-to-end, the migration of ariusciuse/v5migrationtest (which previously failed with 409 BlobArchived) completes and the rebuilt index restores byte-identically.
The decision holds while: Stage 3 writes the sidecar only for BlobTier.Archive chunks and keeps SetMetadataAsync for the rest; repair resolves "own metadata else sidecar" and reads tier exclusively from the live chunks/ listing; and the native v7 archive path continues to write metadata before tiering (so it never needs a sidecar).
Pros and Cons of the Options¶
Zero-byte Cool-tier metadata sidecar, separate prefix (chosen)¶
- Good, because it is archive-writable, ~free, and Azurite-testable.
- Good, because it reuses the thin-stub blob shape and adds no new storage abstraction.
- Good, because a separate prefix keeps
chunks/consumers untouched (no new skip-filters). - Bad, because +1 object per archived chunk and a potential future orphan if GC is added.
Set metadata in place¶
- Good, because it is the simplest path and has no extra objects.
- Bad, because Azure forbids
Set Blob Metadataon Archive-tier blobs (409 BlobArchived) — it cannot describe the very chunks the migration must describe.
Rehydrate → set metadata → re-archive¶
- Good, because the metadata lands on the chunk blob itself, no second object.
- Bad, because per-blob rehydration is slow (hours) and costly, and re-archiving resets early-deletion clocks — prohibitive for a bulk, non-destructive migration.
Blob index tags ("read metadata, else tags")¶
- Good, because Azure permits
Set Blob Tagson archived blobs, tags ride on the blob (no extra object), and are queryable viaFindBlobsByTags. - Bad, because tags carry a small but perpetual per-tag monthly charge (~$3–30/mo per 1–10M chunks) to solve a one-time migration problem.
- Bad, because tags are weakly supported in Azurite (archive-tier + tags-in-listing), so the path could not be tested deterministically.
- Bad, because it needs a new blob abstraction (
SetTagsAsync, a tags trait on listing /BlobListItem.Tags).
Switch all v7 metadata to tags¶
- Good, because it would be a single code path (no tier branch) for every chunk.
- Bad, because native v7 never needs tags (it writes metadata before tiering), so this pays the perpetual per-tag cost on every chunk in every repository to solve a migration-only problem.
More Information¶
- Write side (tier branch):
MigrateV5.WriteMetadataAsync. - Read side (resolve own-metadata-else-sidecar):
ChunkIndexService.GetRepairEntriesAsync, described in repair-chunk-index. - Constants and the GC note:
BlobPaths.V5LegacySideCarPath,ContentTypes.V5LegacyMetadataSideCar. - The commit-sentinel model the metadata participates in: ADR-0017.