detached 836c233

fix(kv): abort fenced catalogue writes safely

commit
836c233bdceb1a2891337972f2faaf704096c365
author
brice · · 2026-07-26 13:17 UTC
parent
e5d0dcf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +111 -22
M internal/persist/persist.go +8 -14
@@ -256,14 +256,12 @@ func (p *Persister) applyLocked(records map[string]*domain.CatalogueRecord, dura
256256 err = p.store.Set(change.key, change.value)
257257 }
258258 if err != nil {
259- p.restoreLocked(previous)
260- return p.fenceLocked(err)
259+ return p.fenceLocked(errors.Join(err, p.restoreLocked(previous)))
261260 }
262261 }
263262 if durable {
264263 if err := p.store.Sync(); err != nil {
265- p.restoreLocked(previous)
266- return p.fenceLocked(err)
264+ return p.fenceLocked(errors.Join(err, p.restoreLocked(previous)))
267265 }
268266 }
269267 for name := range records {
@@ -288,14 +286,16 @@ type storedValue struct {
288286
289287 // restoreLocked restores only keys affected by the rejected identity mutation.
290288 // It intentionally does not Sync: the Persister is fenced immediately after.
291-func (p *Persister) restoreLocked(previous map[string]storedValue) {
289+func (p *Persister) restoreLocked(previous map[string]storedValue) error {
290+ var err error
292291 for key, old := range previous {
293292 if old.exists {
294- _ = p.store.Set([]byte(key), old.value)
293+ err = errors.Join(err, p.store.Set([]byte(key), old.value))
295294 } else {
296- _ = p.store.Delete([]byte(key))
295+ err = errors.Join(err, p.store.Delete([]byte(key)))
297296 }
298297 }
298+ return err
299299 }
300300
301301 func (p *Persister) fenceLocked(cause error) error {
@@ -445,13 +445,7 @@ func (p *Persister) Close() error {
445445 p.mu.Lock()
446446 defer p.mu.Unlock()
447447 if err := p.terminalLocked(); err != nil {
448- if store, ok := p.store.(interface{ CloseWithoutSync() error }); ok {
449- if closeErr := store.CloseWithoutSync(); closeErr != nil {
450- return errors.Join(err, closeErr)
451- }
452- return err
453- }
454- if closeErr := p.store.Close(); closeErr != nil {
448+ if closeErr := p.store.CloseWithoutSync(); closeErr != nil {
455449 return errors.Join(err, closeErr)
456450 }
457451 return err
@@ -256,14 +256,12 @@ func (p *Persister) applyLocked(records map[string]*domain.CatalogueRecord, dura
256 err = p.store.Set(change.key, change.value)256 err = p.store.Set(change.key, change.value)
257 }257 }
258 if err != nil {258 if err != nil {
259- p.restoreLocked(previous)259+ return p.fenceLocked(errors.Join(err, p.restoreLocked(previous)))
260- return p.fenceLocked(err)
261 }260 }
262 }261 }
263 if durable {262 if durable {
264 if err := p.store.Sync(); err != nil {263 if err := p.store.Sync(); err != nil {
265- p.restoreLocked(previous)264+ return p.fenceLocked(errors.Join(err, p.restoreLocked(previous)))
266- return p.fenceLocked(err)
267 }265 }
268 }266 }
269 for name := range records {267 for name := range records {
@@ -288,14 +286,16 @@ type storedValue struct {
288 286
289 // restoreLocked restores only keys affected by the rejected identity mutation.287 // restoreLocked restores only keys affected by the rejected identity mutation.
290 // It intentionally does not Sync: the Persister is fenced immediately after.288 // It intentionally does not Sync: the Persister is fenced immediately after.
291-func (p *Persister) restoreLocked(previous map[string]storedValue) {289+func (p *Persister) restoreLocked(previous map[string]storedValue) error {
290+ var err error
292 for key, old := range previous {291 for key, old := range previous {
293 if old.exists {292 if old.exists {
294- _ = p.store.Set([]byte(key), old.value)293+ err = errors.Join(err, p.store.Set([]byte(key), old.value))
295 } else {294 } else {
296- _ = p.store.Delete([]byte(key))295+ err = errors.Join(err, p.store.Delete([]byte(key)))
297 }296 }
298 }297 }
298+ return err
299 }299 }
300 300
301 func (p *Persister) fenceLocked(cause error) error {301 func (p *Persister) fenceLocked(cause error) error {
@@ -445,13 +445,7 @@ func (p *Persister) Close() error {
445 p.mu.Lock()445 p.mu.Lock()
446 defer p.mu.Unlock()446 defer p.mu.Unlock()
447 if err := p.terminalLocked(); err != nil {447 if err := p.terminalLocked(); err != nil {
448- if store, ok := p.store.(interface{ CloseWithoutSync() error }); ok {448+ if closeErr := p.store.CloseWithoutSync(); closeErr != nil {
449- if closeErr := store.CloseWithoutSync(); closeErr != nil {
450- return errors.Join(err, closeErr)
451- }
452- return err
453- }
454- if closeErr := p.store.Close(); closeErr != nil {
455 return errors.Join(err, closeErr)449 return errors.Join(err, closeErr)
456 }450 }
457 return err451 return err
M internal/persist/persist_test.go +51 -5
@@ -216,17 +216,26 @@ type failClosedStore struct {
216216 data map[string][]byte
217217 durable map[string][]byte
218218
219- setFailures int
220- deleteFailures int
221- syncFailures int
222- failure error
219+ setFailures int
220+ deleteFailures int
221+ syncFailures int
222+ restoreSetFailures int
223+ restoreDeleteFailures int
224+ restoring bool
225+ failure error
226+ restoreFailure error
223227
224228 closeFlushed bool
225229 closeAborted bool
226230 }
227231
228232 func newFailClosedStore(data map[string][]byte) *failClosedStore {
229- return &failClosedStore{data: copyStoreData(data), durable: copyStoreData(data), failure: errors.New("store write failed")}
233+ return &failClosedStore{
234+ data: copyStoreData(data),
235+ durable: copyStoreData(data),
236+ failure: errors.New("store write failed"),
237+ restoreFailure: errors.New("store restore failed"),
238+ }
230239 }
231240
232241 func (s *failClosedStore) Get(key []byte) ([]byte, bool) {
@@ -236,6 +245,10 @@ func (s *failClosedStore) Get(key []byte) ([]byte, bool) {
236245
237246 func (s *failClosedStore) Set(key, value []byte) error {
238247 s.data[string(key)] = append([]byte(nil), value...)
248+ if s.restoring && s.restoreSetFailures > 0 {
249+ s.restoreSetFailures--
250+ return s.restoreFailure
251+ }
239252 if s.setFailures > 0 {
240253 s.setFailures--
241254 return s.failure
@@ -245,6 +258,10 @@ func (s *failClosedStore) Set(key, value []byte) error {
245258
246259 func (s *failClosedStore) Delete(key []byte) error {
247260 delete(s.data, string(key))
261+ if s.restoring && s.restoreDeleteFailures > 0 {
262+ s.restoreDeleteFailures--
263+ return s.restoreFailure
264+ }
248265 if s.deleteFailures > 0 {
249266 s.deleteFailures--
250267 return s.failure
@@ -263,6 +280,7 @@ func (s *failClosedStore) Range(fn func([]byte, []byte) bool) {
263280 func (s *failClosedStore) Sync() error {
264281 if s.syncFailures > 0 {
265282 s.syncFailures--
283+ s.restoring = true
266284 return s.failure
267285 }
268286 s.durable = copyStoreData(s.data)
@@ -411,6 +429,34 @@ func TestRejectedIdentityWriteFencesCatalogue(t *testing.T) {
411429 }
412430 }
413431
432+func TestFailedSyncRetainsRepeatedRollbackFailuresAndAborts(t *testing.T) {
433+ t.Parallel()
434+ encoded, err := encodeRecordValue(validRecord("work", 1))
435+ require.NoError(t, err)
436+ store := newFailClosedStore(map[string][]byte{"work": encoded})
437+ store.syncFailures = 1
438+ store.restoreSetFailures = 1
439+ store.restoreDeleteFailures = 1
440+ p := New(store)
441+
442+ err = p.Rename("work", validRecord("renamed", 1))
443+ require.ErrorIs(t, err, ErrCatalogueDurability)
444+ require.ErrorIs(t, err, store.failure)
445+ require.ErrorIs(t, err, store.restoreFailure)
446+ require.Zero(t, store.restoreSetFailures, "rollback Set failure must be observed")
447+ require.Zero(t, store.restoreDeleteFailures, "rollback Delete failure must be observed")
448+
449+ terminal := p.Sync()
450+ require.Same(t, err, terminal, "the fenced terminal error must remain stable")
451+ require.ErrorIs(t, terminal, store.failure)
452+ require.ErrorIs(t, terminal, store.restoreFailure)
453+ require.ErrorIs(t, p.Close(), ErrCatalogueDurability)
454+ require.True(t, store.closeAborted)
455+ require.False(t, store.closeFlushed)
456+ require.Equal(t, map[string][]byte{"work": encoded}, store.durable,
457+ "aborting a fenced store must not persist dirty rejected state")
458+}
459+
414460 func TestRejectedIdentityWriteRestoresBufferedMetadata(t *testing.T) {
415461 t.Parallel()
416462 encoded, err := encodeRecordValue(validRecord("work", 1))
@@ -216,17 +216,26 @@ type failClosedStore struct {
216 data map[string][]byte216 data map[string][]byte
217 durable map[string][]byte217 durable map[string][]byte
218 218
219- setFailures int219+ setFailures int
220- deleteFailures int220+ deleteFailures int
221- syncFailures int221+ syncFailures int
222- failure error222+ restoreSetFailures int
223+ restoreDeleteFailures int
224+ restoring bool
225+ failure error
226+ restoreFailure error
223 227
224 closeFlushed bool228 closeFlushed bool
225 closeAborted bool229 closeAborted bool
226 }230 }
227 231
228 func newFailClosedStore(data map[string][]byte) *failClosedStore {232 func newFailClosedStore(data map[string][]byte) *failClosedStore {
229- return &failClosedStore{data: copyStoreData(data), durable: copyStoreData(data), failure: errors.New("store write failed")}233+ return &failClosedStore{
234+ data: copyStoreData(data),
235+ durable: copyStoreData(data),
236+ failure: errors.New("store write failed"),
237+ restoreFailure: errors.New("store restore failed"),
238+ }
230 }239 }
231 240
232 func (s *failClosedStore) Get(key []byte) ([]byte, bool) {241 func (s *failClosedStore) Get(key []byte) ([]byte, bool) {
@@ -236,6 +245,10 @@ func (s *failClosedStore) Get(key []byte) ([]byte, bool) {
236 245
237 func (s *failClosedStore) Set(key, value []byte) error {246 func (s *failClosedStore) Set(key, value []byte) error {
238 s.data[string(key)] = append([]byte(nil), value...)247 s.data[string(key)] = append([]byte(nil), value...)
248+ if s.restoring && s.restoreSetFailures > 0 {
249+ s.restoreSetFailures--
250+ return s.restoreFailure
251+ }
239 if s.setFailures > 0 {252 if s.setFailures > 0 {
240 s.setFailures--253 s.setFailures--
241 return s.failure254 return s.failure
@@ -245,6 +258,10 @@ func (s *failClosedStore) Set(key, value []byte) error {
245 258
246 func (s *failClosedStore) Delete(key []byte) error {259 func (s *failClosedStore) Delete(key []byte) error {
247 delete(s.data, string(key))260 delete(s.data, string(key))
261+ if s.restoring && s.restoreDeleteFailures > 0 {
262+ s.restoreDeleteFailures--
263+ return s.restoreFailure
264+ }
248 if s.deleteFailures > 0 {265 if s.deleteFailures > 0 {
249 s.deleteFailures--266 s.deleteFailures--
250 return s.failure267 return s.failure
@@ -263,6 +280,7 @@ func (s *failClosedStore) Range(fn func([]byte, []byte) bool) {
263 func (s *failClosedStore) Sync() error {280 func (s *failClosedStore) Sync() error {
264 if s.syncFailures > 0 {281 if s.syncFailures > 0 {
265 s.syncFailures--282 s.syncFailures--
283+ s.restoring = true
266 return s.failure284 return s.failure
267 }285 }
268 s.durable = copyStoreData(s.data)286 s.durable = copyStoreData(s.data)
@@ -411,6 +429,34 @@ func TestRejectedIdentityWriteFencesCatalogue(t *testing.T) {
411 }429 }
412 }430 }
413 431
432+func TestFailedSyncRetainsRepeatedRollbackFailuresAndAborts(t *testing.T) {
433+ t.Parallel()
434+ encoded, err := encodeRecordValue(validRecord("work", 1))
435+ require.NoError(t, err)
436+ store := newFailClosedStore(map[string][]byte{"work": encoded})
437+ store.syncFailures = 1
438+ store.restoreSetFailures = 1
439+ store.restoreDeleteFailures = 1
440+ p := New(store)
441+
442+ err = p.Rename("work", validRecord("renamed", 1))
443+ require.ErrorIs(t, err, ErrCatalogueDurability)
444+ require.ErrorIs(t, err, store.failure)
445+ require.ErrorIs(t, err, store.restoreFailure)
446+ require.Zero(t, store.restoreSetFailures, "rollback Set failure must be observed")
447+ require.Zero(t, store.restoreDeleteFailures, "rollback Delete failure must be observed")
448+
449+ terminal := p.Sync()
450+ require.Same(t, err, terminal, "the fenced terminal error must remain stable")
451+ require.ErrorIs(t, terminal, store.failure)
452+ require.ErrorIs(t, terminal, store.restoreFailure)
453+ require.ErrorIs(t, p.Close(), ErrCatalogueDurability)
454+ require.True(t, store.closeAborted)
455+ require.False(t, store.closeFlushed)
456+ require.Equal(t, map[string][]byte{"work": encoded}, store.durable,
457+ "aborting a fenced store must not persist dirty rejected state")
458+}
459+
414 func TestRejectedIdentityWriteRestoresBufferedMetadata(t *testing.T) {460 func TestRejectedIdentityWriteRestoresBufferedMetadata(t *testing.T) {
415 t.Parallel()461 t.Parallel()
416 encoded, err := encodeRecordValue(validRecord("work", 1))462 encoded, err := encodeRecordValue(validRecord("work", 1))
M internal/ports/mocks/store_mock.go +44 -0
@@ -79,6 +79,50 @@ func (_c *MockStore_Close_Call) RunAndReturn(run func() error) *MockStore_Close_
7979 return _c
8080 }
8181
82+// CloseWithoutSync provides a mock function for the type MockStore
83+func (_mock *MockStore) CloseWithoutSync() error {
84+ ret := _mock.Called()
85+
86+ if len(ret) == 0 {
87+ panic("no return value specified for CloseWithoutSync")
88+ }
89+
90+ var r0 error
91+ if returnFunc, ok := ret.Get(0).(func() error); ok {
92+ r0 = returnFunc()
93+ } else {
94+ r0 = ret.Error(0)
95+ }
96+ return r0
97+}
98+
99+// MockStore_CloseWithoutSync_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CloseWithoutSync'
100+type MockStore_CloseWithoutSync_Call struct {
101+ *mock.Call
102+}
103+
104+// CloseWithoutSync is a helper method to define mock.On call
105+func (_e *MockStore_Expecter) CloseWithoutSync() *MockStore_CloseWithoutSync_Call {
106+ return &MockStore_CloseWithoutSync_Call{Call: _e.mock.On("CloseWithoutSync")}
107+}
108+
109+func (_c *MockStore_CloseWithoutSync_Call) Run(run func()) *MockStore_CloseWithoutSync_Call {
110+ _c.Call.Run(func(args mock.Arguments) {
111+ run()
112+ })
113+ return _c
114+}
115+
116+func (_c *MockStore_CloseWithoutSync_Call) Return(err error) *MockStore_CloseWithoutSync_Call {
117+ _c.Call.Return(err)
118+ return _c
119+}
120+
121+func (_c *MockStore_CloseWithoutSync_Call) RunAndReturn(run func() error) *MockStore_CloseWithoutSync_Call {
122+ _c.Call.Return(run)
123+ return _c
124+}
125+
82126 // Delete provides a mock function for the type MockStore
83127 func (_mock *MockStore) Delete(key []byte) error {
84128 ret := _mock.Called(key)
@@ -79,6 +79,50 @@ func (_c *MockStore_Close_Call) RunAndReturn(run func() error) *MockStore_Close_
79 return _c79 return _c
80 }80 }
81 81
82+// CloseWithoutSync provides a mock function for the type MockStore
83+func (_mock *MockStore) CloseWithoutSync() error {
84+ ret := _mock.Called()
85+
86+ if len(ret) == 0 {
87+ panic("no return value specified for CloseWithoutSync")
88+ }
89+
90+ var r0 error
91+ if returnFunc, ok := ret.Get(0).(func() error); ok {
92+ r0 = returnFunc()
93+ } else {
94+ r0 = ret.Error(0)
95+ }
96+ return r0
97+}
98+
99+// MockStore_CloseWithoutSync_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CloseWithoutSync'
100+type MockStore_CloseWithoutSync_Call struct {
101+ *mock.Call
102+}
103+
104+// CloseWithoutSync is a helper method to define mock.On call
105+func (_e *MockStore_Expecter) CloseWithoutSync() *MockStore_CloseWithoutSync_Call {
106+ return &MockStore_CloseWithoutSync_Call{Call: _e.mock.On("CloseWithoutSync")}
107+}
108+
109+func (_c *MockStore_CloseWithoutSync_Call) Run(run func()) *MockStore_CloseWithoutSync_Call {
110+ _c.Call.Run(func(args mock.Arguments) {
111+ run()
112+ })
113+ return _c
114+}
115+
116+func (_c *MockStore_CloseWithoutSync_Call) Return(err error) *MockStore_CloseWithoutSync_Call {
117+ _c.Call.Return(err)
118+ return _c
119+}
120+
121+func (_c *MockStore_CloseWithoutSync_Call) RunAndReturn(run func() error) *MockStore_CloseWithoutSync_Call {
122+ _c.Call.Return(run)
123+ return _c
124+}
125+
82 // Delete provides a mock function for the type MockStore126 // Delete provides a mock function for the type MockStore
83 func (_mock *MockStore) Delete(key []byte) error {127 func (_mock *MockStore) Delete(key []byte) error {
84 ret := _mock.Called(key)128 ret := _mock.Called(key)
M internal/ports/store.go +3 -0
@@ -16,6 +16,9 @@ type Store interface {
1616 // or mutate either slice after Range returns.
1717 Range(fn func(k, v []byte) bool)
1818 Sync() error
19+ // CloseWithoutSync releases the store without persisting buffered writes.
20+ // Callers use it after a rejected durable mutation.
21+ CloseWithoutSync() error
1922 Close() error
2023 }
2124
@@ -16,6 +16,9 @@ type Store interface {
16 // or mutate either slice after Range returns.16 // or mutate either slice after Range returns.
17 Range(fn func(k, v []byte) bool)17 Range(fn func(k, v []byte) bool)
18 Sync() error18 Sync() error
19+ // CloseWithoutSync releases the store without persisting buffered writes.
20+ // Callers use it after a rejected durable mutation.
21+ CloseWithoutSync() error
19 Close() error22 Close() error
20 }23 }
21 24
M internal/usecase/daemon/session_test.go +3 -2
@@ -787,8 +787,9 @@ func (s newStaticStore) Range(fn func(k, v []byte) bool) {
787787 }
788788 }
789789 }
790-func (s newStaticStore) Sync() error { return nil }
791-func (s newStaticStore) Close() error { return nil }
790+func (s newStaticStore) Sync() error { return nil }
791+func (s newStaticStore) CloseWithoutSync() error { return nil }
792+func (s newStaticStore) Close() error { return nil }
792793
793794 func TestNamedSessionCreationIsAllowedWithNilStore(t *testing.T) {
794795 pty, release := newBlockingPTY(t)
@@ -787,8 +787,9 @@ func (s newStaticStore) Range(fn func(k, v []byte) bool) {
787 }787 }
788 }788 }
789 }789 }
790-func (s newStaticStore) Sync() error { return nil }790+func (s newStaticStore) Sync() error { return nil }
791-func (s newStaticStore) Close() error { return nil }791+func (s newStaticStore) CloseWithoutSync() error { return nil }
792+func (s newStaticStore) Close() error { return nil }
792 793
793 func TestNamedSessionCreationIsAllowedWithNilStore(t *testing.T) {794 func TestNamedSessionCreationIsAllowedWithNilStore(t *testing.T) {
794 pty, release := newBlockingPTY(t)795 pty, release := newBlockingPTY(t)
M pkg/kv/kv_test.go +2 -1
@@ -191,13 +191,14 @@ func TestDecodeRejectsDuplicateKeysAndTrailingData(t *testing.T) {
191191 require.True(t, errors.Is(err, ErrCorrupt))
192192 }
193193
194-func TestCloseWithoutSyncDiscardsDirtyStore(t *testing.T) {
194+func TestCloseWithoutSyncPreventsLaterCloseFromSyncing(t *testing.T) {
195195 t.Parallel()
196196 path := privateStorePath(t)
197197 store, err := Open(path)
198198 require.NoError(t, err)
199199 require.NoError(t, store.Set([]byte("rejected"), []byte("value")))
200200 require.NoError(t, store.CloseWithoutSync())
201+ require.NoError(t, store.Close(), "a later normal Close must remain a no-op")
201202
202203 reopened, err := Open(path)
203204 require.NoError(t, err)
@@ -191,13 +191,14 @@ func TestDecodeRejectsDuplicateKeysAndTrailingData(t *testing.T) {
191 require.True(t, errors.Is(err, ErrCorrupt))191 require.True(t, errors.Is(err, ErrCorrupt))
192 }192 }
193 193
194-func TestCloseWithoutSyncDiscardsDirtyStore(t *testing.T) {194+func TestCloseWithoutSyncPreventsLaterCloseFromSyncing(t *testing.T) {
195 t.Parallel()195 t.Parallel()
196 path := privateStorePath(t)196 path := privateStorePath(t)
197 store, err := Open(path)197 store, err := Open(path)
198 require.NoError(t, err)198 require.NoError(t, err)
199 require.NoError(t, store.Set([]byte("rejected"), []byte("value")))199 require.NoError(t, store.Set([]byte("rejected"), []byte("value")))
200 require.NoError(t, store.CloseWithoutSync())200 require.NoError(t, store.CloseWithoutSync())
201+ require.NoError(t, store.Close(), "a later normal Close must remain a no-op")
201 202
202 reopened, err := Open(path)203 reopened, err := Open(path)
203 require.NoError(t, err)204 require.NoError(t, err)

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