Skip to content

volumemgr: persist deferred content-tree deletes - #6085

Merged
eriknordmark merged 1 commit into
lf-edge:masterfrom
eriknordmark:volumemgr-persist-deferred-content-delete
Jul 9, 2026
Merged

volumemgr: persist deferred content-tree deletes#6085
eriknordmark merged 1 commit into
lf-edge:masterfrom
eriknordmark:volumemgr-persist-deferred-content-delete

Conversation

@eriknordmark

@eriknordmark eriknordmark commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Motivation: a bug found while testing the kvm-to-k (EVE-kvm↔EVE-k) conversion — deferred content-tree deletes did not survive the conversion reboot. This fix makes timer.defer.content.delete more useful in general but required for the kvm-to-k tests which are being developed.

Description

One way to both test and use the WIP EVE-kvm to EVE-k conversion without having
to re-download app images/containers is to ensure that the content tree is not
deleted (by setting the defer timer), delete the app instance, upgrade/convert
to EVE-k, then re-deploy the app instance. That assumes that the defer timer
works correctly even when there are delays and device reboots. This PR makes it
reliable in those conditions.

A content-tree delete deferred by timer.defer.content.delete keeps the
content's CAS blobs so a quick re-deploy reuses them instead of deleting and
re-downloading. That deferral was tracked only in memory plus the
still-published ContentTreeStatus, both lost on reboot. So when a reboot falls
inside the defer window — notably the EVE-kvm→EVE-k boot-disk conversion, which
deletes the running app before repartitioning and reboots — the blobs were
re-adopted unreferenced and the boot-time GC could reap them before the app was
redeployed, forcing a full re-download of an image whose bytes were still present
on the preserved /persist.

This records each deferred delete as a Persistent DeferredContentDeleteStatus
(content ID, image reference, blob list, expiry). It becomes the single source of
truth: the in-memory deferredDelete slice and the practice of leaving the
ContentTreeStatus published only to hold a RefCount are removed. While a record
is unexpired the blob and image GC reapers skip the listed content, so the blobs
survive the reboot regardless of GC-vs-redeploy timing; the redeploy re-creates
the content tree and reuses them. At expiry the record drives the delete, leaving
any blob that a live content tree now references.

WIP / known follow-up: cancelDeferredDelete keys on content ID, but a
redeploy is assigned a new content ID, so the original record is not cancelled
and lingers until its expiry (harmless — at expiry the now-referenced blobs are
skipped). Keying cancellation on the image reference / blob set would drop the
record promptly on redeploy.

How to test and validate this PR

  • Unit tests for the protection/cancel logic
    (pkg/pillar/cmd/volumemgr/deferredcontentdelete_test.go).
  • Validated end-to-end under eden + swtpm across a kvm→k conversion: set
    timer.defer.content.delete, deploy an app, delete the app instance, convert
    to EVE-k (boot-disk repartition + reboot), then re-deploy the app. The
    DeferredContentDeleteStatus records survived the conversion reboot, the boot
    GC ran and the guards kept the blobs and image, and the app redeploy
    re-downloaded 0 bytes.

Changelog notes

No user-facing changes.

PR Backports

  • 16.0-stable: No.
  • 14.5-stable: No.
  • 13.4-stable: No.

Checklist

  • I've provided a proper description
  • I've added the proper documentation
  • I've tested my PR on amd64 device
  • I've tested my PR on arm64 device
  • I've written the test verification instructions
  • I've set the proper labels to this PR

And the last but not least:

  • I've checked the boxes above, or I've provided a good reason why I didn't
    check them.

@codecov

codecov Bot commented Jun 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 21.15385% with 82 lines in your changes missing coverage. Please review.
✅ Project coverage is 22.84%. Comparing base (c679067) to head (c87a490).
⚠️ Report is 14 commits behind head on master.

Files with missing lines Patch % Lines
pkg/pillar/cmd/volumemgr/handlecontent.go 37.93% 36 Missing ⚠️
pkg/pillar/types/deferredcontentdeletetypes.go 0.00% 25 Missing ⚠️
pkg/pillar/cmd/volumemgr/volumemgr.go 0.00% 14 Missing ⚠️
pkg/pillar/cmd/volumemgr/blob.go 0.00% 7 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #6085      +/-   ##
==========================================
+ Coverage   22.38%   22.84%   +0.46%     
==========================================
  Files         492      503      +11     
  Lines       92128    93954    +1826     
==========================================
+ Hits        20620    21463     +843     
- Misses      69834    70636     +802     
- Partials     1674     1855     +181     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@eriknordmark eriknordmark changed the title [WIP] volumemgr: persist deferred content-tree deletes volumemgr: persist deferred content-tree deletes Jun 27, 2026
@eriknordmark
eriknordmark marked this pull request as ready for review June 27, 2026 16:37
@eriknordmark
eriknordmark removed the request for review from OhmSpectator June 27, 2026 16:38
@rene

rene commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

@claude

@claude

claude Bot commented Jun 29, 2026

Copy link
Copy Markdown

Claude finished @rene's task in 5m 10s —— View job


Code Review

  • Gather PR diff and context
  • Review changed files
  • Post inline comments
  • Post summary

