~bigbes/sr-ht-dolt

ref: dba72f5908d704bbe8b3e8efe35432917192292b sr-ht-dolt/beads/ready_test.go -rw-r--r-- 17.9 KiB
dba72f59 — Eugene Blikh mcpsrv: list the memories a tracker holds 5 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
package beads

import (
	"context"
	"errors"
	"fmt"
	"net/url"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
)

// --- fixtures ----------------------------------------------------------------

// readyNow is the instant the aggregation's clock is pinned to. Nothing about it
// is special beyond being fixed: the TTL is a function of the fixture rather
// than of when the suite ran.
var readyNow = time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)

// fakeReadyDB is one database behind the ReadyOpener seam, counting what each
// call actually read. The counters are the point: the head-hash gate is a claim
// about reads not happening, and only a counter can check it — a timing
// measurement would pass on a fast machine whatever the code did.
type fakeReadyDB struct {
	branches []browse.Branch
	tables   []browse.TableInfo
	rows     map[string]*browse.RowPage
	commits  []browse.CommitInfo

	openErr     error
	branchesErr error
	logErr      error

	opens      int
	closes     int
	rowReads   int
	tableReads int
	logReads   int
}

func (f *fakeReadyDB) Branches(context.Context) ([]browse.Branch, error) {
	if f.branchesErr != nil {
		return nil, f.branchesErr
	}
	return f.branches, nil
}

func (f *fakeReadyDB) Tables(context.Context, string) ([]browse.TableInfo, error) {
	f.tableReads++
	return f.tables, nil
}

func (f *fakeReadyDB) Log(_ context.Context, _, _ string, _ int) ([]browse.CommitInfo, string, error) {
	f.logReads++
	if f.logErr != nil {
		return nil, "", f.logErr
	}
	return f.commits, "", nil
}

func (f *fakeReadyDB) Rows(_ context.Context, _, table string, _, _ int) (*browse.RowPage, error) {
	f.rowReads++
	if p, ok := f.rows[table]; ok {
		return p, nil
	}
	return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table)
}

func (f *fakeReadyDB) Close() error { f.closes++; return nil }

// readyInstance is a set of databases addressed by repository id, plus the
// opener over them.
type readyInstance struct {
	dbs map[int]*fakeReadyDB
}

func (in *readyInstance) open(_ context.Context, d ReadyDatabase) (ReadySession, error) {
	f, ok := in.dbs[d.ID]
	if !ok {
		return nil, fmt.Errorf("no such database: %s", d.Slug())
	}
	if f.openErr != nil {
		return nil, f.openErr
	}
	f.opens++
	return f, nil
}

// readyTracker builds a beads database whose issues page carries the given rows.
// The column order puts is_blocked, is_template and ephemeral last so the
// projection's name-based (not positional) cell mapping is exercised.
func readyTracker(head string, issues [][]string, deps [][]string) *fakeReadyDB {
	return &fakeReadyDB{
		branches: []browse.Branch{{Name: "main", Head: head}},
		tables:   beadsTables(),
		commits: []browse.CommitInfo{{
			Hash: head, Author: "Eugene Blikh", Date: readyNow.Add(-4 * time.Minute),
			Message: "bd: create (auto-commit)",
		}},
		rows: map[string]*browse.RowPage{
			"issues": {
				Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "is_blocked", "is_template", "ephemeral"},
				Rows:    issues,
				Total:   len(issues),
			},
			"dependencies": {
				Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
				Rows:    deps,
				Total:   len(deps),
			},
		},
	}
}

// readyFixture is two trackers: one with three ready issues among six, one with
// a single ready issue.
func readyFixture() (*readyInstance, []ReadyDatabase) {
	alpha := readyTracker("h-alpha", [][]string{
		{"a-1", "Ready, top priority", "open", "0", "task", "alice", "0", "0", "0"},
		{"a-2", "Ready, lower", "open", "2", "bug", "bob", "0", "0", "0"},
		{"a-3", "Ready, same priority as a-2", "open", "2", "task", "alice", "0", "0", "0"},
		{"a-4", "In progress, not ready", "in_progress", "0", "task", "alice", "0", "0", "0"},
		{"a-5", "Blocked, not ready", "open", "0", "task", "bob", "1", "0", "0"},
		{"a-6", "Closed, not ready", "closed", "0", "task", "bob", "0", "0", "0"},
	}, nil)
	beta := readyTracker("h-beta", [][]string{
		{"b-1", "The one ready thing", "open", "1", "task", "carol", "0", "0", "0"},
		{"b-2", "A template scaffold", "open", "0", "task", "carol", "0", "1", "0"},
		{"b-3", "An ephemeral scratch", "open", "0", "task", "carol", "0", "0", "1"},
	}, nil)
	in := &readyInstance{dbs: map[int]*fakeReadyDB{1: alpha, 2: beta}}
	return in, []ReadyDatabase{
		{ID: 1, OwnerName: "alice", Name: "alpha"},
		{ID: 2, OwnerName: "bob", Name: "beta"},
	}
}

