Skip to content

Commit 2c89075

Browse files
authored
Merge pull request #6744 from dolthub/aaron/fix-hasCache-dangling-references-bug-2
go/store/nbs: store.go: Clean up how we update hasCache so that we only update it after successfully writing the memtable.
2 parents 6aba08d + d6b8936 commit 2c89075

File tree

4 files changed

+165
-83
lines changed

4 files changed

+165
-83
lines changed

go/libraries/doltcore/doltdb/gc_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ package doltdb_test
1616

1717
import (
1818
"context"
19+
"errors"
20+
"os"
1921
"testing"
2022

2123
"github.com/dolthub/go-mysql-server/sql"
@@ -28,7 +30,13 @@ import (
2830
"github.com/dolthub/dolt/go/libraries/doltcore/env"
2931
"github.com/dolthub/dolt/go/libraries/doltcore/ref"
3032
"github.com/dolthub/dolt/go/libraries/doltcore/sqle"
33+
"github.com/dolthub/dolt/go/libraries/utils/filesys"
3134
"github.com/dolthub/dolt/go/store/hash"
35+
"github.com/dolthub/dolt/go/store/nbs"
36+
"github.com/dolthub/dolt/go/store/prolly"
37+
"github.com/dolthub/dolt/go/store/prolly/tree"
38+
"github.com/dolthub/dolt/go/store/types"
39+
"github.com/dolthub/dolt/go/store/val"
3240
)
3341

3442
func TestGarbageCollection(t *testing.T) {
@@ -40,6 +48,8 @@ func TestGarbageCollection(t *testing.T) {
4048
testGarbageCollection(t, gct)
4149
})
4250
}
51+
52+
t.Run("HasCacheDataCorruption", testGarbageCollectionHasCacheDataCorruptionBugFix)
4353
}
4454