The approach is sound: replacing the in-memory deferredDelete slice with a Persistent pubsub publication makes the deferral window survive reboots (specifically the EVE-kvm→EVE-k conversion), and the two GC guards (blobDeferredProtected / imageDeferredProtected) correctly cover both the blob-unpublish and the CAS image-sweep paths. Three points worth addressing:

  1. Publish-failure fallthrough (handlecontent.go:340) — if pubDeferredDelete.Publish fails, execution continues into RemoveAllBlobsFromContentTreeStatus, which drops blob refs. Since no record was written, blobDeferredProtected returns false and blobs are immediately removed from CAS, silently defeating the deferral. Falling back to doDeleteContentTree on error would make the failure mode explicit and predictable.

  2. Linear scan in hot path (blob.go:486) — blobDeferredProtected is called once per blob inside RemoveRefFromBlobStatus and does a full O(records × blobs-per-record) scan each time. Not a concern for typical use, but noted for larger image footprints or long defer windows.

  3. Silent skip on missing BlobStatus (handlecontent.go:416) — when LookupBlobStatus returns nil at expiry the blob is skipped without a log entry; a log.Warnf would help diagnose unexpected CAS leftovers.

The acknowledged limitation (cancel keys on ContentID rather than image reference, so a redeploy with a new ContentID leaves the old record until expiry) is harmless and clearly documented.

status.Key(), len(rec.Blobs), expiry)
if err := ctx.pubDeferredDelete.Publish(rec.Key(), rec); err != nil {
log.Errorf("deleteContentTree: publish deferred delete %s: %s", rec.Key(), err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If Publish fails, execution falls through to RemoveAllBlobsFromContentTreeStatus + unpublishContentTreeStatus. At that point blobDeferredProtected returns false (no record was written), so blobs with RefCount=0 are immediately removed from CAS — silently defeating the deferral. Consider falling back to doDeleteContentTree on error:

if err := ctx.pubDeferredDelete.Publish(rec.Key(), rec); err != nil {
    log.Errorf("deleteContentTree: publish deferred delete %s: %s, falling back to immediate delete", rec.Key(), err)
    doDeleteContentTree(ctx, status)
    return
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Publish never fails, but code typically ignores the errors. We could alternatively do a log.Fatal here.

// instead of being deleted then re-downloaded. Persist the (possibly
// decremented) RefCount rather than dropping it, so a later within-boot
// expiry sees the real count.
if blobDeferredProtected(ctx, blob.Sha256) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blobDeferredProtected does a full O(records × blobs-per-record) scan on every call, and it is called once per blob inside RemoveRefFromBlobStatus. Deleting an app whose image has many layers therefore runs this scan once per layer. Fine for the typical case (a handful of deferred records with a handful of blobs each), but if the defer timer is long and many apps are cycling, the overhead compounds. A pre-built map[sha]bool computed once per checkDeferredDelete sweep (and stored on the context) would make all individual checks O(1).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed


for _, sha := range rec.Blobs {
blob := ctx.LookupBlobStatus(sha)
if blob == nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blob == nil after populateInitBlobStatus is unusual (it means the blob was already evicted from CAS or never recorded). When that happens the corresponding CAS blob is silently skipped — no RemoveBlob is attempted, and the blob lingers in CAS until the next ordinary GC sweep. That's acceptable, but worth a log.Warnf so it's visible in field logs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIXED

A content-tree delete deferred by timer.defer.content.delete kept the
content's CAS blobs only via in-memory state and the still-published
ContentTreeStatus, both lost on reboot. After the EVE-kvm->EVE-k boot-disk
conversion (which deletes the running app before repartitioning and reboots)
the blobs were re-adopted unreferenced and the boot-time GC could reap them
before the app was redeployed, forcing a full re-download of an image whose
bytes were still on the preserved /persist.

Record each deferred delete as a Persistent DeferredContentDeleteStatus
(content ID, image reference, blob list, expiry). It is the single source of
truth: the in-memory deferredDelete slice and the practice of leaving the
ContentTreeStatus published to hold a RefCount are gone. While a record is
unexpired the blob and image GC reapers skip the listed content, so the blobs
survive the conversion reboot regardless of GC-vs-redeploy timing; the redeploy
re-creates the content tree, cancels the record, and reuses the blobs. At
expiry the record drives the delete, leaving any blob a live content tree now
references.

Drain any pending global config before servicing deletes: when the controller
raises timer.defer.content.delete and deletes an app in the same update, the
nondeterministic select order could otherwise handle the delete while the defer
value was still 0, taking the immediate-delete branch and reaping the blobs the
operator meant to retain.

Signed-off-by: eriknordmark <erik@zededa.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@eriknordmark
eriknordmark force-pushed the volumemgr-persist-deferred-content-delete branch from eb27466 to c87a490 Compare July 9, 2026 05:22
@eriknordmark
eriknordmark merged commit 435003c into lf-edge:master Jul 9, 2026
37 of 40 checks passed
@eriknordmark
eriknordmark deleted the volumemgr-persist-deferred-content-delete branch July 10, 2026 22:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants