Skip to content

Export rootfs in the background during pause()/snapshot() - #3320

Merged
bchalios merged 3 commits into
mainfrom
feat/deferred-rootfs-export
Jul 30, 2026
Merged

bchalios merged 3 commits into
mainfrom
feat/deferred-rootfs-export

Conversation

@bchalios

@bchalios bchalios commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

What

When we are snapshotting a sandbox, during pause()/snapshot() we create a snapshot of both memory and rootfs.
Exporting the rootfs boils down to reflinking the overlay to the local cache path and then (asynchronously) uploading the cache to GCS.

This exporting process happens synchronously. We have seen that, typically reflinking is very fast as it deals with metadata (rather than moving data around).

This PR makes the export asynchronous (optional, guarded by a feature flag). In the case of asynchronous export of the rootfs, we don't observe the reflink latency in the pause, but we might observe it during resume time if the resume happens while the asynchronous seal operation is still ongoing.

Why

We have seen that, whereas the reflink operation is typically fast, it can experience long tails when there's a lot of dirty data in the page cache that needs to be written to the backing storage.

For example, when we are taking a snapshot after writing 2GiB of data in the disk immediately after the write finished (E1) and 30 seconds after the write had completed and:

  • E1 — pause immediately after write: the ~2 GB NVMe flush burst happens inside the pause window (host Dirty ~2 GB → 0 during the pause). → pause = 6.8 s
  • E2 — 30 s settle before pause: the flush burst happens during the settle; Dirty is already 0 when the pause fires. → pause = 0.9 s
  • Same write, same node — only difference is whether the flush overlaps the pause.

This nicely scales with amount of time we wait after the write:

  • settle 0 s → ~4.2 s (6.8 s tail)
  • settle 5 s → 3.78 s
  • settle 10 s → 3.71 s
  • settle 20 s → 1.53 s
  • settle 30 s → ~0.7–0.9 s

@cla-bot cla-bot Bot added the cla-signed label Jul 21, 2026
@cursor

cursor Bot commented Jul 21, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches pause snapshot correctness, cache eviction, and object-storage upload ordering; mis-timed reads of a half-sealed rootfs or a published header before the body would corrupt resumes, though checkpoint explicitly keeps synchronous export and failures fail fast without retry.

Overview
Adds an off-by-default defer-rootfs-export path on pause (not checkpoint): the NBD provider ejects and stops the sandbox synchronously, builds the rootfs diff header from frozen cache metadata, and returns a promise-backed deferredDiff while a goroutine reflinks the diff. Pause latency drops when dirty page cache must flush during export; resume, async upload, and prefetch harvest block on CachePath until the seal finishes. Seal failure is permanent (ErrDeferredSealFailed, no upload retries); v3 upload gates header publish on the seal so storage cannot get a header without a body. DiffStore eviction skips or async-closes unsealed deferred entries to avoid stalling the eviction goroutine.

Reviewed by Cursor Bugbot for commit bec1b51. Bugbot is set up for automated code reviews on this repo. Configure here.

@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

❌ 8 Tests Failed:

Tests completed Failed Passed Skipped
3660 8 3652 9
View the top 3 failed test(s) by shortest run time
github.com/e2b-dev/infra/tests/integration/internal/tests/proxies::TestMaskRequestHostAPIParameter
Stack Traces | 5s run time
=== RUN   TestMaskRequestHostAPIParameter
=== PAUSE TestMaskRequestHostAPIParameter
=== CONT  TestMaskRequestHostAPIParameter
    mask_request_host_test.go:44: Command [python3] output: event:{start:{pid:1422}}
    mask_request_host_test.go:68: Command [cat] output: event:{start:{pid:1423}}
    mask_request_host_test.go:68: Command [cat] output: event:{data:{stderr:"cat: /tmp/nc_output.txt: No such file or directory\n"}}
    mask_request_host_test.go:68: Command [cat] output: event:{end:{exit_code:1 exited:true status:"exit status 1" error:"exit status 1"}}
    mask_request_host_test.go:69: 
        	Error Trace:	.../tests/proxies/mask_request_host_test.go:69
        	Error:      	Received unexpected error:
        	            	command cat in sandbox ijsqe9a5m0ahv4mku7t1v failed with exit code 1
        	Test:       	TestMaskRequestHostAPIParameter