4555
type stage struct {
@@ -140,3 +150,118 @@ func testGarbageCollection(t *testing.T, test gcTest) {
140150
require.NoError(t, err)
141151
assert.Equal(t, test.expected, actual)
142152
}
153+
154+
// In September 2023, we found a failure to handle the `hasCache` in
155+
// `*NomsBlockStore` appropriately while cleaning up a memtable into which
156+
// dangling references had been written could result in writing chunks to a
157+
// database which referenced non-existant chunks.
158+
//
159+
// The general pattern was to get new chunk addresses into the hasCache, but
160+
// not written to the store, and then to have an incoming chunk add a refenece
161+
// to missing chunk. At that time, we would clear the memtable, since it had
162+
// invalid chunks in it, but we wouldn't purge the hasCache. Later writes which
163+
// attempted to reference the chunks which had made it into the hasCache would
164+
// succeed.
165+
//
166+
// One such concrete pattern for doing this is implemented below. We do:
167+
//
168+
// 1) Put a new chunk to the database -- C1.
169+
//
170+
// 2) Run a GC.
171+
//
172+
// 3) Put a new chunk to the database -- C2.
173+
//
174+
// 4) Call NBS.Commit() with a stale last hash.Hash. This causes us to cache C2
175+
// as present in the store, but it does not get written to disk, because the
176+
// optimistic concurrency control on the value of the current root hash fails.
177+
//
178+
// 5) Put a chunk referencing C1 to the database -- R1.
179+
//
180+
// 5) Call NBS.Commit(). This causes ErrDanglingRef. C1 was written before the
181+
// GC and is no longer in the store. C2 is also cleared from the pending write
182+
// set.
183+
//
184+
// 6) Put a chunk referencing C2 to the database -- R2.
185+
//
186+
// 7) Call NBS.Commit(). This should fail, since R2 references C2 and C2 is not
187+
// in the store. However, C2 is in the cache as a result of step #4, and so
188+
// this does not fail. R2 gets written to disk with a dangling reference to C2.
189+
func testGarbageCollectionHasCacheDataCorruptionBugFix(t *testing.T) {
190+
ctx := context.Background()
191+
192+
d, err := os.MkdirTemp(t.TempDir(), "hascachetest-")
193+
require.NoError(t, err)
194+
195+
ddb, err := doltdb.LoadDoltDB(ctx, types.Format_DOLT, "file://"+d, filesys.LocalFS)
196+
require.NoError(t, err)
197+
defer ddb.Close()
198+
199+
err = ddb.WriteEmptyRepo(ctx, "main", "Aaron Son", "aaron@dolthub.com")
200+
require.NoError(t, err)
201+
202+
root, err := ddb.NomsRoot(ctx)
203+
require.NoError(t, err)
204+
205+
ns := ddb.NodeStore()
206+
207+
c1 := newIntMap(t, ctx, ns, 1, 1)
208+
_, err = ns.Write(ctx, c1.Node())
209+
require.NoError(t, err)
210+
211+
err = ddb.GC(ctx, nil)
212+
require.NoError(t, err)
213+
214+
c2 := newIntMap(t, ctx, ns, 2, 2)
215+
_, err = ns.Write(ctx, c2.Node())
216+
require.NoError(t, err)
217+
218+
success, err := ddb.CommitRoot(ctx, c2.HashOf(), c2.HashOf())
219+
require.NoError(t, err)
220+
require.False(t, success, "committing the root with a last hash which does not match the current root must fail")
221+
222+
r1 := newAddrMap(t, ctx, ns, "r1", c1.HashOf())
223+
_, err = ns.Write(ctx, r1.Node())
224+
require.NoError(t, err)
225+
226+
success, err = ddb.CommitRoot(ctx, root, root)
227+
require.True(t, errors.Is(err, nbs.ErrDanglingRef), "committing a reference to just-collected c1 must fail with ErrDanglingRef")
228+
229+
r2 := newAddrMap(t, ctx, ns, "r2", c2.HashOf())
230+
_, err = ns.Write(ctx, r2.Node())
231+
require.NoError(t, err)
232+
233+
success, err = ddb.CommitRoot(ctx, root, root)
234+
require.True(t, errors.Is(err, nbs.ErrDanglingRef), "committing a reference to c2, which was erased with the ErrDanglingRef above, must also fail with ErrDanglingRef")
235+
}
236+
237+
func newIntMap(t *testing.T, ctx context.Context, ns tree.NodeStore, k, v int8) prolly.Map {
238+
desc := val.NewTupleDescriptor(val.Type{
239+
Enc: val.Int8Enc,
240+
Nullable: false,
241+
})
242+
243+
tb := val.NewTupleBuilder(desc)
244+
tb.PutInt8(0, k)
245+
keyTuple := tb.Build(ns.Pool())
246+
247+
tb.PutInt8(0, v)
248+
valueTuple := tb.Build(ns.Pool())
249+
250+
m, err := prolly.NewMapFromTuples(ctx, ns, desc, desc, keyTuple, valueTuple)
251+
require.NoError(t, err)
252+
return m
253+
}
254+
255+
func newAddrMap(t *testing.T, ctx context.Context, ns tree.NodeStore, key string, h hash.Hash) prolly.AddressMap {
256+
m, err := prolly.NewEmptyAddressMap(ns)
257+
require.NoError(t, err)
258+
259+
editor := m.Editor()
260+
err = editor.Add(ctx, key, h)
261+
require.NoError(t, err)
262+
263+
m, err = editor.Flush(ctx)
264+
require.NoError(t, err)
265+
266+
return m
267+
}

go/store/nbs/generational_chunk_store.go

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ package nbs
1616

1717
import (
1818
"context"
19-
"fmt"
2019
"io"
2120
"path/filepath"
2221
"strings"
@@ -154,18 +153,6 @@ func (gcs *GenerationalNBS) hasMany(recs []hasRecord) (absent hash.HashSet, err
154153
return gcs.oldGen.hasMany(recs)
155154
}
156155

157-
func (gcs *GenerationalNBS) errorIfDangling(ctx context.Context, addrs hash.HashSet) error {
158-
absent, err := gcs.HasMany(ctx, addrs)
159-
if err != nil {
160-
return err
161-
}
162-
if len(absent) != 0 {
163-
s := absent.String()
164-
return fmt.Errorf("Found dangling references to %s", s)
165-
}
166-
return nil
167-
}
168-
169156
// Put caches c in the ChunkSource. Upon return, c must be visible to
170157
// subsequent Get and Has calls, but must not be persistent until a call
171158
// to Flush(). Put may be called concurrently with other calls to Put(),

go/store/nbs/store.go

Lines changed: 40 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,36 @@ func (nbs *NomsBlockStore) putChunk(ctx context.Context, c chunks.Chunk, getAddr
705705
return nil
706706
}
707707

708+
// When we have chunks with dangling references in our memtable, we have to
709+
// throw away the entire memtable.
710+
func (nbs *NomsBlockStore) handlePossibleDanglingRefError(err error) {
711+
if errors.Is(err, ErrDanglingRef) {
712+
nbs.mt = nil
713+
}
714+
}
715+
716+
// Writes to a Dolt database typically involve mutating some tuple maps and
717+
// then mutating the top-level address map which points to all the branch heads
718+
// and working sets. Each internal node of the address map can have many
719+
// references and many of them typically change quite slowly. We keep a cache
720+
// of recently written references which we know are in the database so that we
721+
// don't have to check the table file indexes for these chunks when we write
722+
// references to them again in the near future.
723+
//
724+
// This cache needs to be treated in a principled manner. The integrity checks
725+
// that we run against the a set of chunks we are attempting to write consider
726+
// the to-be-written chunks themselves as also being in the database. This is
727+
// correct, assuming that all the chunks are written at the same time. However,
728+
// we should not add the results of those presence checks to the cache until
729+
// those chunks actually land in the database.
730+
func (nbs *NomsBlockStore) addPendingRefsToHasCache() {
731+
for _, e := range nbs.mt.pendingRefs {
732+
if e.has {
733+
nbs.hasCache.Add(*e.a, struct{}{})
734+
}
735+
}
736+
}
737+
708738
func (nbs *NomsBlockStore) addChunk(ctx context.Context, ch chunks.Chunk, addrs hash.HashSet, checker refCheck) (bool, error) {
709739
if err := ctx.Err(); err != nil {
710740
return false, err
@@ -725,11 +755,10 @@ func (nbs *NomsBlockStore) addChunk(ctx context.Context, ch chunks.Chunk, addrs
725755
if addChunkRes == chunkNotAdded {
726756
ts, err := nbs.tables.append(ctx, nbs.mt, checker, nbs.hasCache, nbs.stats)
727757
if err != nil {
728-
if errors.Is(err, ErrDanglingRef) {
729-
nbs.mt = nil
730-
}
758+
nbs.handlePossibleDanglingRefError(err)
731759
return false, err
732760
}
761+
nbs.addPendingRefsToHasCache()
733762
nbs.tables = ts
734763
nbs.mt = newMemTable(nbs.mtSize)
735764
addChunkRes = nbs.mt.addChunk(a, ch.Data())
@@ -757,7 +786,6 @@ type refCheck func(reqs []hasRecord) (hash.HashSet, error)
757786
func (nbs *NomsBlockStore) errorIfDangling(root hash.Hash, checker refCheck) error {
758787
if !root.IsEmpty() {
759788
a := addr(root)
760-
// We use |Get| here, since it updates recency of the entry.
761789
if _, ok := nbs.hasCache.Get(a); !ok {
762790
var hr [1]hasRecord
763791
hr[0].a = &a
@@ -771,32 +799,6 @@ func (nbs *NomsBlockStore) errorIfDangling(root hash.Hash, checker refCheck) err
771799
nbs.hasCache.Add(a, struct{}{})
772800
}
773801
}
774-
775-
if nbs.mt == nil || nbs.mt.pendingRefs == nil {
776-
return nil // no pending refs to check
777-
}
778-
779-
for i := range nbs.mt.pendingRefs {
780-
// All of these are going to be |Add|ed after the call. We use
781-
// |Contains| to check here so the frequency count only gets
782-
// bumped once.
783-
if nbs.hasCache.Contains(*nbs.mt.pendingRefs[i].a) {
784-
nbs.mt.pendingRefs[i].has = true
785-
}
786-
}
787-
788-
sort.Sort(hasRecordByPrefix(nbs.mt.pendingRefs))
789-
absent, err := checker(nbs.mt.pendingRefs)
790-
if err != nil {
791-
return err
792-
} else if absent.Size() > 0 {
793-
return fmt.Errorf("%w: found dangling references to %s", ErrDanglingRef, absent.String())
794-
}
795-
796-
for _, e := range nbs.mt.pendingRefs {
797-
nbs.hasCache.Add(*e.a, struct{}{})
798-
}
799-
800802
return nil
801803
}
802804

@@ -1130,39 +1132,6 @@ func (nbs *NomsBlockStore) commit(ctx context.Context, current, last hash.Hash,
11301132
return true, nil
11311133
}
11321134

1133-
// check for dangling references in |nbs.mt|
1134-
if err = nbs.errorIfDangling(current, checker); err != nil {
1135-
if errors.Is(err, ErrDanglingRef) {
1136-
nbs.mt = nil
1137-
}
1138-
return false, err
1139-
}
1140-
1141-
// This is unfortunate. We want to serialize commits to the same store
1142-
// so that we avoid writing a bunch of unreachable small tables which result
1143-
// from optimistic lock failures. However, this means that the time to
1144-
// write tables is included in "commit" time and if all commits are
1145-
// serialized, it means a lot more waiting.
1146-
// "non-trivial" tables are persisted here, outside of the commit-lock.
1147-
// all other tables are persisted in updateManifest()
1148-
if nbs.mt != nil {
1149-
cnt, err := nbs.mt.count()
1150-
if err != nil {
1151-
return false, err
1152-
}
1153-
1154-
if cnt > preflushChunkCount {
1155-
ts, err := nbs.tables.append(ctx, nbs.mt, checker, nbs.hasCache, nbs.stats)
1156-
if err != nil {
1157-
if errors.Is(err, ErrDanglingRef) {
1158-
nbs.mt = nil
1159-
}
1160-
return false, err
1161-
}
1162-
nbs.tables, nbs.mt = ts, nil
1163-
}
1164-
}
1165-
11661135
nbs.mm.LockForUpdate()
11671136
defer func() {
11681137
unlockErr := nbs.mm.UnlockForUpdate()
@@ -1233,11 +1202,10 @@ func (nbs *NomsBlockStore) updateManifest(ctx context.Context, current, last has
12331202
if cnt > 0 {
12341203
ts, err := nbs.tables.append(ctx, nbs.mt, checker, nbs.hasCache, nbs.stats)
12351204
if err != nil {
1236-
if errors.Is(err, ErrDanglingRef) {
1237-
nbs.mt = nil
1238-
}
1205+
nbs.handlePossibleDanglingRefError(err)
12391206
return err
12401207
}
1208+
nbs.addPendingRefsToHasCache()
12411209
nbs.tables, nbs.mt = ts, nil
12421210
}
12431211
}
@@ -1250,6 +1218,12 @@ func (nbs *NomsBlockStore) updateManifest(ctx context.Context, current, last has
12501218
return errOptimisticLockFailedTables
12511219
}
12521220

1221+
// check for dangling reference to the new root
1222+
if err = nbs.errorIfDangling(current, checker); err != nil {
1223+
nbs.handlePossibleDanglingRefError(err)
1224+
return err
1225+
}
1226+
12531227
specs, err := nbs.tables.toSpecs()
12541228
if err != nil {
12551229
return err

go/store/nbs/table_set.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -302,10 +302,6 @@ func (ts tableSet) append(ctx context.Context, mt *memTable, checker refCheck, h
302302
return tableSet{}, fmt.Errorf("%w: found dangling references to %s", ErrDanglingRef, absent.String())
303303
}
304304

305-
for _, e := range mt.pendingRefs {
306-
hasCache.Add(*e.a, struct{}{})
307-
}
308-
309305
cs, err := ts.p.Persist(ctx, mt, ts, stats)
310306
if err != nil {
311307
return tableSet{}, err

0 commit comments

Comments
 (0)