Skip to content

Commit d2861e4

Browse files
kalyazinclaude
andcommitted
feat(orch): serve resume from a provisional header during dedup
A resume that overlaps an in-flight pause of the same sandbox can block in the storage-template-memfile span: the local memfile template can't be built until the deduped header resolves, which only happens after dedup's compare+defrag. The in-flight memfd serving added earlier removes the drain wait but not this header wait, because it is upstream of the diff source. At pause, build a provisional local header (identity storage offsets, dirty pages attributed to a fresh provisional build id) from the dirty bitmap and the parent header, and register a memfd-backed diff source for that build id. The local template is built from the provisional header immediately, so a concurrent resume serves dirty pages straight from the still-mapped memfd instead of blocking. Once the deduped header resolves it is swapped in (SwapHeader), routing dirty pages to the compacted deduped diff; the distinct build id makes the swap race-free since build.File resolves the source from the same header snapshot as the offset. The swap is conditional (compare-and-swap from the still-provisional header) so a late swap can't clobber a newer header the upload may have finalized first. The provisional header is local-only: the upload continues to use the deduped header, so the persisted artifact is unchanged. A pause must never parent a snapshot off the provisional header: it maps dirty pages to a synthetic build id with no storage object, so an uploaded header that inherited those mappings (from a resume paused again before the swap) would be unreadable on a cold or cross-node resume. build.File carries the deduped header as a durable-header future, and processMemorySnapshot resolves the parent via DurableHeader (waiting for the deduped header if a swap is still pending) rather than the live, possibly-provisional header. Gated by memfd-dedup-inflight-serve (default off); composes with the in-flight drain serving (provisional covers the compare window, in-flight the drain). Signed-off-by: Nikita Kalyazin <nikita.kalyazin@e2b.dev> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2043ff4 commit d2861e4

9 files changed

Lines changed: 274 additions & 27 deletions

File tree

packages/orchestrator/pkg/sandbox/build/build.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@ var (
5252
)
5353

5454
type File struct {
55-
header atomic.Pointer[header.Header]
55+
header atomic.Pointer[header.Header]
56+
// durable, when set, is the future for the header this file will eventually
57+
// settle on (the deduped header while a provisional header is being served).
58+
// DurableHeader waits on it so a pause never parents a snapshot off a
59+
// provisional (local-only) header — see SetDurableHeader.
60+
durable atomic.Pointer[utils.SetOnce[*header.Header]]
5661
store *DiffStore
5762
fileType DiffType
5863
persistence storage.StorageProvider
@@ -91,6 +96,35 @@ func (b *File) SwapHeader(h *header.Header) {
9196
b.header.Store(h)
9297
}
9398

99+
// SwapHeaderIfCurrent atomically replaces the header with next only if it is
100+
// still old, reporting whether it did. The provisional→deduped swap uses this so
101+
// that, if it runs late, it can't clobber a newer header already installed by
102+
// another writer (e.g. Upload.publish finalizing the build, which swaps
103+
// unconditionally) with the older, still-incomplete deduped header.
104+
func (b *File) SwapHeaderIfCurrent(old, next *header.Header) bool {
105+
return b.header.CompareAndSwap(old, next)
106+
}
107+
108+
// SetDurableHeader records the future for the durable header this file will
109+
// settle on, so DurableHeader can return it instead of the currently-installed
110+
// (possibly provisional) header. Set once, before the file is shared.
111+
func (b *File) SetDurableHeader(f *utils.SetOnce[*header.Header]) {
112+
b.durable.Store(f)
113+
}
114+
115+
// DurableHeader returns the header safe to persist as a parent: the durable
116+
// future's value if one was set (waiting for it), otherwise the currently
117+
// installed header. Callers building an *uploaded* snapshot header (a Pause)
118+
// must use this, never Header(), so they never inherit a provisional header's
119+
// synthetic build-id mappings, which have no storage object.
120+
func (b *File) DurableHeader(ctx context.Context) (*header.Header, error) {
121+
if f := b.durable.Load(); f != nil {
122+
return f.WaitWithContext(ctx)
123+
}
124+
125+
return b.Header(), nil
126+
}
127+
94128
// ReadAt times file.read_at around readAt; Slice composes via readAt directly to
95129
// avoid double counting.
96130
func (b *File) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) {

packages/orchestrator/pkg/sandbox/build/cache.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,13 @@ func (s *DiffStore) Has(d Diff) bool {
176176
return s.cache.Has(d.CacheKey())
177177
}
178178

179+
// Delete evicts a single entry by key, running the eviction callback (which
180+
// closes the Diff). Used to drop the short-lived provisional memfile entry once
181+
// the deduped header has been swapped in, so it doesn't linger for the TTL.
182+
func (s *DiffStore) Delete(key DiffStoreKey) {
183+
s.cache.Delete(key)
184+
}
185+
179186
// Lookup returns the cached Diff for the given key without initialising a new one.
180187
// Returns (nil, false) if the key is not present in the cache.
181188
func (s *DiffStore) Lookup(key DiffStoreKey) (Diff, bool) {

packages/orchestrator/pkg/sandbox/sandbox.go

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1435,6 +1435,17 @@ func (s *Sandbox) Pause(
14351435
type MemorySnapshot struct {
14361436
Diff build.Diff
14371437
DiffHeader *DiffHeader
1438+
// ProvisionalDiffHeader + ProvisionalDiff, when non-nil, let the local
1439+
// template serve immediately from the still-mapped memfd via a distinct
1440+
// provisional build id while dedup runs, instead of blocking a concurrent
1441+
// resume in storage-template-memfile on the deduped header. They feed only
1442+
// the local AddSnapshot path; the upload still uses DiffHeader (deduped).
1443+
ProvisionalDiffHeader *header.Header
1444+
ProvisionalDiff build.Diff
1445+
// ProvisionalSwapDone, when non-nil, is invoked by the AddSnapshot swap
1446+
// goroutine once it has swapped the deduped header in; it lets the dedup
1447+
// goroutine release the memfd the provisional source was serving from.
1448+
ProvisionalSwapDone func()
14381449
// BlockSize is captured synchronously at Pause time because NewUpload's
14391450
// compression validation needs it before the async dedup header resolves;
14401451
// the dedup memfile path produces a page-granular Diff.BlockSize() that
@@ -1456,7 +1467,21 @@ func (s *Sandbox) processMemorySnapshot(ctx context.Context, buildID uuid.UUID)
14561467
if err != nil {
14571468
return MemorySnapshot{}, fmt.Errorf("failed to get original memfile: %w", err)
14581469
}
1470+
// Parent off the durable (deduped) header, never a provisional (local-only)
1471+
// one: a provisional header maps dirty pages to a synthetic build id with no
1472+
// storage object, so an uploaded header inheriting those mappings would be
1473+
// unreadable on a cold or cross-node resume. DurableHeader waits for the
1474+
// deduped header if a provisional swap is still pending; devices without one
1475+
// return their current header immediately.
14591476
memfileHeader := originalMemfile.Header()
1477+
if dh, ok := originalMemfile.(interface {
1478+
DurableHeader(ctx context.Context) (*header.Header, error)
1479+
}); ok {
1480+
memfileHeader, err = dh.DurableHeader(ctx)
1481+
if err != nil {
1482+
return MemorySnapshot{}, fmt.Errorf("failed to resolve durable memfile header: %w", err)
1483+
}
1484+
}
14601485

14611486
memfileDiffMetadata, err := s.Resources.memory.DiffMetadata(ctx, s.process)
14621487
if err != nil {
@@ -1481,7 +1506,7 @@ func (s *Sandbox) processMemorySnapshot(ctx context.Context, buildID uuid.UUID)
14811506
}
14821507
}
14831508

1484-
memfileDiff, memfileDiffHeader, err := pauseProcessMemory(
1509+
memfileDiff, memfileDiffHeader, provMemfileHeader, provMemfileDiff, provMemfileSwapDone, err := pauseProcessMemory(
14851510
ctx,
14861511
buildID,
14871512
memfileHeader,
@@ -1501,11 +1526,14 @@ func (s *Sandbox) processMemorySnapshot(ctx context.Context, buildID uuid.UUID)
15011526
}
15021527

15031528
return MemorySnapshot{
1504-
Diff: memfileDiff,
1505-
DiffHeader: memfileDiffHeader,
1506-
BlockSize: memfileHeader.Metadata.BlockSize,
1507-
header: memfileHeader,
1508-
newBytes: memfileDiffMetadata.Dirty.GetCardinality() * uint64(memfileDiffMetadata.BlockSize),
1529+
Diff: memfileDiff,
1530+
DiffHeader: memfileDiffHeader,
1531+
ProvisionalDiffHeader: provMemfileHeader,
1532+
ProvisionalDiff: provMemfileDiff,
1533+
ProvisionalSwapDone: provMemfileSwapDone,
1534+
BlockSize: memfileHeader.Metadata.BlockSize,
1535+
header: memfileHeader,
1536+
newBytes: memfileDiffMetadata.Dirty.GetCardinality() * uint64(memfileDiffMetadata.BlockSize),
15091537
}, nil
15101538
}
15111539

@@ -1539,7 +1567,7 @@ func pauseProcessMemory(
15391567
dedupDirectIO bool,
15401568
dedupBudget block.DedupBudget,
15411569
dedupInflightServe bool,
1542-
) (d build.Diff, h *DiffHeader, e error) {
1570+
) (d build.Diff, h *DiffHeader, provisionalHeader *header.Header, provisionalDiff build.Diff, provisionalSwapDone func(), e error) {
15431571
ctx, span := tracer.Start(ctx, "process-memory")
15441572
defer span.End()
15451573

@@ -1552,17 +1580,24 @@ func pauseProcessMemory(
15521580
dedupInflightServe,
15531581
)
15541582
if err != nil {
1555-
return nil, nil, fmt.Errorf("failed to export memory: %w", err)
1583+
return nil, nil, nil, nil, nil, fmt.Errorf("failed to export memory: %w", err)
15561584
}
15571585

15581586
diff, err := build.NewLocalDiffFromCache(
15591587
build.GetDiffStoreKey(buildID.String(), build.Memfile),
15601588
cache,
15611589
)
15621590
if err != nil {
1563-
return nil, nil, fmt.Errorf("failed to create local diff from cache: %w", errors.Join(err, cache.Close()))
1591+
return nil, nil, nil, nil, nil, fmt.Errorf("failed to create local diff from cache: %w", errors.Join(err, cache.Close()))
15641592
}
15651593

1594+
// Provisional local header: while the deduped header is still
1595+
// being computed, let a same-node resume serve dirty pages from the memfd via
1596+
// a distinct provisional build id at identity offsets. Gated on the memfd
1597+
// dedup path + the inflight-serve flag; best-effort (fall back to the deduped
1598+
// header on any error). The upload always uses the deduped header below.
1599+
provisionalHeader, provisionalDiff, provisionalSwapDone = buildProvisionalMemfile(ctx, cache, dedupInflightServe, originalMemfile, originalHeader, diffMetadata)
1600+
15661601
// Build the diff header on a goroutine so Pause returns without waiting
15671602
// on memfd-dedup compare. ExportMemory resolves metaOut sync for every
15681603
// other path, so Wait there is non-blocking; the goroutine is harmless.
@@ -1589,7 +1624,46 @@ func pauseProcessMemory(
15891624
setHeader(meta.ToDiffHeader(ctx, originalHeader, buildID))
15901625
}()
15911626

1592-
return diff, headerOut, nil
1627+
return diff, headerOut, provisionalHeader, provisionalDiff, provisionalSwapDone, nil
1628+
}
1629+
1630+
// buildProvisionalMemfile builds the provisional local header + its memfd-backed
1631+
// diff source, plus a swap-done callback the AddSnapshot swap goroutine invokes
1632+
// once it has swapped the deduped header in (it lets the dedup goroutine release
1633+
// the memfd). Returns (nil, nil, nil) — falling back to the deduped header —
1634+
// when the path doesn't apply or on any error, so it never blocks a pause. The
1635+
// provisional source is keyed by a fresh build id so a header swap to the
1636+
// deduped header (after dedup) is race-free (see MemfdIdentitySource).
1637+
func buildProvisionalMemfile(
1638+
ctx context.Context,
1639+
cache block.DiffSource,
1640+
enabled bool,
1641+
originalMemfile block.ReadonlyDevice,
1642+
originalHeader *header.Header,
1643+
diffMetadata *header.DiffMetadata,
1644+
) (*header.Header, build.Diff, func()) {
1645+
dc, ok := cache.(*block.DedupedMemfdCache)
1646+
if !ok || !enabled || originalMemfile == nil || originalHeader == nil || diffMetadata == nil {
1647+
return nil, nil, nil
1648+
}
1649+
1650+
provisionalBuildID := uuid.New()
1651+
provisionalHeader, err := diffMetadata.ToProvisionalDiffHeader(ctx, originalHeader, provisionalBuildID)
1652+
if err != nil {
1653+
logger.L().Warn(ctx, "build provisional memfile header; using deduped header", zap.Error(err))
1654+
1655+
return nil, nil, nil
1656+
}
1657+
1658+
provisionalSource := block.NewMemfdIdentitySource(dc, int64(originalHeader.Metadata.Size))
1659+
provisionalDiff, err := build.NewLocalDiffFromCache(build.GetDiffStoreKey(provisionalBuildID.String(), build.Memfile), provisionalSource)
1660+
if err != nil {
1661+
logger.L().Warn(ctx, "build provisional memfile diff; using deduped header", zap.Error(err))
1662+
1663+
return nil, nil, nil
1664+
}
1665+
1666+
return provisionalHeader, provisionalDiff, dc.MarkSwapped
15931667
}
15941668

15951669
func pauseProcessRootfs(

packages/orchestrator/pkg/sandbox/template/cache.go

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ func (c *Cache) GetTemplate(
195195
c.blockMetrics,
196196
nil,
197197
nil,
198+
nil,
198199
)
199200
if err != nil {
200201
return nil, fmt.Errorf("failed to create template cache from storage: %w", err)
@@ -224,35 +225,129 @@ func (c *Cache) AddSnapshot(
224225
localMetafile File,
225226
memfileDiff build.Diff,
226227
rootfsDiff build.Diff,
228+
// provisionalMemfileHeader/Diff: when non-nil the local memfile
229+
// template is built from the provisional header (which attributes dirty pages
230+
// to provisionalMemfileDiff's build id at identity offsets) so a concurrent
231+
// resume serves immediately from the memfd; once memfileHeader (the deduped
232+
// header) resolves it is swapped in. When nil, the template is built directly
233+
// from memfileHeader as before. The upload always uses memfileHeader.
234+
provisionalMemfileHeader *header.Header,
235+
provisionalMemfileDiff build.Diff,
236+
// provisionalSwapDone, when non-nil, is called once the deduped header has
237+
// been swapped in; it lets the dedup goroutine release the memfd the
238+
// provisional source was serving from.
239+
provisionalSwapDone func(),
227240
) error {
228241
switch memfileDiff.(type) {
229242
case *build.NoDiff:
230243
default:
231244
c.buildStore.Add(memfileDiff)
232245
}
246+
if provisionalMemfileDiff != nil {
247+
if _, ok := provisionalMemfileDiff.(*build.NoDiff); !ok {
248+
// Assumption: this provisional entry stays resident in the build store
249+
// until the SwapHeader below (i.e. for the provisional window — the
250+
// dedup compare). It is keyed by a synthetic build id with no GCS
251+
// object, so if it were evicted mid-window a resume read routed to it
252+
// would miss and could not be rebuilt (createDiff has nothing to fetch),
253+
// failing the read. This holds because the store TTL (hours) dwarfs the
254+
// window (seconds) and eviction is LRU-by-TTL, so this freshly-added
255+
// entry is evicted last. We rely on that rather than guarding the miss:
256+
// the eviction can't realistically occur, so a fallback isn't warranted.
257+
c.buildStore.Add(provisionalMemfileDiff)
258+
}
259+
}
233260

234261
switch rootfsDiff.(type) {
235262
case *build.NoDiff:
236263
default:
237264
c.buildStore.Add(rootfsDiff)
238265
}
239266

267+
// Build the local template from the provisional header (resolved now) so
268+
// Memfile() doesn't block on dedup; fall back to the deduped header future.
269+
// When serving a provisional header, pass the deduped header future as the
270+
// memfile's durable header so a pause parents off it, never the provisional
271+
// header (whose synthetic build id has no storage object). It is applied at
272+
// construction — before the memfile device is published — so no reader can
273+
// observe the device with the durable header unset.
274+
localMemfileHeader := memfileHeader
275+
var durableMemfileHeader *utils.SetOnce[*header.Header]
276+
if provisionalMemfileHeader != nil {
277+
localMemfileHeader = resolvedHeader(provisionalMemfileHeader)
278+
durableMemfileHeader = memfileHeader
279+
}
280+
240281
storageTemplate, err := newTemplateFromStorage(
241282
c.config.BuilderConfig,
242283
buildId,
243-
memfileHeader,
284+
localMemfileHeader,
244285
rootfsHeader,
245286
c.persistence,
246287
c.blockMetrics,
247288
localSnapfile,
248289
localMetafile,
290+
durableMemfileHeader,
249291
)
250292
if err != nil {
251293
return fmt.Errorf("failed to create template cache from storage: %w", err)
252294
}
253295

254296
c.getTemplateWithFetch(ctx, storageTemplate, 0)
255297

298+
// Swap the provisional header for the deduped one once dedup finishes, so
299+
// subsequent reads route dirty pages to the (compacted) deduped diff and the
300+
// provisional memfd source is no longer referenced. The durable header was
301+
// wired in at construction above.
302+
if provisionalMemfileHeader != nil {
303+
swapCtx := context.WithoutCancel(ctx)
304+
go func() {
305+
deduped, err := memfileHeader.Wait()
306+
if err != nil {
307+
logger.L().Warn(swapCtx, "provisional memfile header swap: deduped header failed", zap.Error(err))
308+
309+
return
310+
}
311+
mem, err := storageTemplate.Memfile(swapCtx)
312+
if err != nil {
313+
logger.L().Warn(swapCtx, "provisional memfile header swap: get memfile", zap.Error(err))
314+
315+
return
316+
}
317+
if mem == nil {
318+
logger.L().Warn(swapCtx, "provisional memfile header swap: memfile is nil")
319+
320+
return
321+
}
322+
// Swap only if the header is still the provisional one. Upload.publish
323+
// (and the P2P poll path) install a finalized header unconditionally;
324+
// if this goroutine runs late we must not clobber that newer header
325+
// with the older, still-incomplete deduped one. Either way the
326+
// provisional header is no longer needed, so release + drop below.
327+
if cas, ok := mem.(interface {
328+
SwapHeaderIfCurrent(old, next *header.Header) bool
329+
}); ok {
330+
if !cas.SwapHeaderIfCurrent(provisionalMemfileHeader, deduped) {
331+
logger.L().Info(swapCtx, "provisional memfile header swap: header already advanced; skipping")
332+
}
333+
} else {
334+
mem.SwapHeader(deduped)
335+
}
336+
337+
// Reads now route off the provisional build id. Let the dedup goroutine
338+
// release the memfd (it keeps it mapped until this fires or a grace
339+
// elapses). The provisional store entry is intentionally NOT deleted
340+
// here: a reader that planned on the provisional header but has not yet
341+
// hit the store would miss and fall through to a storage fetch for the
342+
// synthetic build id (never uploaded). It is harmless to leave — no
343+
// reads route to it post-swap, it reports FileSize 0 so it doesn't skew
344+
// disk eviction, and the store TTL reclaims it (its Close is a no-op).
345+
if provisionalSwapDone != nil {
346+
provisionalSwapDone()
347+
}
348+
}()
349+
}
350+
256351
return nil
257352
}
258353

packages/orchestrator/pkg/sandbox/template/storage.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/e2b-dev/infra/packages/shared/pkg/logger"
1616
"github.com/e2b-dev/infra/packages/shared/pkg/storage"
1717
"github.com/e2b-dev/infra/packages/shared/pkg/storage/header"
18+
"github.com/e2b-dev/infra/packages/shared/pkg/utils"
1819
)
1920

2021
const (
@@ -156,6 +157,18 @@ func (d *Storage) SwapHeader(h *header.Header) {
156157
d.source.SwapHeader(h)
157158
}
158159

160+
func (d *Storage) SwapHeaderIfCurrent(old, next *header.Header) bool {
161+
return d.source.SwapHeaderIfCurrent(old, next)
162+
}
163+
164+
func (d *Storage) SetDurableHeader(f *utils.SetOnce[*header.Header]) {
165+
d.source.SetDurableHeader(f)
166+
}
167+
168+
func (d *Storage) DurableHeader(ctx context.Context) (*header.Header, error) {
169+
return d.source.DurableHeader(ctx)
170+
}
171+
159172
func (d *Storage) Close() error {
160173
return nil
161174
}

0 commit comments

Comments
 (0)