--- FAIL: TestMaskRequestHostAPIParameter (5.00s)
github.com/e2b-dev/infra/tests/integration/internal/tests/api/templates::TestDeleteTemplateFromAnotherTeamAPIKey
Stack Traces | 156s run time
=== RUN   TestDeleteTemplateFromAnotherTeamAPIKey
=== PAUSE TestDeleteTemplateFromAnotherTeamAPIKey
=== CONT  TestDeleteTemplateFromAnotherTeamAPIKey
    build_template_test.go:159: test-to-delete-another-team-api-key: [info] Building template q0jzzwccbyazwsvtfozy/97f93d89-1219-4422-bc28-3c562a86be83
    build_template_test.go:159: test-to-delete-another-team-api-key: [info] [base] FROM ubuntu:22.04 [d98ee3a5214ceead727199118135d103899d85d263a170f022495c0908a5c45b]
    build_template_test.go:159: test-to-delete-another-team-api-key: [info] Base Docker image size: 30 MB
    build_template_test.go:159: test-to-delete-another-team-api-key: [info] Creating file system and pulling Docker image
    build_template_test.go:159: test-to-delete-another-team-api-key: [info] Uncompressing layer sha256:d6834b4a794c03efa2c998853e64969fa8851b11b2ade63292268872a37759d0 30 MB
    build_template_test.go:159: test-to-delete-another-team-api-key: [info] Uncompressing layer sha256:186009cdc9aa7e67740a3c8312ec6fae318355cd6cf09fbd4f29aee5a0923739 14 MB
    build_template_test.go:159: test-to-delete-another-team-api-key: [info] Uncompressing layer sha256:e216621d8a0a7a4486d6a228759b29bc378eff95e6ea9469854b8624b267dd13 137 B
    build_template_test.go:159: test-to-delete-another-team-api-key: [info] Layers extracted
    build_template_test.go:159: test-to-delete-another-team-api-key: [info] Root filesystem structure: bin, boot, dev, etc, home, lib, lib32, lib64, libx32, media, mnt, opt, proc, root, run, sbin, srv, sys, tmp, usr, var
    build_template_test.go:159: test-to-delete-another-team-api-key: [info] Provisioning sandbox template
    build_template_test.go:159: test-to-delete-another-team-api-key: [info] Provisioning was successful, cleaning up
    build_template_test.go:159: test-to-delete-another-team-api-key: [info] Sandbox template provisioned
    build_template_test.go:159: test-to-delete-another-team-api-key: [info] [base] DEFAULT USER user [8079b44084c5d10933c7dc61e96fc26dfd6273dce8bb078d38ef47f777c3fea6]
    build_template_test.go:159: test-to-delete-another-team-api-key: [info] [builder 1/1] RUN echo 'Hello, World!' [d005a1d7e6c7dae88e8c271f9c6c48e9e25929a572d0ab255e54f68989803f35]
    build_template_test.go:159: test-to-delete-another-team-api-key: [info] [builder 1/1] [stdout]: Hello, World!
    build_template_test.go:159: test-to-delete-another-team-api-key: [info] [finalize] Finalizing template build [02457e2c96b97ba9f22df8feccf73e006887705c902b5aef9cdf7f8b0f7c2ee0]
    build_template_test.go:159: test-to-delete-another-team-api-key: [error] Build failed: An internal error occurred. Please try again or contact support with the build ID.
    delete_template_test.go:51: Build failed: An internal error occurred. Please try again or contact support with the build ID.
--- FAIL: TestDeleteTemplateFromAnotherTeamAPIKey (155.55s)
github.com/e2b-dev/infra/tests/integration/internal/tests/api/templates::TestDeleteTemplate
Stack Traces | 157s run time
=== RUN   TestDeleteTemplate
=== PAUSE TestDeleteTemplate
=== CONT  TestDeleteTemplate
    build_template_test.go:159: test-to-delete: [info] Building template 4jkr5wrk3i9v3vyhdbt1/7c20eedc-c990-4c24-9c99-7a988ec6f613
    build_template_test.go:159: test-to-delete: [info] [base] FROM ubuntu:22.04 [d98ee3a5214ceead727199118135d103899d85d263a170f022495c0908a5c45b]
    build_template_test.go:159: test-to-delete: [info] Base Docker image size: 30 MB
    build_template_test.go:159: test-to-delete: [info] Creating file system and pulling Docker image
    build_template_test.go:159: test-to-delete: [info] Uncompressing layer sha256:d6834b4a794c03efa2c998853e64969fa8851b11b2ade63292268872a37759d0 30 MB
    build_template_test.go:159: test-to-delete: [info] Uncompressing layer sha256:186009cdc9aa7e67740a3c8312ec6fae318355cd6cf09fbd4f29aee5a0923739 14 MB
    build_template_test.go:159: test-to-delete: [info] Uncompressing layer sha256:e216621d8a0a7a4486d6a228759b29bc378eff95e6ea9469854b8624b267dd13 137 B
    build_template_test.go:159: test-to-delete: [info] Layers extracted
    build_template_test.go:159: test-to-delete: [info] Root filesystem structure: bin, boot, dev, etc, home, lib, lib32, lib64, libx32, media, mnt, opt, proc, root, run, sbin, srv, sys, tmp, usr, var
    build_template_test.go:159: test-to-delete: [info] Provisioning sandbox template
    build_template_test.go:159: test-to-delete: [info] Provisioning was successful, cleaning up
    build_template_test.go:159: test-to-delete: [info] Sandbox template provisioned
    build_template_test.go:159: test-to-delete: [info] [base] DEFAULT USER user [8079b44084c5d10933c7dc61e96fc26dfd6273dce8bb078d38ef47f777c3fea6]
    build_template_test.go:159: test-to-delete: [info] [builder 1/1] RUN echo 'Hello, World!' [d005a1d7e6c7dae88e8c271f9c6c48e9e25929a572d0ab255e54f68989803f35]
    build_template_test.go:159: test-to-delete: [info] [builder 1/1] [stdout]: Hello, World!
    build_template_test.go:159: test-to-delete: [info] [finalize] Finalizing template build [02457e2c96b97ba9f22df8feccf73e006887705c902b5aef9cdf7f8b0f7c2ee0]
    build_template_test.go:159: test-to-delete: [error] Build failed: An internal error occurred. Please try again or contact support with the build ID.
    delete_template_test.go:18: Build failed: An internal error occurred. Please try again or contact support with the build ID.
--- FAIL: TestDeleteTemplate (156.61s)
github.com/e2b-dev/infra/tests/integration/internal/tests/envd::TestCACertTrustedAfterFilesystemOnlyReboot
Stack Traces | 172s run time
=== RUN   TestCACertTrustedAfterFilesystemOnlyReboot
=== PAUSE TestCACertTrustedAfterFilesystemOnlyReboot
=== CONT  TestCACertTrustedAfterFilesystemOnlyReboot
    template.go:43: test-fs-only-ca-reboot: [info] Building template bps63fmwmftgiypokivf/135ba57c-4c95-4f4e-902a-3fe0ac774df3
