[Blob] __blobstorage__ grows without bound: GC cannot reclaim partially-referenced extents
Los mantenedores suelen responder en 1 día
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 5/5
- Tiempo estimado
- Más de una semana
- Aptitud para principiantes
- 32/100
- Tipo de issue
- Nueva funcionalidad
- Claridad
- Bastante claro
- Estado de actividad
- Activo
- Stack tecnológico
- node.js, sql, typescript
- Área
- backend, databases, performance
Línea de trabajo
Comienza con src/common/utils/constants.ts, BlobGCManager.markSweep() y BlobReferredExtentsAsyncIterator, y luego ejecuta el archivo adjunto azurite-extent-fragmentation-repro.js contra un directorio de datos persistente. Revisa el cambio propuesto de IBlobMetadataStore y el nuevo punto de entrada src/blob/gc/BlobExtentCompactor; se considera terminado cuando los chunks activos compactados siguen siendo idénticos byte a byte, los extents obsoletos pueden recuperarse y el comportamiento existente de GC sigue siendo seguro.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
Which service(blob, file, queue, table) does this issue concern?
Blob (the queue service shares FSExtentStore and is affected in the same way).
Which version of the Azurite was used?
3.37.0, and reproduced against main.
Where do you get Azurite? (npm, DockerHub, NuGet, Visual Studio Code Extension)
DockerHub in development; reproduced locally from a build of main (npm).
What's the Node.js version?
v22.22.2 (Linux x64).
What problem was encountered?
On a long-running Azurite container (~month, persistent volume, continuous
overwrite/delete churn), __blobstorage__ grew to ~52 GB while the live blob
data was only ~500 MB. The GC never reclaims it.
This is not GC lag and not a GC failure — the GC is behaving exactly as
designed. The space is structurally unreclaimable:
FSExtentStore.appendExtent()packs many blobs into shared extent files of
up toDEFAULT_MAX_EXTENT_SIZE(64 MB,src/common/utils/constants.ts).BlobGCManager.markSweep()is whole-file mark-and-sweep. It builds a set of
all extent IDs, removes every ID that is still referenced, and deletes what
remains. There is no chunk-level accounting, so an extent is deleted only
when every reference into it is gone.BlobReferredExtentsAsyncIteratoryields onlypersistency.id, discarding
offset/count, so per-extent live-byte usage is not computed anywhere.
Consequently a single surviving 1 KB blob pins its entire 64 MB extent
forever. Under sustained churn, long-lived blobs get scattered across many
extents that were mostly filled with data that has since been deleted, and disk
usage grows without bound. ~52 GB across ~830 extents at ~1% utilization each
is exactly what this predicts after a months.
The class comment on BlobGCManager already anticipates the missing piece:
In the future, GC manager can also help merging small extent mapped files
into one big file to improve the performance.
Measured on a build of main with the attached script — 10 rounds of "write
one 1 MB blob that is kept, then 56 MB of blobs written and deleted
immediately":
--- immediately after churn
extent files on disk : 9 (570.0 MB)
live referenced bytes : 10.0 MB
dead bytes pinned in referenced extents : 512.0 MB <-- never reclaimable
bytes in fully-unreferenced extents : 48.0 MB
--- after 22 minutes of GC running
extent files on disk : 9 (570.0 MB)
live referenced bytes : 10.0 MB
dead bytes pinned in referenced extents : 512.0 MB
bytes in fully-unreferenced extents : 48.0 MB
per-extent utilization:
28ff88b7 size=66.0 MB live=2.0 MB util=3.03%
545789d7 size=65.0 MB live=1.0 MB util=1.54%
247365e3 size=65.0 MB live=1.0 MB util=1.54%
86f669c4 size=65.0 MB live=1.0 MB util=1.54%
57dd5047 size=65.0 MB live=1.0 MB util=1.54%
839e2170 size=65.0 MB live=1.0 MB util=1.54%
39b06b69 size=65.0 MB live=1.0 MB util=1.54%
4c9020d5 size=66.0 MB live=2.0 MB util=3.03%
48932e93 size=48.0 MB live=0.0 MB util=0.00%
570 MB of disk holding 10 MB of live data. 22 minutes covers more than two full
mark-sweep cycles past the 10-minute DEFAULT_EXTENT_GC_PROTECT_TIME_IN_MS
window, and the GC deleted nothing — correctly, since the only fully
unreferenced extent (48932e93) is the active write extent that
FSExtentStore.isActiveExtent() deliberately skips.
The only workaround today is deleting __blobstorage__, which destroys the
live data along with the dead bytes.
Steps to reproduce the issue?
Attached: `
azurite-extent-fragmentation-repro.js
`. It drives the churn through
the public blob API and then classifies every byte in the extent store as live,
dead-but-pinned, or reclaimable by reading Azurite's own metadata.
npm install @azure/storage-blob
npx azurite-blob --location ./azurite-data --silent &
node azurite-extent-fragmentation-repro.js ./azurite-data
ROUNDS and WAIT_MINUTES are configurable; the default 22-minute wait is
chosen to exceed the GC interval plus the extent protect time.
Have you found a mitigation/solution?
Yes — extent compaction, built on top of the existing GC rather than replacing
it. I have a working prototype and would be glad to open a PR if the approach
looks acceptable.
The idea: identify extents whose live-byte ratio is below a threshold, copy
their live chunks out through the normal appendExtent() path, and rewrite the
blob metadata references to the new locations.
The key design property is that compaction never deletes anything. Once its
live chunks have been relocated, the emptied extent is simply unreferenced, and
the existing mark-and-sweep deletes it on a later pass — still behind the
protect window and the isActiveExtent() guard. That makes the concurrency
story tractable:
- in-flight reads against the old extent keep working, because the file is
still there; - chunks shared between blobs (snapshots and
startCopyFromURLboth copy
persistencyreferences) are safe — relocating only some references means
the extent just isn't reclaimed this round; - if a blob is deleted or overwritten mid-relocation, the store re-reads the
document, sees the chunks no longer match, and skips the update; the
freshly written copy becomes ordinary garbage that the GC reclaims.
Additionally, each relocated chunk would be read back from its new location and
compared against the source before its metadata reference is updated, so a
faulty relocation costs disk space rather than corrupting a blob.
Prototype result on the data directory above — 570 MB to 58 MB, with all blobs
verified byte-identical after a restart (the residual 48 MB is the old active
write extent, which normal GC removes once it is no longer active):
before: 570.0 MB on disk, 9 extents, 10.0 MB live
selected 8 extents below 25% utilization
moved 10.0 MB of live data
after: 58.0 MB on disk, 2 extents
verified 10 blobs, 0 mismatches
Sketch of the change: a new IBlobMetadataStore method to relocate chunk
references (implemented for Loki and SQL — SQL only needs the persistency and
committedBlocksInOrder columns, since it does not persist page ranges), a new
BlobExtentCompactor under src/blob/gc/, a call site in markSweep(), and an
opt-in CLI flag so existing behaviour is unchanged by default. Extents
referenced by uncommitted blocks would be excluded from candidacy rather than
partially relocated.
Questions before I invest in a PR:
- Is compaction in the GC the direction you would want, or would you prefer an
offline/maintenance-mode command that operates on a stopped data directory? - Should it be opt-in behind a flag, or on by default with a conservative
utilization threshold and a per-cycle byte budget? - Would you want the same treatment for the queue service in the same change,
or kept separate?
Two unrelated minor things noticed while investigating, happy to fold into a
separate PR:
LokiExtentMetadataStore.updateExtent()writeslastModifiedInMSon insert
butLastModifyInMSon update, so a freshly inserted extent does not match
the protect-time predicate inlistExtents()in the intended way.BlobGCManager.markSweep()logsallExtents.entries(a function reference)
where the count was intended.
- Lenguaje dominante
- TypeScript
- Estrellas
- 2.3k
- Forks
- 400
- Merge medio
- 2 d 17 h
- PR fusionados (30 d)
- 30
Preparar el entorno
- Incluye un Dockerfile o un archivo de Docker Compose
- Tiene una plantilla de pull request
- Sin guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de Azure/Azurite
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 82/100
Los mantenedores suelen responder en 1 día
-
Dificultad 1/5 Menos de una hora Aptitud para principiantes 75/100
Azure/Azurite#2872 · 1 reacción ·
Los mantenedores suelen responder en 1 día
-
Dificultad 4/5 3-5 días Aptitud para principiantes 48/100
Azure/Azurite#2865 · 8 reacciones ·
Los mantenedores suelen responder en 1 día
-
Get Blob with Range bytes=-1 returns 500Posiblemente ocupada @hippi345 la tomó hace 18 días. Abierto
Dificultad 3/5 Medio día Aptitud para principiantes 62/100
Los mantenedores suelen responder en 1 día
-
Acquiring a blob lease without a duration causes later requests to return 500Posiblemente ocupada @hippi345 la tomó hace 18 días. Abierto
Dificultad 3/5 1-2 días Aptitud para principiantes 65/100
Los mantenedores suelen responder en 1 día
Todos los issues de Azure/Azurite
Issues similares
-
[Bug] Pod detail shows CPU usage ten times too high, and throws on decimal memory suffixesPosiblemente ocupada @RaphaelFakhri la tomó hoy. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 84/100
Los mantenedores suelen responder en 11 días
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 84/100
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
remotion-dev/remotion#12957 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 66/100
tyrasd/overpass-turbo#871 ·
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 67/100
Los mantenedores suelen responder en 1 día