Skip to content

Commit 6a334d2

Browse files
committed
fix(telemetry): resolve shutdown marker after async-op drain (Spec 080, review round 3)
1 parent c97bf84 commit 6a334d2

5 files changed

Lines changed: 149 additions & 20 deletions

File tree

internal/runtime/prechurn_lifecycle_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,42 @@ func TestRuntimeCloseCleanupBranchStillResolvesAndClosesStorage(t *testing.T) {
138138
t.Fatalf("close 2: %v", err)
139139
}
140140
}
141+
142+
// TestRuntimeCloseAfterExternalStopAsyncStillResolvesClean guards the split
143+
// Close sequence (Spec 080 FR-010, review round 3): Close now runs
144+
// storage.StopAsync (stop + drain queued async DB ops) BEFORE resolving the
145+
// shutdown marker, then closes the DB — so the marker is truly the last DB
146+
// write. Driving StopAsync externally first exercises the double-stop path
147+
// (StopAsync inside Close, then again inside storageManager.Close): it must
148+
// not panic, and the marker must still resolve to clean.
149+
func TestRuntimeCloseAfterExternalStopAsyncStillResolvesClean(t *testing.T) {
150+
t.Setenv("MCPPROXY_LAUNCHED_BY", "")
151+
dataDir := t.TempDir()
152+
153+
rt, _ := previousShutdownVia(t, dataDir)
154+
db := rt.StorageManager().GetDB()
155+
if db == nil {
156+
t.Fatal("precondition: storage DB must be open")
157+
}
158+
159+
// Worst-case double stop: the async manager is already stopped and
160+
// drained before Close runs the same sequence again.
161+
rt.StorageManager().StopAsync()
162+
if err := rt.Close(); err != nil {
163+
t.Fatalf("close after external StopAsync: %v", err)
164+
}
165+
166+
// Storage fully closed…
167+
if err := db.View(func(*bbolt.Tx) error { return nil }); !errors.Is(err, berrors.ErrDatabaseNotOpen) {
168+
t.Fatalf("expected storage closed after Close, View err = %v", err)
169+
}
170+
171+
// …and the marker resolved: the next instance reports a clean shutdown.
172+
rt2, prev := previousShutdownVia(t, dataDir)
173+
if prev != "clean" {
174+
t.Fatalf("after Close with external StopAsync: expected clean, got %q", prev)
175+
}
176+
if err := rt2.Close(); err != nil {
177+
t.Fatalf("close 2: %v", err)
178+
}
179+
}

internal/runtime/runtime.go

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -654,27 +654,36 @@ func (r *Runtime) Close() error {
654654
}
655655