Executing command findmnt in sandbox iutm3tvqn5flekvkdzrp0 (user: root)
    template.go:43: test-fs-only-ca-reboot: [info] [base] FROM ubuntu:22.04 [d98ee3a5214ceead727199118135d103899d85d263a170f022495c0908a5c45b]
    template.go:43: test-fs-only-ca-reboot: [info] Base Docker image size: 30 MB
    template.go:43: test-fs-only-ca-reboot: [info] Creating file system and pulling Docker image
    template.go:43: test-fs-only-ca-reboot: [info] Uncompressing layer sha256:d6834b4a794c03efa2c998853e64969fa8851b11b2ade63292268872a37759d0 30 MB
    template.go:43: test-fs-only-ca-reboot: [info] Uncompressing layer sha256:186009cdc9aa7e67740a3c8312ec6fae318355cd6cf09fbd4f29aee5a0923739 14 MB
    template.go:43: test-fs-only-ca-reboot: [info] Uncompressing layer sha256:e216621d8a0a7a4486d6a228759b29bc378eff95e6ea9469854b8624b267dd13 137 B
    template.go:43: test-fs-only-ca-reboot: [info] Layers extracted
    template.go:43: test-fs-only-ca-reboot: [info] Root filesystem structure: bin, boot, dev, etc, home, lib, lib32, lib64, libx32, media, mnt, opt, proc, root, run, sbin, srv, sys, tmp, usr, var
    template.go:43: test-fs-only-ca-reboot: [info] Provisioning sandbox template
    template.go:43: test-fs-only-ca-reboot: [info] Provisioning was successful, cleaning up
    template.go:43: test-fs-only-ca-reboot: [info] Sandbox template provisioned
    template.go:43: test-fs-only-ca-reboot: [info] [base] DEFAULT USER user [8079b44084c5d10933c7dc61e96fc26dfd6273dce8bb078d38ef47f777c3fea6]
    template.go:43: test-fs-only-ca-reboot: [info] [builder 1/1] RUN echo 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJmRENDQVNHZ0F3SUJBZ0lCQVRBS0JnZ3Foa2pPUFFRREFqQWxNU013SVFZRFZRUURFeHBsTW1JdGRHVnoKZEMxallTMW1jeTF2Ym14NUxYSmxZbTl2ZERBZUZ3MHlOakEzTXpBeE1ETXpNVFphRncweU5qQTNNekF4TVRNMApNVFphTUNVeEl6QWhCZ05WQkFNVEdtVXlZaTEwWlhOMExXTmhMV1p6TFc5dWJIa3RjbVZpYjI5ME1Ga3dFd1lICktvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVNVm9FM0tteXdUcFZpejdDVkpnK08vdkE0SkYzNnh0RGpkZTgKb2s3WW5xOEt1MS9NcEVvUVpIZXFOb2I4K1BSc0FXY3RmWC9oREtieWdnN016TUd6RTZOQ01FQXdEZ1lEVlIwUApBUUgvQkFRREFnSUVNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHdIUVlEVlIwT0JCWUVGRnc4QUtWL1p0OG00d1QvCmk1MEZDVnVHZ21oNk1Bb0dDQ3FHU000OUJBTUNBMGtBTUVZQ0lRRHRFWjhVa0EyUEVBVGw5WURvRm9LT1hRajAKcnBIU2I2NGJCcHF4cDc0RUR3SWhBTzRHMEdBZDg0ZW93OFdQRGlKRTdEN2V3cmdINy9KbkIvWnBDVTdwRzdaUQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==' | base64 -d > .../share/ca-certificates/e2b-reboot-ca.crt root [d5d955abf436f4b9df94db508c2b2fd2fd4213c5a2c94691820e5a6978091b2f]
    template.go:43: test-fs-only-ca-reboot: [info] [finalize] Finalizing template build [e0c989c4beae4f8ded88c1f4c77e48a954569147cf950cbcb5f0310a23ccc1f4]
    template.go:43: test-fs-only-ca-reboot: [error] Build failed: An internal error occurred. Please try again or contact support with the build ID.
    ca_cert_reboot_test.go:30: Build failed: {<nil> An internal error occurred. Please try again or contact support with the build ID. <nil>}
--- FAIL: TestCACertTrustedAfterFilesystemOnlyReboot (171.59s)
View the full list of 5 ❄️ flaky test(s)
github.com/e2b-dev/infra/tests/integration/internal/tests/envd::TestListDir

Flake rate in main: 25.94% (Passed 217 times, Failed 76 times)

Stack Traces | 1.24s run time
=== RUN   TestListDir
=== PAUSE TestListDir
=== CONT  TestListDir
--- FAIL: TestListDir (1.24s)
Executing command python in sandbox isf0l6fmk71i2cpzz39vw
github.com/e2b-dev/infra/tests/integration/internal/tests/envd::TestListDir/depth_0_lists_only_root_directory

Flake rate in main: 29.33% (Passed 53 times, Failed 22 times)

Stack Traces | 0.02s run time
=== RUN   TestListDir/depth_0_lists_only_root_directory
=== PAUSE TestListDir/depth_0_lists_only_root_directory
=== CONT  TestListDir/depth_0_lists_only_root_directory
    filesystem_test.go:96: 
        	Error Trace:	.../tests/envd/filesystem_test.go:96
        	Error:      	Received unexpected error:
        	            	unavailable: 502 Bad Gateway
        	Test:       	TestListDir/depth_0_lists_only_root_directory
--- FAIL: TestListDir/depth_0_lists_only_root_directory (0.02s)
github.com/e2b-dev/infra/tests/integration/internal/tests/envd::TestListDir/depth_1_lists_root_directory

Flake rate in main: 23.11% (Passed 163 times, Failed 49 times)

Stack Traces | 0.01s run time
=== RUN   TestListDir/depth_1_lists_root_directory
=== PAUSE TestListDir/depth_1_lists_root_directory
=== CONT  TestListDir/depth_1_lists_root_directory
    filesystem_test.go:96: 
        	Error Trace:	.../tests/envd/filesystem_test.go:96
        	Error:      	Received unexpected error:
        	            	unavailable: 502 Bad Gateway
        	Test:       	TestListDir/depth_1_lists_root_directory
--- FAIL: TestListDir/depth_1_lists_root_directory (0.01s)
github.com/e2b-dev/infra/tests/integration/internal/tests/envd::TestListDir/depth_2_lists_first_level_of_subdirectories_(in_this_case_the_root_directory)

Flake rate in main: 50.00% (Passed 1 times, Failed 1 times)

Stack Traces | 0.01s run time
=== RUN   TestListDir/depth_2_lists_first_level_of_subdirectories_(in_this_case_the_root_directory)
=== PAUSE TestListDir/depth_2_lists_first_level_of_subdirectories_(in_this_case_the_root_directory)
=== CONT  TestListDir/depth_2_lists_first_level_of_subdirectories_(in_this_case_the_root_directory)
    filesystem_test.go:96: 
        	Error Trace:	.../tests/envd/filesystem_test.go:96
        	Error:      	Received unexpected error:
        	            	unavailable: 502 Bad Gateway
        	Test:       	TestListDir/depth_2_lists_first_level_of_subdirectories_(in_this_case_the_root_directory)
--- FAIL: TestListDir/depth_2_lists_first_level_of_subdirectories_(in_this_case_the_root_directory) (0.01s)
github.com/e2b-dev/infra/tests/integration/internal/tests/envd::TestListDir/depth_3_lists_all_directories_and_files

Flake rate in main: 23.14% (Passed 93 times, Failed 28 times)

Stack Traces | 0.01s run time
=== RUN   TestListDir/depth_3_lists_all_directories_and_files
=== PAUSE TestListDir/depth_3_lists_all_directories_and_files
=== CONT  TestListDir/depth_3_lists_all_directories_and_files
    filesystem_test.go:96: 
        	Error Trace:	.../tests/envd/filesystem_test.go:96
        	Error:      	Received unexpected error:
        	            	unavailable: 502 Bad Gateway
        	Test:       	TestListDir/depth_3_lists_all_directories_and_files
--- FAIL: TestListDir/depth_3_lists_all_directories_and_files (0.01s)

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a deferred rootfs export mechanism to move the rootfs diff sealing (reflink copy) off the critical path of the sandbox pause operation, allowing it to return without waiting for the writeback stall. The reviewer feedback focuses on improving robustness and error handling: specifically, implementing a fallback to synchronous export when the rootfs provider (such as DirectProvider) does not support deferred export, defining a sentinel error ErrDeferredExportNotSupported to cleanly trigger this fallback, and preventing a potential resource leak of sealCache if the pause operation fails before the background export starts.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread packages/orchestrator/pkg/sandbox/sandbox.go Outdated
Comment thread packages/orchestrator/pkg/sandbox/sandbox.go
Comment thread packages/orchestrator/pkg/sandbox/rootfs/nbd.go
Comment thread packages/orchestrator/pkg/sandbox/rootfs/direct.go
Comment thread packages/orchestrator/pkg/sandbox/sandbox.go
Comment thread packages/orchestrator/pkg/sandbox/rootfs/direct.go
@bchalios
bchalios force-pushed the feat/deferred-rootfs-export branch 2 times, most recently from 05b2204 to 44d32a5 Compare July 21, 2026 11:45
Comment thread packages/orchestrator/pkg/sandbox/sandbox.go
Comment thread packages/orchestrator/pkg/sandbox/sandbox.go Outdated
@bchalios
bchalios force-pushed the feat/deferred-rootfs-export branch from 44d32a5 to 05b2204 Compare July 21, 2026 12:01
Comment thread packages/orchestrator/pkg/sandbox/sandbox.go
Comment thread packages/orchestrator/pkg/server/sandboxes.go
@bchalios

Copy link
Copy Markdown
Contributor Author

Thanks for the reviews. Status on each finding:

Addressed in the current revision

  • Sync fallback for non-NBD providers (gemini high / Cursor "Missing sync fallback for DirectProvider"): DirectProvider.PrepareExportDiff now returns the new sentinel rootfs.ErrDeferredExportNotSupported, and Pause detects it via errors.Is and falls back to the synchronous pauseProcessRootfs path (clearing deferRootfsExport). Enabling the flag on a Direct-provider sandbox degrades to synchronous export instead of failing the pause.
  • Sentinel error (gemini medium, nbd.go / direct.go): added ErrDeferredExportNotSupported in the rootfs package.
  • Ejected sealCache leak on pause error (gemini high / Cursor "Ejected cache leaked on pause error"): the ejected cache is closed by a cleanup guarded on started, so if Pause aborts before startSeal runs the cache is closed there; once the seal owns it, the goroutine closes it.

Fixed in the incoming revision

  • Abort cleanup races with seal (Cursor medium): good catch. The sealCache close was gated on started, but the diffPromise.SetError abort-resolver was not — so on the success path it still ran on snapshot release and could poison the promise, racing the seal's SetValue and dropping the sealed diff. The abort-resolver is now gated on started too, and started is an atomic.Bool (written on the pause goroutine, read on the cleanup goroutine). After the seal starts the resolver is a no-op; it only poisons the promise on the genuine abort path (before startSeal), where it must, so the deferred diff's Close never blocks on a seal that will never resolve.

Intended tradeoff — proposing a follow-up rather than blocking here

  • Failed seal breaks cached snapshot (Cursor high): this is the deliberate async-durability tradeoff of deferring the export, and it's feature-flag-gated and pause-only. Bounding the risk:

    • The seal is a local copy_file_range reflink; it only fails on genuine disk/IO errors (ENOSPC, EIO), which would fail the synchronous path too — just at a different time.
    • Pause already returns before the snapshot upload completes (the upload is fire-and-forget on uploadsWG), so "pause RPC succeeds, durability resolves shortly after" is the existing model; a failed seal extends that window to the local diff.

    The genuine difference from main is that a failed seal leaves the local cache entry unusable (its promise errors), whereas a failed upload on main still leaves a usable local diff for same-node resume. If we want to close that gap, I'd do it as a follow-up: on seal failure, evict the build's local template-cache entry so a same-node resume falls through cleanly instead of hitting a poisoned diff. That eviction is cross-layer (the entry is added in server.snapshotAndCacheSandbox; the failure is detected in sandbox.runDeferredRootfsExport), so I'd prefer to keep it out of this PR. Happy to file it.

