mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +00:00
fix(resources): subtract shared layers when accounting managed prune bytes (#1155)
* fix(resources): subtract shared layers when accounting managed prune bytes Both `pruneManagedOnly` and `estimateManagedReclaim` walked the Sencho-managed prunable image set and summed `img.Size` per image. That counts shared base layers once per image, so a 1 GB base layer shared across N managed images was reported as N GB freed — the same shape of inflation we just fixed for the system-scope banner. Introduce `getImageSharedSizeMap()` which reads `df.Images[].SharedSize` once and lets both code paths subtract `SharedSize` per image when totalling: `+= max(0, Size - shared)`. If `df` fails, the helper returns an empty map and the accounting degrades to the prior sum-of-Size behavior rather than failing the prune. Verified against a live daemon: `/api/system/prune/estimate` with `scope: managed, target: images` now returns the layer-aware number; the older per-image-Size sum was roughly 1.8× larger on the same set. * fix(resources): use df-delta for destructive managed prune; label estimate as lower bound The first attempt at this PR subtracted SharedSize per prunable image on both paths. That formula undercounts when prunable images share a layer exclusively with each other: Docker frees the layer once, but the per-image subtraction removes it from every referrer. The reported total is then strictly less than the truth. Split the two paths: - pruneManagedOnly (destructive) now snapshots `docker df` before and after the parallel removes and reports `max(0, before.LayersSize - after.LayersSize)`. That is the honest measurement of bytes freed. Concurrent pulls during the prune can grow the after value; the clamp treats that as 0 reclaimed for the affected delta rather than attributing the new bytes to us. - estimateManagedReclaim keeps the per-image Σ(Size - SharedSize) formula but the JSDoc now calls it a "conservative lower bound" and documents the under-report mechanism. There is no cheap way to exactly price an arbitrary prune subset without per-layer enumeration. Fallback chain when df fails on the destructive path: - before-snapshot succeeded, after failed → per-image lower bound from before-snapshot (safe; SharedSize was known at start). - before-snapshot failed → report 0 with a warn log (after-only would build a SharedSize map missing the just-pruned images, which would over-report by treating them as having no sharing). Replaces the prior `getImageSharedSizeMap()` helper with two pieces: `safeDfSnapshot()` (I/O) and a private static `mapSharedSizesFromDf()` (pure parse), reused by both code paths. New invariant test asserts `prune.reclaimedBytes >= estimate.reclaimableBytes` on the same inputs so future changes to either formula cannot flip the direction. Addresses Codex audit blocker on PR #1155.
This commit is contained in:
@@ -363,6 +363,42 @@ class DockerController {
|
||||
return { images, volumes, networks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `docker df` snapshot, or null if the call fails. The
|
||||
* destructive prune path uses this for a `LayersSize` before/after delta;
|
||||
* the estimate path uses it for a SharedSize lookup. Null on failure lets
|
||||
* each caller decide how to degrade rather than throwing mid-prune.
|
||||
*/
|
||||
private async safeDfSnapshot(): Promise<{
|
||||
LayersSize?: number;
|
||||
Images?: Array<{ Id?: string; SharedSize?: number }>;
|
||||
} | null> {
|
||||
try {
|
||||
return await this.docker.df();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts `Id -> SharedSize` from a df snapshot. Treats missing or
|
||||
* negative (Docker's "unknown" sentinel) SharedSize as 0 so the caller
|
||||
* counts the image's full Size as unique, an under-report rather than an
|
||||
* over-report.
|
||||
*/
|
||||
private static mapSharedSizesFromDf(
|
||||
df: { Images?: Array<{ Id?: string; SharedSize?: number }> } | null,
|
||||
): Map<string, number> {
|
||||
const m = new Map<string, number>();
|
||||
if (!df?.Images) return m;
|
||||
for (const img of df.Images) {
|
||||
if (!img?.Id) continue;
|
||||
const s = typeof img.SharedSize === 'number' && img.SharedSize >= 0 ? img.SharedSize : 0;
|
||||
m.set(img.Id, s);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
public async pruneManagedOnly(
|
||||
target: 'images' | 'volumes' | 'networks',
|
||||
knownStackNames: string[]
|
||||
@@ -422,14 +458,37 @@ class DockerController {
|
||||
&& !unmanagedImageIds.has(img.Id)
|
||||
&& !selfIdentity.isOwnImage(img.Id)
|
||||
);
|
||||
// df-before / df-after delta is the only honest measurement of bytes
|
||||
// actually freed. Per-image (Size - SharedSize) undercounts layers
|
||||
// shared exclusively between prunable images (Docker frees the layer
|
||||
// once, but the per-image formula subtracts it from every referrer).
|
||||
const beforeDf = await this.safeDfSnapshot();
|
||||
await Promise.all(prunable.map(async (img) => {
|
||||
try {
|
||||
await this.docker.getImage(img.Id).remove({ force: true });
|
||||
reclaimedBytes += img.Size ?? 0;
|
||||
} catch (e) {
|
||||
console.error(`[pruneManagedOnly] Failed to remove image ${img.Id}:`, e);
|
||||
}
|
||||
}));
|
||||
const afterDf = await this.safeDfSnapshot();
|
||||
if (typeof beforeDf?.LayersSize === 'number' && typeof afterDf?.LayersSize === 'number') {
|
||||
// Clamp to 0: a concurrent pull during the prune can grow on-disk
|
||||
// bytes, and attributing that growth to "reclaimed" would mislead.
|
||||
reclaimedBytes = Math.max(0, beforeDf.LayersSize - afterDf.LayersSize);
|
||||
} else if (beforeDf) {
|
||||
// After-snapshot failed but we have the before-snapshot, so fall
|
||||
// back to a per-image lower bound. (We deliberately do not fall back
|
||||
// on after-only: after the prune those image IDs are gone from the
|
||||
// daemon's view, so a SharedSize map built from afterDf would treat
|
||||
// every pruned image as having no sharing and over-report.)
|
||||
console.warn('[pruneManagedOnly] docker df after-snapshot unavailable; reporting per-image lower bound');
|
||||
const shared = DockerController.mapSharedSizesFromDf(beforeDf);
|
||||
for (const img of prunable) {
|
||||
reclaimedBytes += Math.max(0, (img.Size ?? 0) - (shared.get(img.Id) ?? 0));
|
||||
}
|
||||
} else {
|
||||
console.warn('[pruneManagedOnly] docker df unavailable on both ends; reporting 0 reclaimed');
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, reclaimedBytes };
|
||||
@@ -440,6 +499,13 @@ class DockerController {
|
||||
* the `/api/system/prune/estimate` route. Walks the same filter rules but
|
||||
* does not call `.remove()`. Kept structurally parallel to the destructive
|
||||
* method so the two stay in lockstep when the enumeration logic changes.
|
||||
*
|
||||
* For images, the returned figure is a **conservative lower bound** on
|
||||
* bytes that will actually be freed. The formula subtracts each prunable
|
||||
* image's `SharedSize`, which double-counts layers shared between two
|
||||
* prunable images (the layer would only be freed once on prune). The
|
||||
* exact freed bytes can only be measured by the df-delta that
|
||||
* `pruneManagedOnly` reports after the destructive action.
|
||||
*/
|
||||
public async estimateManagedReclaim(
|
||||
target: 'images' | 'volumes' | 'networks',
|
||||
@@ -479,7 +545,10 @@ class DockerController {
|
||||
&& !unmanagedImageIds.has(img.Id)
|
||||
&& !selfIdentity.isOwnImage(img.Id),
|
||||
);
|
||||
for (const img of prunable) reclaimableBytes += img.Size ?? 0;
|
||||
const sharedSizes = DockerController.mapSharedSizesFromDf(await this.safeDfSnapshot());
|
||||
for (const img of prunable) {
|
||||
reclaimableBytes += Math.max(0, (img.Size ?? 0) - (sharedSizes.get(img.Id) ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
return { reclaimableBytes };
|
||||
|
||||
Reference in New Issue
Block a user