func across(in *readyInstance, dbs []ReadyDatabase, cache *ReadyCache, f ReadyFilter, now time.Time) *ReadyView {
	return ReadyAcross(context.Background(), dbs, in.open, cache, f, now)
}

// --- tests -------------------------------------------------------------------

// The projection: which issues are ready, how the groups are ordered, how the
// cards inside one are, and that each group carries its own database's head.
func TestReadyAcrossGroupsAndOrders(t *testing.T) {
	in, dbs := readyFixture()

	view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)

	require.Len(t, view.Groups, 2)
	assert.Equal(t, 4, view.Total)
	assert.Equal(t, 2, view.Considered)
	assert.False(t, view.Capped)
	assert.Empty(t, view.Failed)

	// Groups: ready count desc, then name.
	assert.Equal(t, "alice/alpha", view.Groups[0].Database.Slug())
	assert.Equal(t, "bob/beta", view.Groups[1].Database.Slug())

	// Cards: priority, then id. In-progress, blocked, closed, template and
	// ephemeral issues are not ready.
	var ids []string
	for _, c := range view.Groups[0].Cards {
		ids = append(ids, c.ID)
		assert.True(t, c.Ready, "%s must be marked ready", c.ID)
	}
	assert.Equal(t, []string{"a-1", "a-2", "a-3"}, ids)
	require.Len(t, view.Groups[1].Cards, 1)
	assert.Equal(t, "b-1", view.Groups[1].Cards[0].ID)

	// Each group carries its own database's head, so "ready" from a store that
	// stopped receiving pushes is not claimed silently.
	require.NotNil(t, view.Groups[0].Head)
	assert.Equal(t, "h-alpha", view.Groups[0].Head.Hash)
	assert.Equal(t, "main", view.Groups[0].Ref)
	require.NotNil(t, view.Groups[1].Head)
	assert.Equal(t, "h-beta", view.Groups[1].Head.Hash)

	// The filter options come from the whole ready set.
	assert.Equal(t, []string{"alice", "bob", "carol"}, view.Options.Assignees)
	assert.Equal(t, []string{"0", "1", "2"}, view.Options.Priorities)

	// Every session opened is closed again: this page opens N stores, and one
	// leaked handle per request per database is the failure mode it is bounded
	// against.
	for id, f := range in.dbs {
		assert.Equal(t, f.opens, f.closes, "database %d: %d opens, %d closes", id, f.opens, f.closes)
	}
}

// A "blocks" edge to a still-open target blocks its source; the same edge to a
// closed one does not. This is the ready rule the board shares, exercised
// through the aggregation rather than restated in it.
func TestReadyAcrossHonoursOpenBlockers(t *testing.T) {
	tracker := readyTracker("h1", [][]string{
		{"i-1", "Blocked by an open issue", "open", "1", "task", "alice", "0", "0", "0"},
		{"i-2", "The open blocker", "open", "1", "task", "alice", "0", "0", "0"},
		{"i-3", "Blocked by a closed issue", "open", "1", "task", "alice", "0", "0", "0"},
		{"i-4", "The closed blocker", "closed", "1", "task", "alice", "0", "0", "0"},
		{"i-5", "A subtask of an open epic", "open", "1", "task", "alice", "0", "0", "0"},
		{"i-6", "The epic", "open", "1", "epic", "alice", "0", "0", "0"},
	}, [][]string{
		{"d1", "i-1", "i-2", "blocks"},
		{"d2", "i-3", "i-4", "blocks"},
		{"d3", "i-5", "i-6", "parent-child"},
	})
	in := &readyInstance{dbs: map[int]*fakeReadyDB{1: tracker}}
	dbs := []ReadyDatabase{{ID: 1, OwnerName: "alice", Name: "alpha"}}

	view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)

	require.Len(t, view.Groups, 1)
	var ids []string
	for _, c := range view.Groups[0].Cards {
		ids = append(ids, c.ID)
	}
	// i-1 waits on an open issue; everything else is actionable — including the
	// subtask, because parent-child is hierarchy and not a blocker.
	assert.Equal(t, []string{"i-2", "i-3", "i-5", "i-6"}, ids)
}