@bchalios

Copy link
Copy Markdown
Contributor Author

Two more from the latest Cursor pass — both valid, both fixed in the incoming revision:

  • Empty deferred rootfs breaks peer lookup (Cursor medium): correct — AddSnapshot only skips a concrete *NoDiff, so a deferredDiff that later resolves to NoDiff (a pause with no dirty filesystem blocks) would sit in the DiffStore and make peer LookupDiff succeed, then return NoDiffError from Slice/Size instead of ErrNotAvailable. Fixed at the source: setupDeferredRootfsExport already has the dirty bitmap synchronously (it builds the header before the seal), so when diffMetadata.Dirty.IsEmpty() it now returns a real *NoDiff directly — closing the ejected cache and skipping the background seal entirely (nothing to reflink). That matches the synchronous path exactly, so AddSnapshot omits it from the store and peer lookup keeps returning ErrNotAvailable.

  • Upload waits on rootfs before others (Cursor medium): correct — runV3/runV4 resolved RootfsDiff.CachePath (which blocks on the seal) before building the errgroup, serializing the memfile/snapfile/metadata uploads behind the reflink. Moved the rootfs CachePath resolution into its own errgroup goroutine in both paths, so only the rootfs upload waits on the seal and the other objects upload concurrently with it. (The rootfs header is built synchronously in the deferred path, so only the data path was gated.)

Build + vet + race tests green.

@bchalios
bchalios force-pushed the feat/deferred-rootfs-export branch from 05b2204 to 54615d0 Compare July 21, 2026 13:03
Comment thread packages/orchestrator/pkg/server/sandboxes.go
Comment thread packages/orchestrator/pkg/sandbox/sandbox.go
@bchalios

Copy link
Copy Markdown
Contributor Author

Two more from the re-review of the pushed fixes — both addressed in the incoming revision:

  • Harvest resumes before rootfs seal (Cursor medium): right — the prefetch harvest's throwaway warm resume reads the rootfs, which with deferred export is a promise-backed diff that only resolves once the background seal finishes, so the resume would block on (and spend its budget against) the reflink. Fixed by making the harvest wait for the seal before its timed resume: snapshotResult now carries the rootfs diff, and harvestResumePrefetchAsync calls rootfsDiff.CachePath(hCtx) first — returns immediately on the synchronous/NoDiff paths, blocks until the seal on the deferred path, and if the seal fails or the harvest deadline fires first it records a skipped outcome and never touches a half-sealed snapshot (same skip semantics as a missed start slot). So the two flags now cooperate instead of the harvest reliably failing.

  • Deferred path skips rootfs metrics (Cursor low): correct — setupDeferredRootfsExport built diffMetadata but never called recordSnapshotDiff, so the rootfs size/ratio metrics went dark with the flag on. Added the same recordSnapshotDiff(ctx, "rootfs", diffMetadata, originalHeader) call the synchronous pauseProcessRootfs path makes.

Build + vet + race tests (server + sandbox + build) all green.

@bchalios
bchalios force-pushed the feat/deferred-rootfs-export branch from 54615d0 to d82260a Compare July 21, 2026 13:18
Comment thread packages/orchestrator/pkg/sandbox/build_upload_v3.go
@bchalios

Copy link
Copy Markdown
Contributor Author

Good catch — and this one was introduced by the V3 half of my upload reorder, so it's on me.

V3 uploads rootfs header before seal (Cursor high): correct. In runV3 the rootfs header and body are separate errgroup goroutines, and the header resolves synchronously at pause time — so moving CachePath into only the body goroutine let the header goroutine finalize and publish the rootfs header to storage while the background seal was still running. A subsequent seal failure (which doesn't retry) would then leave a completed header with no body. runV4 doesn't have this because its uploadFramed does body-then-header in one goroutine behind the same CachePath gate.

Fixed by gating the V3 rootfs header goroutine on the seal as well (RootfsDiff.CachePath(egCtx) before storeHeaderWithMetrics). Now both rootfs goroutines wait on the seal — so if it fails, neither the header nor the body is published — while memfile/snapfile/metadata still overlap the reflink. No-op on the synchronous path. Build + vet + race tests green.

@bchalios
bchalios force-pushed the feat/deferred-rootfs-export branch from d82260a to e0229fd Compare July 21, 2026 13:34
Comment thread packages/orchestrator/pkg/server/sandboxes.go
@bchalios

Copy link
Copy Markdown
Contributor Author

Peers see unsealed rootfs as ready (Cursor medium): correct that, with the P2P-chunk-transfer flag and deferred export both on, peerRegistry.Register advertises the build before the seal finishes, so a peer's Size/Slice blocks on our reflink (or gets Internal if the seal fails) rather than ErrNotAvailable. Leaving it as-is intentionally for this PR — it's the same async-durability tradeoff as the deferred seal itself, and the impact is bounded and non-corrupting:

  • A peer blocks at most for the seal duration, then its chunk client times out and falls back to fetching from GCS — a latency hit, not a failure.
  • If the seal fails, the build's own upload fails too, so the build is unavailable everywhere regardless; the peer error is just one symptom of that already-documented failure mode.
  • It only bites when both feature flags are enabled together, which isn't the initial rollout.

The clean fix is to advertise to peers only once the rootfs is sealed. I'm deferring it to a focused follow-up rather than bolting it on here, because doing it correctly has to preserve the RegisterUnregister happens-before ordering: a naive background Register-after-seal races the upload's Unregister (the upload only finishes after the seal) and can leak a peer registration until its TTL. The right shape is to register inside the upload flow right after CachePath resolves and before the upload, which is a small refactor of the upload path — better as its own change. I'll file it.

@ValentaTomas
ValentaTomas force-pushed the main branch 2 times, most recently from 5aad415 to d71980e Compare July 25, 2026 22:53
@bchalios
bchalios force-pushed the feat/deferred-rootfs-export branch 2 times, most recently from c978d51 to f04a401 Compare July 28, 2026 16:46
@bchalios
bchalios marked this pull request as ready for review July 28, 2026 17:55
@bchalios
bchalios enabled auto-merge (squash) July 28, 2026 17:56
Comment thread packages/orchestrator/pkg/sandbox/sandbox.go
Comment thread packages/orchestrator/pkg/sandbox/build/deferred_diff.go
@jakubno
jakubno removed their request for review July 29, 2026 07:06
@bchalios
bchalios force-pushed the feat/deferred-rootfs-export branch 3 times, most recently from de0c279 to 5afb878 Compare July 29, 2026 13:47
return s.failRootfsSeal(diffPromise, fmt.Errorf("create rootfs diff file: %w", err))
}

