Skip to content

Commit 735254f

Browse files
authored
test: add revision tests (#3257)
* test: add revision test * fix: making Spanner honor the configured GC window instead of hardcoding it * test: modify SnapshotReadStabilityTest so it fails for memdb ONLY * fix: broken test and new test * chore: changelog * chore: simplify godocs * chore: autovacuum=off for postgres tests
1 parent 1ba6b97 commit 735254f

17 files changed

Lines changed: 649 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
55

66
## [Unreleased]
7+
### Fixed
8+
- MemDB: a read at a given revision, such as a `Check` at an `at_exact_snapshot` ZedToken, could include relationships written *after* that revision, so the same revision returned different results as later writes arrived instead of a stable point-in-time view. Reads at a revision now return only the data committed as of it. (https://github.com/authzed/spicedb/pull/3257)
9+
- Spanner: the configured `--datastore-gc-window` was ignored, and revisions were always treated as valid for 24 hours (the change stream retention). Spanner now honors the configured window, capped at that retention since older revisions cannot be read regardless. (https://github.com/authzed/spicedb/pull/3257)
710

811
## [1.56.1] - 2026-08-26
912
### Changed

internal/datastore/memdb/memdb.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,12 @@ func NewMemdbDatastore(
7171
{
7272
revision: nowRevision(),
7373
schemaHash: "",
74-
db: db,
74+
// A snapshot of the still-empty database rather than the
75+
// database itself, so that a read at the creation revision
76+
// above sees the datastore as it was created rather than as it
77+
// is now. Every later entry likewise holds a snapshot frozen
78+
// at its own revision.
79+
db: db.Snapshot(),
7580
},
7681
},
7782

@@ -122,7 +127,7 @@ func (mdb *memdbDatastore) UniqueID(_ context.Context) (string, error) {
122127
}
123128

124129
// SnapshotReader returns a reader for the snapshot visible at the given
125-
// revision: the first entry in mdb.revisions at or after it, located by
130+
// revision: the most recent entry in mdb.revisions at or before it, located by
126131
// binary search.
127132
func (mdb *memdbDatastore) SnapshotReader(dr datastore.Revision) datastore.Reader {
128133
mdb.RLock()
@@ -140,13 +145,17 @@ func (mdb *memdbDatastore) SnapshotReader(dr datastore.Revision) datastore.Reade
140145
return &memdbReader{nil, nil, err, time.Now()}
141146
}
142147

148+
// sort.Search finds the first snapshot newer than the requested revision,
149+
// so the one visible at it is the entry before that.
143150
revIndex := sort.Search(len(mdb.revisions), func(i int) bool {
144-
return mdb.revisions[i].revision.GreaterThan(dr) || mdb.revisions[i].revision.Equal(dr)
151+
return mdb.revisions[i].revision.GreaterThan(dr)
145152
})
146153

147-
// handle the case when there is no revision snapshot newer than the requested revision
148-
if revIndex == len(mdb.revisions) {
149-
revIndex = len(mdb.revisions) - 1
154+
// Handle the case where every snapshot is newer than the requested
155+
// revision, i.e. it predates the datastore itself: the oldest snapshot is
156+
// the closest thing to the state at that revision.
157+
if revIndex > 0 {
158+
revIndex--
150159
}
151160

152161
rev := mdb.revisions[revIndex]

internal/datastore/memdb/revisions.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ func (mdb *memdbDatastore) OptimizedRevision(_ context.Context) (datastore.Revis
8282
optimized = now
8383
}
8484

85+
// Rounding down can land before the oldest snapshot, which no read can be
86+
// served at. Advertise head instead, as Postgres does for an empty bucket.
87+
if optimized.LessThan(mdb.revisions[0].revision) {
88+
optimized = mdb.headRevisionNoLock()
89+
}
90+
8591
// Find the schema hash visible at the optimized revision: walk the
8692
// revisions list backward for the most recent snapshot whose revision
8793
// is at or before `optimized`.

internal/datastore/postgres/postgres_shared_test.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,15 +1155,27 @@ func HeadRevisionDoesNotConsumeXIDTest(t *testing.T, ds datastore.Datastore) {
11551155
return x
11561156
}
11571157

1158-
const iterations = 10
1158+
// The xid counter is cluster-wide, so activity that has nothing to do with
1159+
// this test can advance it while the test runs: autoanalyze burns two xids
1160+
// each time it fires, and the test instance is shared by every database the
1161+
// suite creates. Tolerate that noise rather than requiring an exact match,
1162+
// but keep the allowance far below the one-per-call a HeadRevision that
1163+
// opened a write transaction would produce.
1164+
const (
1165+
iterations = 100
1166+
allowedBackground = 10
1167+
)
1168+
11591169
before := nextXID()
11601170
for i := 0; i < iterations; i++ {
11611171
_, err := ds.HeadRevision(ctx)
11621172
require.NoError(t, err)
11631173
}
11641174
after := nextXID()
11651175

1166-
require.Equal(t, before, after, "HeadRevision burned %d xid(s) over %d calls; expected 0", after-before, iterations)
1176+
require.LessOrEqual(t, after-before, uint64(allowedBackground),
1177+
"HeadRevision burned %d xid(s) over %d calls; expected at most %d from unrelated background activity",
1178+
after-before, iterations, allowedBackground)
11671179
}
11681180

11691181
// ConcurrentRevisionWatchTest uses goroutines and channels to intentionally set up a pair of

internal/datastore/spanner/enginebuilder.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func newDatastoreFromConfig(ctx context.Context, opts datastorecfg.Config) (data
4040
opts.URI,
4141
FollowerReadDelay(opts.FollowerReadDelay),
4242
RevisionQuantization(opts.RevisionQuantization),
43+
GCWindow(opts.GCWindow),
4344
MaxRevisionStalenessPercent(opts.MaxRevisionStalenessPercent),
4445
//nolint:staticcheck // the deprecated credentials options remain supported until removal
4546
CredentialsFile(opts.SpannerCredentialsFile),

internal/datastore/spanner/options.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type spannerOptions struct {
3636
watchChangeBufferMaximumSize uint64
3737
watchBufferWriteTimeout time.Duration
3838
revisionQuantization time.Duration
39+
gcWindow time.Duration
3940
followerReadDelay time.Duration
4041
maxRevisionStalenessPercent float64
4142
credentialsFilePath string
@@ -66,12 +67,12 @@ const (
6667
errQuantizationTooLarge = "revision quantization (%s) must be less than (%s)"
6768

6869
defaultRevisionQuantization = 5 * time.Second
70+
defaultGCWindow = defaultChangeStreamRetention
6971
defaultFollowerReadDelay = 0 * time.Second
7072
defaultMaxRevisionStalenessPercent = 0.1
7173
defaultWatchBufferLength = 128
7274
defaultWatchBufferWriteTimeout = 1 * time.Second
7375
defaultDisableStats = false
74-
maxRevisionQuantization = 24 * time.Hour
7576
defaultFilterMaximumIDCount = 100
7677
defaultColumnOptimizationOption = common.ColumnOptimizationOptionStaticValues
7778
defaultWatchDisabled = false
@@ -89,6 +90,7 @@ func generateConfig(options []Option) (spannerOptions, error) {
8990
watchBufferLength: defaultWatchBufferLength,
9091
watchBufferWriteTimeout: defaultWatchBufferWriteTimeout,
9192
revisionQuantization: defaultRevisionQuantization,
93+
gcWindow: defaultGCWindow,
9294
followerReadDelay: defaultFollowerReadDelay,
9395
maxRevisionStalenessPercent: defaultMaxRevisionStalenessPercent,
9496
disableStats: defaultDisableStats,
@@ -104,12 +106,22 @@ func generateConfig(options []Option) (spannerOptions, error) {
104106
option(&computed)
105107
}
106108

107-
// Run any checks on the config that need to be done
108-
if computed.revisionQuantization >= maxRevisionQuantization {
109+
// Run any checks on the config that need to be done.
110+
// Revisions older than the change stream retention are not readable no
111+
// matter what is configured, so a larger window cannot be honored.
112+
if computed.gcWindow > defaultChangeStreamRetention {
113+
log.Warn().
114+
Dur("changeStreamRetention", defaultChangeStreamRetention).
115+
Dur("gcWindow", computed.gcWindow).
116+
Msg("configured gc window exceeds the Spanner change stream retention, so capping to the retention")
117+
computed.gcWindow = defaultChangeStreamRetention
118+
}
119+
120+
if computed.revisionQuantization >= computed.gcWindow {
109121
return computed, fmt.Errorf(
110122
errQuantizationTooLarge,
111123
computed.revisionQuantization,
112-
maxRevisionQuantization,
124+
computed.gcWindow,
113125
)
114126
}
115127

@@ -157,6 +169,12 @@ func RevisionQuantization(bucketSize time.Duration) Option {
157169
}
158170
}
159171

172+
func GCWindow(window time.Duration) Option {
173+
return func(so *spannerOptions) {
174+
so.gcWindow = window
175+
}
176+
}
177+
160178
// FollowerReadDelay is the time delay to apply to enable historical reads.
161179
//
162180
// This value defaults to 0 seconds.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package spanner
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestGCWindowOption(t *testing.T) {
11+
tcs := []struct {
12+
name string
13+
options []Option
14+
expectedGCWindow time.Duration
15+
expectedError string
16+
}{
17+
{
18+
name: "defaults to the change stream retention",
19+
expectedGCWindow: defaultChangeStreamRetention,
20+
},
21+
{
22+
name: "a window within the change stream retention is honored",
23+
options: []Option{GCWindow(1 * time.Hour)},
24+
expectedGCWindow: 1 * time.Hour,
25+
},
26+
{
27+
name: "a window beyond the change stream retention is capped",
28+
options: []Option{GCWindow(48 * time.Hour)},
29+
expectedGCWindow: defaultChangeStreamRetention,
30+
},
31+
{
32+
name: "quantization at least as large as the window is rejected",
33+
options: []Option{GCWindow(time.Second), RevisionQuantization(time.Second)},
34+
expectedError: "revision quantization (1s) must be less than (1s)",
35+
},
36+
}
37+
38+
for _, tc := range tcs {
39+
t.Run(tc.name, func(t *testing.T) {
40+
config, err := generateConfig(tc.options)
41+
if tc.expectedError != "" {
42+
require.ErrorContains(t, err, tc.expectedError)
43+
return
44+
}
45+
46+
require.NoError(t, err)
47+
require.Equal(t, tc.expectedGCWindow, config.gcWindow)
48+
})
49+
}
50+
}

internal/datastore/spanner/spanner.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ func NewSpannerDatastore(ctx context.Context, database string, opts ...Option) (
209209

210210
ds := &spannerDatastore{
211211
RemoteClockRevisions: revisions.NewRemoteClockRevisions(
212-
defaultChangeStreamRetention,
212+
config.gcWindow,
213213
maxRevisionStaleness,
214214
config.followerReadDelay,
215215
config.revisionQuantization,

internal/datastore/spanner/spanner_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func TestSpannerDatastore(t *testing.T) {
3333
ds := b.NewDatastore(t, func(engine, uri string) datastore.Datastore {
3434
ds, err := NewSpannerDatastore(ctx, uri,
3535
RevisionQuantization(revisionParameters.Quantization),
36+
GCWindow(time.Duration(revisionParameters.GCRetentionWindow)),
3637
WatchBufferLength(watchBufferLength),
3738
WithDatastoreMetricsOption(DatastoreMetricsOptionOpenTelemetry),
3839
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
11
listen_addresses = '*'
22
max_connections = 3000
33
track_commit_timestamp = 1
4+
# Autovacuum is left on so autoanalyze keeps table statistics fresh: without
5+
# them the planner cannot push batched `object_id IN (...)` lookups into the
6+
# relation_tuple index, which roughly halves check throughput in the larger
7+
# tests. Vacuuming itself buys nothing on the timescale of a test, so the
8+
# thresholds below put it out of reach and -1 turns off insert-triggered
9+
# vacuums entirely. Autovacuum's own transactions still burn xids, so
10+
# HeadRevisionDoesNotConsumeXIDTest tolerates a few rather than requiring none.
11+
autovacuum = on
12+
autovacuum_vacuum_threshold = 2000000000
13+
autovacuum_vacuum_insert_threshold = -1
14+
autovacuum_freeze_max_age = 2000000000

0 commit comments

Comments
 (0)