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:
Anso
2026-05-22 02:40:22 -04:00
committed by GitHub
parent a1caf6b0dd
commit cd1cde2fd4
2 changed files with 275 additions and 2 deletions
+71 -2
View File
@@ -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 };