if _, err := sealCache.ExportToDiff(ctx, diffFile.File); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude is telling me that DiffMetadata and ExportToDiff both read the tracker to get the bitmap, but at different times, and there is no structural guarantee that they are exactly the same. Can we pass the metadata in here?

func (c *Cache) ExportToDiffWithMetadata(ctx context.Context, out *os.File, meta *header.DiffMetadata) error

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.

done!

// kinds) so the fs-only pause latency can be decomposed into quiesce + rootfs.
// This is the pause CRITICAL-PATH rootfs cost: the full export for the
// synchronous path, but only the eject/setup for the deferred path — the
// background reflink seal (runDeferredRootfsExport) is intentionally excluded.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

it looks like with excluding the reflink seal, we lose visibility on the reflink latency. Shall we add a new metric for that?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

also that way we should be able to detect errors on the async path, otherwise they're silent?

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.

Added a metric specifically for the sealing part.

return c.closed.Load()
}

// DiffMetadata returns the dirty/empty diff metadata from the tracker without

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PR summary: we should make it clearer that the latency isn't eliminated in the case of immediate resume - it migrates to the pause.

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.

updated the PR summary.

// Close waits for the seal to resolve and closes the materialized diff. If the
// seal failed there is nothing to close (the producer cleans up the partial file
// on error), so only close when the diff actually materialized.
func (d *deferredDiff) Close() error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this seems to be only called on disk pressure-caused chunk eviction, but isn't on the TTL-caused eviction. Doesn't look material with 25h TTL, but becomes a problem if we choose to shorten TTL for some reason.

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.

added logic to close an unsealed deferredDiff in a go routine (without cancel) in case of eviction.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

rootfs copy is synchronous today is no longer true.

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


// With deferred rootfs export the just-paused snapshot's rootfs diff is
// sealed (reflinked) in the background, and the throwaway warm resume below
// reads the rootfs. Wait for the seal to finish here instead of letting the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can we not count it into the harvestDurationHistogram and add record this wait as a separate metric?

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.

done!