// The head-hash gate: a second call with unmoved heads reads no rows, no tables
// and no log. Asserted by counting reads on the fake, never by timing.
func TestReadyAcrossHeadHashGateSkipsEveryRead(t *testing.T) {
	in, dbs := readyFixture()
	cache := NewReadyCache()

	first := across(in, dbs, cache, ReadyFilter{}, readyNow)
	require.Len(t, first.Groups, 2)
	alpha := in.dbs[1]
	firstReads := alpha.rowReads
	require.Greater(t, firstReads, 0, "the first call must read rows")

	second := across(in, dbs, cache, ReadyFilter{}, readyNow.Add(30*time.Second))

	assert.Equal(t, firstReads, alpha.rowReads, "a second call with an unmoved head must read no rows")
	assert.Equal(t, 1, alpha.tableReads, "nor list tables again")
	assert.Equal(t, 1, alpha.logReads, "nor read the log again")
	// The session is still opened and its branches listed — that is what the gate
	// is gated on, and it is the cheap half.
	assert.Equal(t, 2, alpha.opens)
	assert.Equal(t, 2, alpha.closes)
	// And the answer is the same one.
	assert.Equal(t, first.Total, second.Total)
	require.Len(t, second.Groups, 2)
	assert.Equal(t, first.Groups[0].Cards, second.Groups[0].Cards)
	require.NotNil(t, second.Groups[0].Head)
	assert.Equal(t, "h-alpha", second.Groups[0].Head.Hash)
}

// A head that moved is a projection that no longer stands: the rows are read
// again even well inside the TTL.
func TestReadyAcrossMovedHeadForcesAReread(t *testing.T) {
	in, dbs := readyFixture()
	cache := NewReadyCache()
	alpha := in.dbs[1]

	across(in, dbs, cache, ReadyFilter{}, readyNow)
	firstReads := alpha.rowReads

	alpha.branches = []browse.Branch{{Name: "main", Head: "h-alpha-2"}}
	across(in, dbs, cache, ReadyFilter{}, readyNow.Add(time.Second))

	assert.Greater(t, alpha.rowReads, firstReads, "a moved head must be re-read")
	// The other database's head did not move, so it was not re-read.
	assert.Equal(t, 1, in.dbs[2].tableReads)
}

// The TTL: past it the projection is re-read even though the head has not
// moved, so a cache can never be the reason a reader sees yesterday's answer.
func TestReadyAcrossTTLExpiryForcesAReread(t *testing.T) {
	in, dbs := readyFixture()
	cache := NewReadyCache()
	alpha := in.dbs[1]

	across(in, dbs, cache, ReadyFilter{}, readyNow)
	firstReads := alpha.rowReads

	// One tick short of the TTL: still cached.
	across(in, dbs, cache, ReadyFilter{}, readyNow.Add(ReadyCacheTTL-time.Nanosecond))
	assert.Equal(t, firstReads, alpha.rowReads, "inside the TTL the projection stands")

	// At the TTL: read again.
	across(in, dbs, cache, ReadyFilter{}, readyNow.Add(ReadyCacheTTL))
	assert.Greater(t, alpha.rowReads, firstReads, "at the TTL the projection is re-read")
}

// The ceiling: more candidates than ReadyMaxDatabases and the view says it was
// capped, having opened exactly the ceiling's worth of stores.
func TestReadyAcrossCeiling(t *testing.T) {
	in := &readyInstance{dbs: map[int]*fakeReadyDB{}}
	var dbs []ReadyDatabase
	for i := 1; i <= ReadyMaxDatabases+3; i++ {
		in.dbs[i] = readyTracker(fmt.Sprintf("h%d", i), [][]string{
			{fmt.Sprintf("t%d-1", i), "Ready", "open", "1", "task", "alice", "0", "0", "0"},
		}, nil)
		dbs = append(dbs, ReadyDatabase{ID: i, OwnerName: "alice", Name: fmt.Sprintf("db%02d", i)})
	}

	view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)

	assert.True(t, view.Capped, "a page that hit the ceiling must say so")
	assert.Equal(t, ReadyMaxDatabases, view.Considered)
	assert.Equal(t, ReadyMaxDatabases, view.Max)
	assert.Len(t, view.Groups, ReadyMaxDatabases)
	// The three past the ceiling were never opened.
	for i := ReadyMaxDatabases + 1; i <= ReadyMaxDatabases+3; i++ {
		assert.Zero(t, in.dbs[i].opens, "database %d is past the ceiling", i)
	}
}

// A database named by ?db= is never dropped by the ceiling: the filter narrows
// the candidates first.
func TestReadyAcrossCeilingAppliesAfterTheDatabaseFilter(t *testing.T) {
	in := &readyInstance{dbs: map[int]*fakeReadyDB{}}
	var dbs []ReadyDatabase
	for i := 1; i <= ReadyMaxDatabases+3; i++ {
		in.dbs[i] = readyTracker(fmt.Sprintf("h%d", i), [][]string{
			{fmt.Sprintf("t%d-1", i), "Ready", "open", "1", "task", "alice", "0", "0", "0"},
		}, nil)
		dbs = append(dbs, ReadyDatabase{ID: i, OwnerName: "alice", Name: fmt.Sprintf("db%02d", i)})
	}
	last := dbs[len(dbs)-1]

	view := across(in, dbs, NewReadyCache(), ReadyFilter{Databases: []string{last.Slug()}}, readyNow)

	assert.False(t, view.Capped)
	assert.Equal(t, 1, view.Considered)
	require.Len(t, view.Groups, 1)
	assert.Equal(t, last.Slug(), view.Groups[0].Database.Slug())
}

// A database that cannot be opened, or whose rows cannot be read, costs itself
// only. The error is carried out for the caller's log and the rest of the answer
// stands.
func TestReadyAcrossFailingDatabaseCostsItselfOnly(t *testing.T) {
	in, dbs := readyFixture()
	in.dbs[1].openErr = errors.New("browse: open store: no such file or directory")

	view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)

	require.Len(t, view.Failed, 1)
	assert.Equal(t, "alice/alpha", view.Failed[0].Database.Slug())
	assert.ErrorContains(t, view.Failed[0].Err, "no such file")
	require.Len(t, view.Groups, 1)
	assert.Equal(t, "bob/beta", view.Groups[0].Database.Slug())

	// A store that opens but cannot list its branches fails the same way.
	in2, dbs2 := readyFixture()
	in2.dbs[1].branchesErr = errors.New("browse: list branches: corrupt chunk")
	view2 := across(in2, dbs2, NewReadyCache(), ReadyFilter{}, readyNow)
	require.Len(t, view2.Failed, 1)
	require.Len(t, view2.Groups, 1)
	assert.Equal(t, 1, in2.dbs[1].closes, "a failing database still closes its session")
}

// A database whose tables are not a beads schema is skipped silently: no group,
// no failure, and no second table listing on the next request.
func TestReadyAcrossSkipsNonBeadsDatabases(t *testing.T) {
	in, dbs := readyFixture()
	plain := &fakeReadyDB{
		branches: []browse.Branch{{Name: "main", Head: "h-plain"}},
		tables:   []browse.TableInfo{{Name: "measurements", Columns: []browse.ColumnInfo{{Name: "id"}}}},
	}
	in.dbs[3] = plain
	dbs = append(dbs, ReadyDatabase{ID: 3, OwnerName: "carol", Name: "sensors"})
	cache := NewReadyCache()

	view := across(in, dbs, cache, ReadyFilter{}, readyNow)
	assert.Len(t, view.Groups, 2)
	assert.Empty(t, view.Failed)
	assert.Zero(t, plain.rowReads, "a non-beads database is never read for rows")

	across(in, dbs, cache, ReadyFilter{}, readyNow.Add(time.Second))
	assert.Equal(t, 1, plain.tableReads, "the fingerprint is worth one table listing")
}

// A store with no branches at all — freshly initialised, never pushed to — is
// skipped rather than failing the page.
func TestReadyAcrossSkipsAnEmptyStore(t *testing.T) {
	in := &readyInstance{dbs: map[int]*fakeReadyDB{1: {}}}
	dbs := []ReadyDatabase{{ID: 1, OwnerName: "alice", Name: "fresh"}}

	view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)

	assert.Empty(t, view.Groups)
	assert.Empty(t, view.Failed)
	assert.Equal(t, 1, view.Considered)
}

// A log that cannot be read costs the group its freshness line and nothing else.
func TestReadyAcrossSurvivesAnUnreadableLog(t *testing.T) {
	in, dbs := readyFixture()
	in.dbs[1].logErr = errors.New("browse: walk commits: corrupt chunk")

	view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)

	require.Len(t, view.Groups, 2)
	assert.Nil(t, view.Groups[0].Head, "no head is renderable")
	assert.Len(t, view.Groups[0].Cards, 3, "the ready set is still answered")
}

func TestReadyFilters(t *testing.T) {
	in, dbs := readyFixture()

	t.Run("q narrows over id and title", func(t *testing.T) {
		view := across(in, dbs, NewReadyCache(), ReadyFilter{Query: "TOP priority"}, readyNow)
		require.Len(t, view.Groups, 1)
		require.Len(t, view.Groups[0].Cards, 1)
		assert.Equal(t, "a-1", view.Groups[0].Cards[0].ID)
		assert.Equal(t, 1, view.Total)
	})

	t.Run("assignee is exact", func(t *testing.T) {
		view := across(in, dbs, NewReadyCache(), ReadyFilter{Assignee: "alice"}, readyNow)
		require.Len(t, view.Groups, 1, "only alpha has alice's ready work")
		assert.Equal(t, "alice/alpha", view.Groups[0].Database.Slug())
		assert.Equal(t, 2, view.Total)
	})

	t.Run("priority is exact", func(t *testing.T) {
		view := across(in, dbs, NewReadyCache(), ReadyFilter{Priority: "2"}, readyNow)
		require.Len(t, view.Groups, 1)
		assert.Equal(t, 2, view.Total)
	})

	t.Run("db selects databases", func(t *testing.T) {
		// Its own fixture: this one asserts that a database was never opened, and
		// the shared one has been opened by every subtest above.
		in, dbs := readyFixture()
		view := across(in, dbs, NewReadyCache(), ReadyFilter{Databases: []string{"bob/beta"}}, readyNow)
		require.Len(t, view.Groups, 1)
		assert.Equal(t, "bob/beta", view.Groups[0].Database.Slug())
		assert.Equal(t, 1, view.Considered, "the other database is never opened")
		assert.Zero(t, in.dbs[1].opens)
	})

	t.Run("a filter that matches nothing empties the page", func(t *testing.T) {
		view := across(in, dbs, NewReadyCache(), ReadyFilter{Assignee: "nobody"}, readyNow)
		assert.Empty(t, view.Groups)
		assert.Zero(t, view.Total)
		// The options still list the real values, so the reader can pick another.
		assert.Equal(t, []string{"alice", "bob", "carol"}, view.Options.Assignees)
	})
}

func TestParseReadyFilter(t *testing.T) {
	q, err := url.ParseQuery("q=+cache+&assignee=alice&priority=1&db=alice%2Falpha&db=+&db=bob%2Fbeta")
	require.NoError(t, err)

	f := ParseReadyFilter(q)

	assert.Equal(t, "cache", f.Query)
	assert.Equal(t, "alice", f.Assignee)
	assert.Equal(t, "1", f.Priority)
	assert.Equal(t, []string{"alice/alpha", "bob/beta"}, f.Databases, "a blank ?db= is not a database")
	assert.True(t, f.Active())
	assert.False(t, ParseReadyFilter(url.Values{}).Active())
}

// The cache is bounded by entry count: it is a cache and not a store, and an
// instance with more databases than the ceiling must not grow it without end.
func TestReadyCacheIsBoundedByEntryCount(t *testing.T) {
	cache := NewReadyCache()
	for i := 1; i <= readyCacheMaxEntries+10; i++ {
		cache.store(i, readyEntry{head: fmt.Sprintf("h%d", i), at: readyNow, beads: true})
	}
	cache.mu.Lock()
	n := len(cache.entries)
	cache.mu.Unlock()
	assert.LessOrEqual(t, n, readyCacheMaxEntries)
}