detached ae5758e

fix(daemon): retain timed-out checkpoint state

commit
ae5758ef6b22e2dc482d1d4148e04419dc4c7254
author
brice · · 2026-07-22 00:35 UTC
parent
ffeecb7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +52 -11
M internal/usecase/daemon/session.go +11 -1
@@ -908,6 +908,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
908908 d.refreshSessionCwd(sess)
909909 }
910910 var terminalSnapshotErr error
911+ retainSnapshotRetry := false
911912 if d.snapsEnabled && !isEphemeral {
912913 if purge {
913914 if d.snapshotRepository != nil {
@@ -928,6 +929,10 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
928929 if reason != ports.ReasonServerShutdown {
929930 return terminalSnapshotErr
930931 }
932+ // Preserve any already-queued routine capture until the blocked
933+ // worker can safely discard it. This retains the dirty generation
934+ // and forced retry intent without adding another queue entry.
935+ retainSnapshotRetry = true
931936 d.persistShutdownSnapshotFailure(name, terminalSnapshotErr)
932937 }
933938 }
@@ -938,7 +943,12 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
938943 // budget expires, the worker is cancelled by its one final join instead;
939944 // keep the cache reachable because an uncooperative writer may still be
940945 // encoding immutable state.
941- quarantineDone := quarantineSnapshotCoordinator(sess)
946+ var quarantineDone <-chan struct{}
947+ if retainSnapshotRetry {
948+ quarantineDone = quarantineSnapshotCoordinatorRetainingQueuedCapture(sess)
949+ } else {
950+ quarantineDone = quarantineSnapshotCoordinator(sess)
951+ }
942952 joined := true
943953 if deadline != nil {
944954 select {
@@ -908,6 +908,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
908 d.refreshSessionCwd(sess)908 d.refreshSessionCwd(sess)
909 }909 }
910 var terminalSnapshotErr error910 var terminalSnapshotErr error
911+ retainSnapshotRetry := false
911 if d.snapsEnabled && !isEphemeral {912 if d.snapsEnabled && !isEphemeral {
912 if purge {913 if purge {
913 if d.snapshotRepository != nil {914 if d.snapshotRepository != nil {
@@ -928,6 +929,10 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
928 if reason != ports.ReasonServerShutdown {929 if reason != ports.ReasonServerShutdown {
929 return terminalSnapshotErr930 return terminalSnapshotErr
930 }931 }
932+ // Preserve any already-queued routine capture until the blocked
933+ // worker can safely discard it. This retains the dirty generation
934+ // and forced retry intent without adding another queue entry.
935+ retainSnapshotRetry = true
931 d.persistShutdownSnapshotFailure(name, terminalSnapshotErr)936 d.persistShutdownSnapshotFailure(name, terminalSnapshotErr)
932 }937 }
933 }938 }
@@ -938,7 +943,12 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
938 // budget expires, the worker is cancelled by its one final join instead;943 // budget expires, the worker is cancelled by its one final join instead;
939 // keep the cache reachable because an uncooperative writer may still be944 // keep the cache reachable because an uncooperative writer may still be
940 // encoding immutable state.945 // encoding immutable state.
941- quarantineDone := quarantineSnapshotCoordinator(sess)946+ var quarantineDone <-chan struct{}
947+ if retainSnapshotRetry {
948+ quarantineDone = quarantineSnapshotCoordinatorRetainingQueuedCapture(sess)
949+ } else {
950+ quarantineDone = quarantineSnapshotCoordinator(sess)
951+ }
942 joined := true952 joined := true
943 if deadline != nil {953 if deadline != nil {
944 select {954 select {
M internal/usecase/daemon/snapshot.go +17 -4
@@ -329,7 +329,20 @@ func quarantineSnapshotCoordinator(sess *session) <-chan struct{} {
329329 return quarantineSnapshotCoordinatorWithEpoch(sess).done
330330 }
331331
332+// quarantineSnapshotCoordinatorRetainingQueuedCapture is used after a forced
333+// shutdown checkpoint times out. Its queued capture is logical retry state, not
334+// a repository operation: startSnapshotPublication rejects it while
335+// quarantined, and the worker eventually discards it after its blocked call
336+// returns.
337+func quarantineSnapshotCoordinatorRetainingQueuedCapture(sess *session) <-chan struct{} {
338+ return quarantineSnapshotCoordinatorWithOptions(sess, true).done
339+}
340+
332341 func quarantineSnapshotCoordinatorWithEpoch(sess *session) snapshotCoordinatorQuarantine {
342+ return quarantineSnapshotCoordinatorWithOptions(sess, false)
343+}
344+
345+func quarantineSnapshotCoordinatorWithOptions(sess *session, retainQueuedCapture bool) snapshotCoordinatorQuarantine {
333346 if sess == nil {
334347 done := make(chan struct{})
335348 close(done)
@@ -343,10 +356,10 @@ func quarantineSnapshotCoordinatorWithEpoch(sess *session) snapshotCoordinatorQu
343356 if sess.snapshotPublicationCancel != nil {
344357 sess.snapshotPublicationCancel()
345358 }
346- // Remove a not-yet-started capture from the coordinator immediately. The
347- // global queue may still contain it, but startSnapshotPublication compares
348- // identity and will discard it even if this session is later renamed.
349- if sess.snapshotQueuedCapture != nil {
359+ if sess.snapshotQueuedCapture != nil && !retainQueuedCapture {
360+ // A rename or purge must discard a capture under the old identity. The
361+ // global queue may still contain it, but startSnapshotPublication
362+ // compares identity and will discard it even if this session is renamed.
350363 sess.snapshotQueuedCapture.coordinatorDiscarded = true
351364 sess.snapshotQueuedCapture = nil
352365 if sess.snapshotPendingCaptures > 0 {
@@ -329,7 +329,20 @@ func quarantineSnapshotCoordinator(sess *session) <-chan struct{} {
329 return quarantineSnapshotCoordinatorWithEpoch(sess).done329 return quarantineSnapshotCoordinatorWithEpoch(sess).done
330 }330 }
331 331
332+// quarantineSnapshotCoordinatorRetainingQueuedCapture is used after a forced
333+// shutdown checkpoint times out. Its queued capture is logical retry state, not
334+// a repository operation: startSnapshotPublication rejects it while
335+// quarantined, and the worker eventually discards it after its blocked call
336+// returns.
337+func quarantineSnapshotCoordinatorRetainingQueuedCapture(sess *session) <-chan struct{} {
338+ return quarantineSnapshotCoordinatorWithOptions(sess, true).done
339+}
340+
332 func quarantineSnapshotCoordinatorWithEpoch(sess *session) snapshotCoordinatorQuarantine {341 func quarantineSnapshotCoordinatorWithEpoch(sess *session) snapshotCoordinatorQuarantine {
342+ return quarantineSnapshotCoordinatorWithOptions(sess, false)
343+}
344+
345+func quarantineSnapshotCoordinatorWithOptions(sess *session, retainQueuedCapture bool) snapshotCoordinatorQuarantine {
333 if sess == nil {346 if sess == nil {
334 done := make(chan struct{})347 done := make(chan struct{})
335 close(done)348 close(done)
@@ -343,10 +356,10 @@ func quarantineSnapshotCoordinatorWithEpoch(sess *session) snapshotCoordinatorQu
343 if sess.snapshotPublicationCancel != nil {356 if sess.snapshotPublicationCancel != nil {
344 sess.snapshotPublicationCancel()357 sess.snapshotPublicationCancel()
345 }358 }
346- // Remove a not-yet-started capture from the coordinator immediately. The359+ if sess.snapshotQueuedCapture != nil && !retainQueuedCapture {
347- // global queue may still contain it, but startSnapshotPublication compares360+ // A rename or purge must discard a capture under the old identity. The
348- // identity and will discard it even if this session is later renamed.361+ // global queue may still contain it, but startSnapshotPublication
349- if sess.snapshotQueuedCapture != nil {362+ // compares identity and will discard it even if this session is renamed.
350 sess.snapshotQueuedCapture.coordinatorDiscarded = true363 sess.snapshotQueuedCapture.coordinatorDiscarded = true
351 sess.snapshotQueuedCapture = nil364 sess.snapshotQueuedCapture = nil
352 if sess.snapshotPendingCaptures > 0 {365 if sess.snapshotPendingCaptures > 0 {
M internal/usecase/daemon/snapshot_scheduler_test.go +24 -6
@@ -329,7 +329,11 @@ func TestForcedSnapshotShutdownTimeoutRetainsRetryableStateAndNotice(t *testing.
329329
330330 blockerStarted := make(chan struct{})
331331 releaseBlocker := make(chan struct{})
332- defer close(releaseBlocker)
332+ var releaseBlockerOnce sync.Once
333+ release := func() { releaseBlockerOnce.Do(func() { close(releaseBlocker) }) }
334+ // Register this after newTestDaemon's worker cleanup so the uncooperative
335+ // repository call is always released before cleanup asks the worker to join.
336+ t.Cleanup(release)
333337 repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
334338 if publication.Name == "blocker" {
335339 close(blockerStarted)
@@ -363,14 +367,28 @@ func TestForcedSnapshotShutdownTimeoutRetainsRetryableStateAndNotice(t *testing.
363367 timer.fire()
364368 require.Error(t, <-killed)
365369
366- sess.snapshotMu.Lock()
367- require.True(t, sess.snapDirty.Load())
368- require.Greater(t, sess.snapshotForcedGeneration, sess.snapshotPublishedGeneration)
369- require.NotNil(t, sess.snapshotQueuedCapture, "the one routine capture remains retryable")
370- sess.snapshotMu.Unlock()
370+ func() {
371+ sess.snapshotMu.Lock()
372+ defer sess.snapshotMu.Unlock()
373+ require.True(t, sess.snapDirty.Load())
374+ require.Greater(t, sess.snapshotForcedGeneration, sess.snapshotPublishedGeneration)
375+ require.True(t, sess.snapshotPending)
376+ require.Equal(t, uint(1), sess.snapshotPendingCaptures)
377+ require.NotNil(t, sess.snapshotQueuedCapture, "the one routine capture remains retryable")
378+ require.Nil(t, sess.snapshotInFlightCapture)
379+ }()
371380 d.snapshotWorkerMu.Lock()
372381 require.LessOrEqual(t, len(d.snapshotJobs), snapshotQueueCapacity)
373382 d.snapshotWorkerMu.Unlock()
383+
384+ // The retained capture is not published after quarantine, but releasing the
385+ // unrelated blocked call lets the worker discard it and lets cleanup join.
386+ release()
387+ require.Eventually(t, func() bool {
388+ sess.snapshotMu.Lock()
389+ defer sess.snapshotMu.Unlock()
390+ return !sess.snapshotPending && sess.snapshotQueuedCapture == nil
391+ }, testWaitTimeout, time.Millisecond)
374392 }
375393
376394 func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) {
@@ -329,7 +329,11 @@ func TestForcedSnapshotShutdownTimeoutRetainsRetryableStateAndNotice(t *testing.
329 329
330 blockerStarted := make(chan struct{})330 blockerStarted := make(chan struct{})
331 releaseBlocker := make(chan struct{})331 releaseBlocker := make(chan struct{})
332- defer close(releaseBlocker)332+ var releaseBlockerOnce sync.Once
333+ release := func() { releaseBlockerOnce.Do(func() { close(releaseBlocker) }) }
334+ // Register this after newTestDaemon's worker cleanup so the uncooperative
335+ // repository call is always released before cleanup asks the worker to join.
336+ t.Cleanup(release)
333 repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {337 repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
334 if publication.Name == "blocker" {338 if publication.Name == "blocker" {
335 close(blockerStarted)339 close(blockerStarted)
@@ -363,14 +367,28 @@ func TestForcedSnapshotShutdownTimeoutRetainsRetryableStateAndNotice(t *testing.
363 timer.fire()367 timer.fire()
364 require.Error(t, <-killed)368 require.Error(t, <-killed)
365 369
366- sess.snapshotMu.Lock()370+ func() {
367- require.True(t, sess.snapDirty.Load())371+ sess.snapshotMu.Lock()
368- require.Greater(t, sess.snapshotForcedGeneration, sess.snapshotPublishedGeneration)372+ defer sess.snapshotMu.Unlock()
369- require.NotNil(t, sess.snapshotQueuedCapture, "the one routine capture remains retryable")373+ require.True(t, sess.snapDirty.Load())
370- sess.snapshotMu.Unlock()374+ require.Greater(t, sess.snapshotForcedGeneration, sess.snapshotPublishedGeneration)
375+ require.True(t, sess.snapshotPending)
376+ require.Equal(t, uint(1), sess.snapshotPendingCaptures)
377+ require.NotNil(t, sess.snapshotQueuedCapture, "the one routine capture remains retryable")
378+ require.Nil(t, sess.snapshotInFlightCapture)
379+ }()
371 d.snapshotWorkerMu.Lock()380 d.snapshotWorkerMu.Lock()
372 require.LessOrEqual(t, len(d.snapshotJobs), snapshotQueueCapacity)381 require.LessOrEqual(t, len(d.snapshotJobs), snapshotQueueCapacity)
373 d.snapshotWorkerMu.Unlock()382 d.snapshotWorkerMu.Unlock()
383+
384+ // The retained capture is not published after quarantine, but releasing the
385+ // unrelated blocked call lets the worker discard it and lets cleanup join.
386+ release()
387+ require.Eventually(t, func() bool {
388+ sess.snapshotMu.Lock()
389+ defer sess.snapshotMu.Unlock()
390+ return !sess.snapshotPending && sess.snapshotQueuedCapture == nil
391+ }, testWaitTimeout, time.Millisecond)
374 }392 }
375 393
376 func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) {394 func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) {

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