detached 0abd08b

feat(snapshot): rate-limit checkpoints

commit
0abd08bc88b9be58c7907d21492de68529fdc1d3
author
brice · · 2026-07-21 21:50 UTC
parent
025da44
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +62 -22
M internal/usecase/daemon/snapshot.go +62 -22
@@ -43,17 +43,24 @@ type snapshotCaptureTab struct {
4343 }
4444
4545 type snapshotCapturePane struct {
46- id layout.PaneID
47- stableID string
48- cwd string
46+ id layout.PaneID
47+ stableID string
48+ cwd string
49+ // history is retained for the v3 compatibility encoder. Incremental
50+ // captures keep sealed identities and a copied tail separately so capture
51+ // never rotates a live mutable tail merely to persist it.
4952 history vt.HistoryView
53+ sealed vt.HistorySnapshotView
54+ tail vt.HistoryView
5055 visible []byte
5156 visibleErr error
5257 process *snapcodec.Process
5358 }
5459
5560 const (
56- snapshotInterval = 30 * time.Second
61+ // Routine checkpoints are rate limited from completion; forced teardown
62+ // checkpoints use the same bounded worker but never consume this window.
63+ snapshotInterval = 2 * time.Minute
5764 snapshotFinalFlushTimeout = time.Second
5865 )
5966
@@ -82,7 +89,14 @@ func (d *Daemon) restoreSnapshots(ctx context.Context) {
8289 }
8390 }
8491 }
85- if !d.snapsEnabled || d.snaps == nil {
92+ if !d.snapsEnabled {
93+ return
94+ }
95+ if d.snapshotRepository != nil {
96+ d.restoreIncrementalSnapshots(ctx)
97+ return
98+ }
99+ if d.snaps == nil {
86100 return
87101 }
88102 blobs, err := d.snaps.Load()
@@ -139,7 +153,11 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
139153 }
140154 d.mu.Unlock()
141155
142- sctx, cancel := context.WithCancel(d.serveCtx)
156+ parent := d.serveCtx
157+ if parent == nil {
158+ parent = context.Background()
159+ }
160+ sctx, cancel := context.WithCancel(parent)
143161 opened := make([]*tab, 0, len(snap.Tabs))
144162 // Snapshot restore runs before any client Hello is available. Start panes
145163 // from the daemon environment captured at startup; the next attach replaces
@@ -318,7 +336,7 @@ func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
318336 }
319337 p.mu.Lock()
320338 defer p.mu.Unlock()
321- screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, snap.Tail)
339+ screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}, snap.SealedChunks, snap.Tail)
322340 if err != nil {
323341 return fmt.Errorf("snapshot history: %w", err)
324342 }
@@ -362,7 +380,7 @@ func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {
362380 }
363381
364382 func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool {
365- if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil || !sess.snapEligible.Load() {
383+ if d == nil || sess == nil || !d.snapsEnabled || (d.snapshotRepository == nil && d.snaps == nil) || !sess.snapEligible.Load() {
366384 return true
367385 }
368386 sess.snapshotMu.Lock()
@@ -496,10 +514,15 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh
496514 paneCapture := snapshotCapturePane{
497515 id: p.id,
498516 stableID: p.stableID,
499- history: p.history.SealAndView(),
500517 visible: visible,
501518 visibleErr: visibleErr,
502519 }
520+ if d.snapshotRepository != nil {
521+ paneCapture.sealed = p.history.SnapshotView()
522+ paneCapture.tail = paneCapture.sealed.Tail()
523+ } else {
524+ paneCapture.history = p.history.SealAndView()
525+ }
503526 p.mu.Unlock()
504527 paneCapture.cwd = fallbackCwd
505528 if d.procCwd != nil && pid > 0 {
@@ -601,9 +624,20 @@ func (d *Daemon) startSnapshotEncodeWorker() {
601624 d.finishSnapshotCapture(capture, false)
602625 return false
603626 }
604- data, err := d.encodeSnapshotCapture(capture)
605- if err == nil && workerCtx.Err() == nil {
606- err = d.snaps.Write(capture.name, data)
627+ var err error
628+ if d.snapshotRepository != nil {
629+ publication, publicationErr := d.incrementalPublication(capture)
630+ if publicationErr != nil {
631+ err = publicationErr
632+ } else if workerCtx.Err() == nil {
633+ err = d.snapshotRepository.Publish(workerCtx, publication)
634+ }
635+ } else {
636+ data, encodeErr := d.encodeSnapshotCapture(capture)
637+ err = encodeErr
638+ if err == nil && workerCtx.Err() == nil {
639+ err = d.snaps.Write(capture.name, data)
640+ }
607641 }
608642 d.clearSnapshotWorkerInFlight(workerID, capture)
609643 if err != nil && workerCtx.Err() == nil {
@@ -781,15 +815,7 @@ func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error)
781815 }
782816
783817 func (d *Daemon) snapshotSaver(ctx context.Context) {
784- t := d.clock.NewTimer(snapshotInterval)
785- defer t.Stop()
786- for {
787- select {
788- case <-ctx.Done():
789- return
790- case <-t.C():
791- }
792-
818+ checkpoint := func() {
793819 d.mu.Lock()
794820 sessions := d.sessionsSnapshotLocked()
795821 d.mu.Unlock()
@@ -798,7 +824,21 @@ func (d *Daemon) snapshotSaver(ctx context.Context) {
798824 d.scheduleSnapshot(sess)
799825 }
800826 }
801- t.Reset(snapshotInterval)
827+ }
828+ // A session born dirty must not wait through a full interval before its
829+ // first crash-safe checkpoint. Producers remain non-blocking because each
830+ // schedule call only attempts the bounded handoff.
831+ checkpoint()
832+ t := d.clock.NewTimer(snapshotInterval)
833+ defer t.Stop()
834+ for {
835+ select {
836+ case <-ctx.Done():
837+ return
838+ case <-t.C():
839+ checkpoint()
840+ t.Reset(snapshotInterval)
841+ }
802842 }
803843 }
804844
@@ -43,17 +43,24 @@ type snapshotCaptureTab struct {
43 }43 }
44 44
45 type snapshotCapturePane struct {45 type snapshotCapturePane struct {
46- id layout.PaneID46+ id layout.PaneID
47- stableID string47+ stableID string
48- cwd string48+ cwd string
49+ // history is retained for the v3 compatibility encoder. Incremental
50+ // captures keep sealed identities and a copied tail separately so capture
51+ // never rotates a live mutable tail merely to persist it.
49 history vt.HistoryView52 history vt.HistoryView
53+ sealed vt.HistorySnapshotView
54+ tail vt.HistoryView
50 visible []byte55 visible []byte
51 visibleErr error56 visibleErr error
52 process *snapcodec.Process57 process *snapcodec.Process
53 }58 }
54 59
55 const (60 const (
56- snapshotInterval = 30 * time.Second61+ // Routine checkpoints are rate limited from completion; forced teardown
62+ // checkpoints use the same bounded worker but never consume this window.
63+ snapshotInterval = 2 * time.Minute
57 snapshotFinalFlushTimeout = time.Second64 snapshotFinalFlushTimeout = time.Second
58 )65 )
59 66
@@ -82,7 +89,14 @@ func (d *Daemon) restoreSnapshots(ctx context.Context) {
82 }89 }
83 }90 }
84 }91 }
85- if !d.snapsEnabled || d.snaps == nil {92+ if !d.snapsEnabled {
93+ return
94+ }
95+ if d.snapshotRepository != nil {
96+ d.restoreIncrementalSnapshots(ctx)
97+ return
98+ }
99+ if d.snaps == nil {
86 return100 return
87 }101 }
88 blobs, err := d.snaps.Load()102 blobs, err := d.snaps.Load()
@@ -139,7 +153,11 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
139 }153 }
140 d.mu.Unlock()154 d.mu.Unlock()
141 155
142- sctx, cancel := context.WithCancel(d.serveCtx)156+ parent := d.serveCtx
157+ if parent == nil {
158+ parent = context.Background()
159+ }
160+ sctx, cancel := context.WithCancel(parent)
143 opened := make([]*tab, 0, len(snap.Tabs))161 opened := make([]*tab, 0, len(snap.Tabs))
144 // Snapshot restore runs before any client Hello is available. Start panes162 // Snapshot restore runs before any client Hello is available. Start panes
145 // from the daemon environment captured at startup; the next attach replaces163 // from the daemon environment captured at startup; the next attach replaces
@@ -318,7 +336,7 @@ func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
318 }336 }
319 p.mu.Lock()337 p.mu.Lock()
320 defer p.mu.Unlock()338 defer p.mu.Unlock()
321- screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows}, snap.SealedChunks, snap.Tail)339+ screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}, snap.SealedChunks, snap.Tail)
322 if err != nil {340 if err != nil {
323 return fmt.Errorf("snapshot history: %w", err)341 return fmt.Errorf("snapshot history: %w", err)
324 }342 }
@@ -362,7 +380,7 @@ func (d *Daemon) scheduleFinalSnapshot(sess *session) bool {
362 }380 }
363 381
364 func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool {382 func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bool {
365- if d == nil || sess == nil || !d.snapsEnabled || d.snaps == nil || !sess.snapEligible.Load() {383+ if d == nil || sess == nil || !d.snapsEnabled || (d.snapshotRepository == nil && d.snaps == nil) || !sess.snapEligible.Load() {
366 return true384 return true
367 }385 }
368 sess.snapshotMu.Lock()386 sess.snapshotMu.Lock()
@@ -496,10 +514,15 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh
496 paneCapture := snapshotCapturePane{514 paneCapture := snapshotCapturePane{
497 id: p.id,515 id: p.id,
498 stableID: p.stableID,516 stableID: p.stableID,
499- history: p.history.SealAndView(),
500 visible: visible,517 visible: visible,
501 visibleErr: visibleErr,518 visibleErr: visibleErr,
502 }519 }
520+ if d.snapshotRepository != nil {
521+ paneCapture.sealed = p.history.SnapshotView()
522+ paneCapture.tail = paneCapture.sealed.Tail()
523+ } else {
524+ paneCapture.history = p.history.SealAndView()
525+ }
503 p.mu.Unlock()526 p.mu.Unlock()
504 paneCapture.cwd = fallbackCwd527 paneCapture.cwd = fallbackCwd
505 if d.procCwd != nil && pid > 0 {528 if d.procCwd != nil && pid > 0 {
@@ -601,9 +624,20 @@ func (d *Daemon) startSnapshotEncodeWorker() {
601 d.finishSnapshotCapture(capture, false)624 d.finishSnapshotCapture(capture, false)
602 return false625 return false
603 }626 }
604- data, err := d.encodeSnapshotCapture(capture)627+ var err error
605- if err == nil && workerCtx.Err() == nil {628+ if d.snapshotRepository != nil {
606- err = d.snaps.Write(capture.name, data)629+ publication, publicationErr := d.incrementalPublication(capture)
630+ if publicationErr != nil {
631+ err = publicationErr
632+ } else if workerCtx.Err() == nil {
633+ err = d.snapshotRepository.Publish(workerCtx, publication)
634+ }
635+ } else {
636+ data, encodeErr := d.encodeSnapshotCapture(capture)
637+ err = encodeErr
638+ if err == nil && workerCtx.Err() == nil {
639+ err = d.snaps.Write(capture.name, data)
640+ }
607 }641 }
608 d.clearSnapshotWorkerInFlight(workerID, capture)642 d.clearSnapshotWorkerInFlight(workerID, capture)
609 if err != nil && workerCtx.Err() == nil {643 if err != nil && workerCtx.Err() == nil {
@@ -781,15 +815,7 @@ func (d *Daemon) capturePaneProcess(pty interface{ ForegroundPgid() (int, error)
781 }815 }
782 816
783 func (d *Daemon) snapshotSaver(ctx context.Context) {817 func (d *Daemon) snapshotSaver(ctx context.Context) {
784- t := d.clock.NewTimer(snapshotInterval)818+ checkpoint := func() {
785- defer t.Stop()
786- for {
787- select {
788- case <-ctx.Done():
789- return
790- case <-t.C():
791- }
792-
793 d.mu.Lock()819 d.mu.Lock()
794 sessions := d.sessionsSnapshotLocked()820 sessions := d.sessionsSnapshotLocked()
795 d.mu.Unlock()821 d.mu.Unlock()
@@ -798,7 +824,21 @@ func (d *Daemon) snapshotSaver(ctx context.Context) {
798 d.scheduleSnapshot(sess)824 d.scheduleSnapshot(sess)
799 }825 }
800 }826 }
801- t.Reset(snapshotInterval)827+ }
828+ // A session born dirty must not wait through a full interval before its
829+ // first crash-safe checkpoint. Producers remain non-blocking because each
830+ // schedule call only attempts the bounded handoff.
831+ checkpoint()
832+ t := d.clock.NewTimer(snapshotInterval)
833+ defer t.Stop()
834+ for {
835+ select {
836+ case <-ctx.Done():
837+ return
838+ case <-t.C():
839+ checkpoint()
840+ t.Reset(snapshotInterval)
841+ }
802 }842 }
803 }843 }
804 844

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close