656656
// Spec 080 (US3, FR-010): resolve the shutdown marker to "clean" at the
657-
// LAST point the DB is still open — after cache/index cleanup, immediately
658-
// before storage closes (cache.Close only stops a goroutine and index.Close
659-
// only touches Bleve; neither closes the BBolt DB). Every branch above —
660-
// including the container-cleanup verification, whose early exits live
661-
// inside verifyContainerCleanup — reaches this point, so a graceful Close
662-
// always resolves; conversely a hang/SIGKILL/panic ANYWHERE earlier in
663-
// shutdown leaves the marker armed and the next instance honestly reports
664-
// previous_shutdown="crash". Idempotent on double Close; on an
665-
// already-closed DB the write fails harmlessly (logged at debug) and the
666-
// marker is left untouched.
667-
if r.prechurnStore != nil && r.storageManager != nil {
668-
if db := r.storageManager.GetDB(); db != nil {
669-
if err := r.prechurnStore.ResolveCleanShutdown(db); err != nil {
670-
if r.logger != nil {
671-
r.logger.Debug("Failed to resolve shutdown marker to clean", zap.Error(err))
657+
// LAST point the DB is still open — i.e. after the async storage manager
658+
// has stopped AND drained its queue (those queued operations perform BBolt
659+
// writes; StopAsync below runs that drain), immediately before the BBolt
660+
// handle closes. Every branch above — including the container-cleanup
661+
// verification, whose early exits live inside verifyContainerCleanup —
662+
// reaches this point, so a graceful Close always resolves; conversely a
663+
// hang/SIGKILL/panic ANYWHERE earlier in shutdown — including mid-drain —
664+
// leaves the marker armed and the next instance honestly reports
665+
// previous_shutdown="crash". Idempotent on double Close (StopAsync no-ops,
666+
// and storageManager.Close's internal async stop no-ops after StopAsync);
667+
// on an already-closed DB the marker write fails harmlessly (logged at
668+
// debug) and the marker is left untouched.
669+
if r.storageManager != nil {
670+
// (1) Stop + drain queued async DB operations — the last DB writes
671+
// other than the marker resolve itself.
672+
r.storageManager.StopAsync()
673+
674+
// (2) Resolve the marker to clean, now that no other DB work remains.
675+
if r.prechurnStore != nil {
676+
if db := r.storageManager.GetDB(); db != nil {
677+
if err := r.prechurnStore.ResolveCleanShutdown(db); err != nil {
678+
if r.logger != nil {
679+
r.logger.Debug("Failed to resolve shutdown marker to clean", zap.Error(err))
680+
}
672681
}
673682
}
674683
}
675-
}
676684

677-
if r.storageManager != nil {
685+
// (3) Close the BBolt handle; the async stop inside Close is a no-op,
686+
// so no DB work intervenes between the marker resolve and db.Close.
678687
if err := r.storageManager.Close(); err != nil {
679688
errs = append(errs, fmt.Errorf("close storage manager: %w", err))
680689
}

internal/storage/async_ops.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type AsyncManager struct {
3030
opQueue chan Operation
3131
ctx context.Context
3232
cancel context.CancelFunc
33+
stateMu sync.Mutex // guards started; makes Start/Stop idempotent
3334
started bool
3435
wg sync.WaitGroup
3536
}
@@ -48,6 +49,9 @@ func NewAsyncManager(db *BoltDB, logger *zap.SugaredLogger) *AsyncManager {
4849

4950
// Start begins processing storage operations in a dedicated goroutine
5051
func (am *AsyncManager) Start() {
52+
am.stateMu.Lock()
53+
defer am.stateMu.Unlock()
54+
5155
if am.started {
5256
return
5357
}
@@ -56,13 +60,18 @@ func (am *AsyncManager) Start() {
5660
go am.processOperations()
5761
}
5862

59-
// Stop gracefully shuts down the async manager
63+
// Stop gracefully shuts down the async manager, draining any queued
64+
// operations. Idempotent: subsequent calls are no-ops (no double-cancel,
65+
// no DB work), so Manager.StopAsync followed by Manager.Close is safe.
6066
func (am *AsyncManager) Stop() {
67+
am.stateMu.Lock()
68+
defer am.stateMu.Unlock()
69+
6170
if !am.started {
6271
return
6372
}
6473
am.cancel()
65-
am.wg.Wait() // Wait for the goroutine to finish
74+
am.wg.Wait() // Wait for the goroutine to finish (drains the queue)
6675
am.started = false
6776
}
6877

internal/storage/async_ops_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,3 +399,56 @@ func TestSaveServerSyncFieldCoverage(t *testing.T) {
399399

400400
t.Logf("ServerConfig has %d fields, all mapped to UpstreamRecord", serverConfigType.NumField())
401401
}
402+
403+
// TestManagerStopAsyncDrainsThenCloseIsIdempotent guards the split shutdown
404+
// sequence behind Spec 080 FR-010: StopAsync must stop the async manager AND
405+
// drain queued operations to the DB (so a caller can perform a final write —
406+
// the telemetry shutdown marker — strictly after all async DB work), and the
407+
// subsequent Close must not re-run the drain, double-cancel, or panic. A
408+
// StopAsync after Close must also be a harmless no-op.
409+
func TestManagerStopAsyncDrainsThenCloseIsIdempotent(t *testing.T) {
410+
logger := zaptest.NewLogger(t).Sugar()
411+
manager, err := NewManager(t.TempDir(), logger)
412+
if err != nil {
413+
t.Fatalf("NewManager: %v", err)
414+
}
415+
416+
// Seed a record, then queue an async write against it.
417+
if err := manager.SaveUpstreamServer(&config.ServerConfig{
418+
Name: "drain-test",
419+
Protocol: "stdio",
420+
Command: "true",
421+
Enabled: false,
422+
}); err != nil {
423+
t.Fatalf("SaveUpstreamServer: %v", err)
424+
}
425+
manager.asyncMgr.EnableServerAsync("drain-test", true)
426+
427+
// StopAsync must flush the queued write before returning.
428+
manager.StopAsync()
429+
got, err := manager.GetUpstreamServer("drain-test")
430+
if err != nil {
431+
t.Fatalf("GetUpstreamServer after StopAsync: %v", err)
432+
}
433+
if !got.Enabled {
434+
t.Fatal("queued async write not drained by StopAsync")
435+
}
436+
437+
// The DB is still open between StopAsync and Close — this is the window
438+
// where the shutdown marker is resolved.
439+
if manager.GetDB() == nil {
440+
t.Fatal("DB must remain open after StopAsync")
441+
}
442+
443+
// Double StopAsync, then Close (whose internal async stop must no-op).
444+
manager.StopAsync()
445+
if err := manager.Close(); err != nil {
446+
t.Fatalf("Close after StopAsync: %v", err)
447+
}
448+
449+
// StopAsync and Close after Close must not panic.
450+
manager.StopAsync()
451+
if err := manager.Close(); err != nil {
452+
t.Fatalf("second Close: %v", err)
453+
}
454+
}

internal/storage/manager.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,26 @@ func NewManager(dataDir string, logger *zap.SugaredLogger) (*Manager, error) {
4343
}, nil
4444
}
4545

46-
// Close closes the storage manager
46+
// StopAsync stops the async operation manager, draining any queued
47+
// operations to the database. It is idempotent: a second call (including
48+
// the one inside Close) is a no-op, so callers may StopAsync then Close.
49+
//
50+
// Use this when a final DB write must happen strictly AFTER all queued
51+
// async operations have flushed but BEFORE the DB handle closes — e.g.
52+
// the telemetry shutdown marker (Spec 080 FR-010): StopAsync, write the
53+
// marker, then Close, which then closes the DB with no intervening work.
54+
func (m *Manager) StopAsync() {
55+
m.mu.Lock()
56+
defer m.mu.Unlock()
57+
58+
if m.asyncMgr != nil {
59+
m.asyncMgr.Stop()
60+
}
61+
}
62+
63+
// Close closes the storage manager: it stops the async manager (draining
64+
// queued operations to the DB — a no-op if StopAsync already ran), then
65+
// closes the underlying BBolt database.
4766
func (m *Manager) Close() error {
4867
m.mu.Lock()
4968
defer m.mu.Unlock()

0 commit comments

Comments
 (0)