// diff in the background, so the pause returns without paying the reflink stall.
// Only safe on the suspend path, where nothing reads the diff before the seal
// completes.
func (s *Sandbox) setupDeferredRootfsExport(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

would it make sense to add tests to cover failure branches here?

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.

Added failure branch tests covering the eject-failure early return.

// TestDeferredDiff_SealedIsNonBlocking verifies sealed() reports readiness
// without blocking on the promise, so cache eviction can skip an unsealed diff
// instead of stalling on it.
func TestDeferredDiff_SealedIsNonBlocking(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can we have a test for the fix in the commit, ie the diff is skipped when unsealed?

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.

added a test that ensures that the diff is skipped when unsealed and then later evicted after it was sealed.

if err := s.sealCacheToDiff(ctx, sealCache, buildID, blockSize, diffPromise); err != nil {
logger.L().Error(ctx, "deferred rootfs export failed", zap.Error(err))
} else {
telemetry.ReportEvent(ctx, "rootfs diff sealed (deferred)")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude says we don't report 'exported rootfs' event on the deferred path.

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.

adding missing telemetry events

@bchalios
bchalios force-pushed the feat/deferred-rootfs-export branch from 5afb878 to 48f42c3 Compare July 29, 2026 15:40
// The provider (e.g. DirectProvider) can't defer; fall through to the
// synchronous export below. Safe because PrepareExportDiff returns this
// sentinel before ejecting/stopping anything.
pauseOpts.deferRootfsExport = false

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude is saying we can never read this false, including the histogram function above (processRootfsDurationHistogram) where it could be helpful.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

like deferRootfsExport could be an attribute to that metric

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.

done

// when ejecting the writable cache fails, setup propagates the error and returns
// no diff/header/seal — so Pause falls back to the synchronous export rather than
// registering a deferred diff whose seal would never run.
func TestSetupDeferredRootfsExport_PrepareError(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

claude thinks it would also be nice to test the call order of the cleanups in setupDeferredRootfsExport in the case of a later failure to avoid lockups.

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.

added

@bchalios
bchalios force-pushed the feat/deferred-rootfs-export branch 2 times, most recently from e458828 to 455008e Compare July 29, 2026 17:20

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 0d28de6. Configure here.

Comment thread packages/orchestrator/pkg/sandbox/build_upload_v3.go
Add the building blocks to seal the rootfs diff off the pause critical path:

- block.Cache.DiffMetadata() returns the dirty/empty bitmaps from the frozen
  tracker without copying, so the diff header + scheduling metadata can be built
  synchronously while the reflink copy runs in the background.
- rootfs.Provider.PrepareExportDiff ejects the writable cache, stops the sandbox
  and waits for the overlay device to release, then returns the frozen ejected
  cache WITHOUT reflinking it (shares ejectAndStopSandbox with ExportDiff).
  DirectProvider stubs it (NBD-only).
- build.NewDeferredDiff wraps a promise of a Diff: CacheKey/BlockSize are
  synchronous (DiffStore.Add + upload compress-config need them up front), while
  data methods block on the promise until the background sealer resolves it.

No behavior change yet — nothing calls these.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Babis Chalios <babis.chalios@e2b.dev>
@bchalios
bchalios force-pushed the feat/deferred-rootfs-export branch from 0d28de6 to b9742e0 Compare July 30, 2026 10:06
@blacksmith-sh

This comment has been minimized.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b9742e09ed

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +2139 to +2141
diff, err := diffFile.CloseToDiff(blockSize)
if err != nil {
return s.failRootfsSeal(diffPromise, fmt.Errorf("materialize rootfs diff: %w", 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.

P2 Badge Remove deferred diff files when materialization fails

When deferred rootfs export reaches this path and CloseToDiff fails after the large diff has already been written (for example an fsync/stat error), the partial cache file is only closed and is never removed or registered with the diff store. Because the upload error is then marked as a permanent deferred-seal failure, repeated failures can leave orphaned rootfs diff files in DefaultCacheDir that disk-pressure eviction cannot reclaim until process restart.

Useful? React with 👍 / 👎.

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.

Good catch — fixed in d545ded. The leak was in CloseToDiff: on a Sync/Stat/materialization failure it only closed the fd (defer f.File.Close()) without os.Remove, so the partial file orphaned in DefaultCacheDir with nothing registering it in the DiffStore (eviction can't reclaim a file it doesn't know about). Fixed at the CloseToDiff layer so every caller — the deferred seal, the synchronous pause path, and ensurefreedisk/grow — removes its partial file on failure. Added TestLocalDiffFileCloseToDiffRemovesPartialOnError.

Comment on lines +189 to +196
// DeferRootfsExportFlag moves the rootfs diff seal (the reflink, which forces a
// synchronous host->NVMe writeback) off the pause critical path: pause() ejects
// the cache and stops the sandbox, then reflinks the diff in the background so
// the call returns without the writeback stall. Applied only to the suspend
// (pause) path, where nothing reads the diff until a later resume. Off by
// default; falls back to the synchronous export when off or on a non-NBD
// provider.
DeferRootfsExportFlag = NewBoolFlag("defer-rootfs-export", false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Update architecture for deferred pause flow

This flag adds a pause mode where the orchestrator returns after ejecting/stopping the sandbox and seals the rootfs diff in the background, so resumes/prefetch may wait on that seal; docs/ARCHITECTURE.md still describes pause as synchronously diffing rootfs before the snapshot is cached/uploaded. Please update the pause/resume flow documentation with this new deferred-seal behavior so operators do not rely on the old ordering.

AGENTS.md reference: AGENTS.md:L7-L9

Useful? React with 👍 / 👎.

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.

Done in d545ded — added a Deferred rootfs export sub-bullet to the Pause section of docs/ARCHITECTURE.md. It documents the flag-gated behavior: the orchestrator ejects the writable COW cache during pause and returns, then seals it into the rootfs diff (reflink) in the background — so the async upload and any origin-node resume/prefetch that reads the rootfs diff wait on the seal, and a seal failure is permanent (the upload fails fast rather than retrying).

bchalios and others added 2 commits July 30, 2026 12:23
Sealing the rootfs diff is a reflink (copy_file_range on XFS) that forces a
synchronous host->NVMe writeback of the changed blocks — the stall that makes
pause() scale with how much the guest wrote. Move it off the critical path for
the suspend path: eject the cache + stop the sandbox, hand back a deferred diff,
and reflink it in the background, so pause() returns without the writeback.

- WithDeferredRootfsExport pause option + setupDeferredRootfsExport /
  runDeferredRootfsExport / sealCacheToDiff in Pause: build the header
  synchronously from the frozen cache's DiffMetadata, resolve the deferred diff
  from a background reflink. The async upload waits on the deferred diff, so
  graceful shutdown is gated via the server's upload WaitGroup.
- Gated by the defer-rootfs-export flag (default off), and only the Pause
  (suspend) handler opts in — Checkpoint resumes a fresh sandbox from the new
  build immediately, so its diff must be materialized synchronously.
- Falls back to the synchronous export when off or on a non-NBD provider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Babis Chalios <babis.chalios@e2b.dev>
The single disk-space eviction goroutine calls FileSize on the oldest
evictable cache entry; for a still-sealing deferred rootfs diff that blocks
on the background seal (WaitWithContext with the eviction loop's long-lived
ctx), stalling all reclamation during exactly the high-disk-pressure window
eviction exists to relieve.

Add a non-blocking deferredDiff.sealed() check (via the SetOnce Done channel)
and skip not-yet-sealed diffs in eviction, like pinned entries — so eviction
neither blocks on the seal nor evicts a fresh, in-flight snapshot; the diff
becomes evictable once its seal resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Babis Chalios <babis.chalios@e2b.dev>
@bchalios
bchalios force-pushed the feat/deferred-rootfs-export branch from b9742e0 to bec1b51 Compare July 30, 2026 10:25

@kalyazin kalyazin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM

The only thing is claude flags the following as a minor finding, and I can't see how realistic it is. Up to you if you find it worth fixing.

Seal goroutine isn't on a shutdown WaitGroup — after drainUploads times out the process can still exit mid-copy_file_range. Now bounded in blast radius by
the CloseToDiff cleanup for in-process failures, but an exit-time partial still orphans.

@bchalios
bchalios merged commit 851527d into main Jul 30, 2026
42 checks passed
@bchalios
bchalios deleted the feat/deferred-rootfs-export branch July 30, 2026 10:49
bchalios added a commit that referenced this pull request Jul 31, 2026
Adds the overlay primitives the in-place background rootfs seal needs, on top of
main's #3320 deferred-export code:

- Overlay: atomic writable-cache pointer + a "sealing" slot. SwapCache installs
  a fresh writable cache and moves the previous one to the sealing slot; ReadAt
  resolves writable -> sealing -> base. FoldSealing / ReleaseSealing collapse the
  sealing cache back once its background seal is done.
- Cache.FillMissingFrom copies the sealing cache's blocks the writable cache
  lacks (concurrency-safe writeAtIfAbsent keeps the guest's newer writes), so the
  writable cache is a complete diff again after a fold.

Cache.DiffMetadata already exists on main (from #3320) and is reused. Purely
additive; overlay.go was untouched by #3320.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Babis Chalios <babis.chalios@e2b.dev>
bchalios added a commit that referenced this pull request Jul 31, 2026
Adds the NBD provider capabilities the in-place background seal needs, alongside
main's #3320 PrepareExportDiff (destroy-path eject+stop):

- ExportDiffInPlace: reflink the overlay's dirty blocks without ejecting the
  cache, so the sandbox keeps running on it (synchronous in-place fallback).
- SwapForBackgroundSeal: flush the device, swap a fresh writable cache onto the
  live overlay, return the frozen previous cache for background sealing.
- FoldSealed: fold the sealing cache back into the writable cache and detach it.

DirectProvider stubs all three (NBD-only). Reuses main's ejectAndStopSandbox.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Babis Chalios <babis.chalios@e2b.dev>
bchalios added a commit that referenced this pull request Jul 31, 2026
Adds the overlay primitives the in-place background rootfs seal needs, on top of
main's #3320 deferred-export code:

- Overlay: atomic writable-cache pointer + a "sealing" slot. SwapCache installs
  a fresh writable cache and moves the previous one to the sealing slot; ReadAt
  resolves writable -> sealing -> base. FoldSealing / ReleaseSealing collapse the
  sealing cache back once its background seal is done.
- Cache.FillMissingFrom copies the sealing cache's blocks the writable cache
  lacks (concurrency-safe writeAtIfAbsent keeps the guest's newer writes), so the
  writable cache is a complete diff again after a fold.

Cache.DiffMetadata already exists on main (from #3320) and is reused. Purely
additive; overlay.go was untouched by #3320.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Babis Chalios <babis.chalios@e2b.dev>
bchalios added a commit that referenced this pull request Jul 31, 2026
Adds the NBD provider capabilities the in-place background seal needs, alongside
main's #3320 PrepareExportDiff (destroy-path eject+stop):

- ExportDiffInPlace: reflink the overlay's dirty blocks without ejecting the
  cache, so the sandbox keeps running on it (synchronous in-place fallback).
- SwapForBackgroundSeal: flush the device, swap a fresh writable cache onto the
  live overlay, return the frozen previous cache for background sealing.
- FoldSealed: fold the sealing cache back into the writable cache and detach it.

DirectProvider stubs all three (NBD-only). Reuses main's ejectAndStopSandbox.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Babis Chalios <babis.chalios@e2b.dev>
jakubno pushed a commit that referenced this pull request Aug 3, 2026
## What

When we are snapshotting a sandbox, during pause()/snapshot() we create
a snapshot of both memory and rootfs.
Exporting the rootfs boils down to reflinking the overlay to the local
cache path and then (asynchronously) uploading the cache to GCS.

This exporting process happens synchronously. We have seen that,
_typically_ reflinking is very fast as it deals with metadata (rather
than moving data around).

This PR makes the export asynchronous (optional, guarded by a feature
flag).

## Why

We have seen that, whereas the reflink operation is typically fast, it
can experience long tails when there's a lot of dirty data in the page
cache that needs to be written to the backing storage.

For example, when we are taking a snapshot after writing 2GiB of data in
the disk immediately after the write finished (E1) and 30 seconds after
the write had completed and:

* E1 — pause immediately after write: the ~2 GB NVMe flush burst happens
inside the pause window (host Dirty ~2 GB → 0 during the pause). → pause
= 6.8 s
* E2 — 30 s settle before pause: the flush burst happens during the
settle; Dirty is already 0 when the pause fires. → pause = 0.9 s
* Same write, same node — only difference is whether the flush overlaps
the pause.

This nicely scales with amount of time we wait after the write:
* settle  0 s  →  ~4.2 s   (6.8 s tail)
* settle  5 s  →   3.78 s
* settle 10 s  →   3.71 s
* settle 20 s  →   1.53 s
* settle 30 s  →  ~0.7–0.9 s

---------

Signed-off-by: Babis Chalios <babis.chalios@e2b.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jakubno pushed a commit that referenced this pull request Aug 3, 2026
Adds the overlay primitives the in-place background rootfs seal needs, on top of
main's #3320 deferred-export code:

- Overlay: atomic writable-cache pointer + a "sealing" slot. SwapCache installs
  a fresh writable cache and moves the previous one to the sealing slot; ReadAt
  resolves writable -> sealing -> base. FoldSealing / ReleaseSealing collapse the
  sealing cache back once its background seal is done.
- Cache.FillMissingFrom copies the sealing cache's blocks the writable cache
  lacks (concurrency-safe writeAtIfAbsent keeps the guest's newer writes), so the
  writable cache is a complete diff again after a fold.

Cache.DiffMetadata already exists on main (from #3320) and is reused. Purely
additive; overlay.go was untouched by #3320.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Babis Chalios <babis.chalios@e2b.dev>
jakubno pushed a commit that referenced this pull request Aug 3, 2026
Adds the NBD provider capabilities the in-place background seal needs, alongside
main's #3320 PrepareExportDiff (destroy-path eject+stop):

- ExportDiffInPlace: reflink the overlay's dirty blocks without ejecting the
  cache, so the sandbox keeps running on it (synchronous in-place fallback).
- SwapForBackgroundSeal: flush the device, swap a fresh writable cache onto the
  live overlay, return the frozen previous cache for background sealing.
- FoldSealed: fold the sealing cache back into the writable cache and detach it.

DirectProvider stubs all three (NBD-only). Reuses main's ejectAndStopSandbox.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Babis Chalios <babis.chalios@e2b.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants