Skip to content

Commit 66bc76f

Browse files
authored
ci: reduce Windows Go test critical path (#1335)
Windows Go tests became the CI critical path after redundant Linux work was removed. Recent successful jobs spend nearly all of their wall time inside the Go test command, with real SQLite fixture setup and the CGo-heavy DuckDB package competing for four runner cores; adding broad `t.Parallel` calls would increase contention without addressing that work. The suite now constructs high-cardinality SQLite fixtures transactionally while preserving the production triggers, scale comparisons, and 1,024-row claim boundary they protect. The ordinary database suite deterministically verifies the closed-writer fallback, while the concurrent close/reopen stress case remains in the existing Linux race job. On Windows, DuckDB runs on an independent runner and the main package set avoids verbose log I/O, allowing both long test groups to advance concurrently. This trades additional Windows runner-minutes for a shorter required-check critical path. The principal review points are the package split in `.github/workflows/ci.yml`, the deterministic/race coverage split in `internal/db`, and the transaction-backed fixtures in `internal/artifact`, `internal/parser`, and `internal/sync`. Co-authored-by: Wes McKinney <wesm@users.noreply.github.com>
1 parent f0942ab commit 66bc76f

10 files changed

Lines changed: 266 additions & 98 deletions

.github/workflows/ci.yml

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,24 @@ jobs:
164164
- name: Restore pricing snapshot
165165
run: go run ./internal/pricing/cmd/litellm-snapshot -restore
166166

167-
- name: Run Go tests
168-
run: go test -tags "fts5" ${{ runner.os == 'Linux' && '-coverprofile=coverage.out' || '' }} ./... -v -count=1 -timeout=20m
167+
- name: Run Go tests (Linux)
168+
if: runner.os == 'Linux'
169+
run: go test -tags "fts5" -coverprofile=coverage.out ./... -v -count=1 -timeout=20m
170+
env:
171+
CGO_ENABLED: "1"
172+
173+
- name: Run Go tests (Windows)
174+
if: runner.os == 'Windows'
175+
shell: pwsh
176+
run: |
177+
# DuckDB runs in its own job below so its CGo-heavy package does not
178+
# occupy one of this four-core runner's package workers for minutes.
179+
$packages = @(go list ./...)
180+
if ($LASTEXITCODE -ne 0) {
181+
exit $LASTEXITCODE
182+
}
183+
$packages = $packages | Where-Object { $_ -ne 'go.kenn.io/agentsview/internal/duckdb' }
184+
go test -tags "fts5" $packages -count=1 -timeout=20m
169185
env:
170186
CGO_ENABLED: "1"
171187

@@ -185,6 +201,47 @@ jobs:
185201
if: runner.os == 'Linux' && steps.codecov.outcome == 'failure'
186202
run: echo "::warning::Codecov upload failed"
187203

204+
# Keep the derived-mirror suite concurrent with the main Windows package set.
205+
test-duckdb-windows:
206+
name: Go Test (windows-latest, DuckDB)
207+
runs-on: windows-latest
208+
steps:
209+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
210+
with:
211+
fetch-depth: 0
212+
persist-credentials: false
213+
214+
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
215+
with:
216+
go-version-file: go.mod
217+
218+
- name: Setup MinGW
219+
id: setup_mingw
220+
continue-on-error: true
221+
uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2
222+
with:
223+
msystem: MINGW64
224+
update: false
225+
install: mingw-w64-x86_64-gcc
226+
path-type: inherit
227+
228+
- name: Setup MinGW (retry on transient failure)
229+
if: steps.setup_mingw.outcome == 'failure'
230+
uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2
231+
with:
232+
msystem: MINGW64
233+
update: false
234+
install: mingw-w64-x86_64-gcc
235+
path-type: inherit
236+
237+
- name: Restore pricing snapshot
238+
run: go run ./internal/pricing/cmd/litellm-snapshot -restore
239+
240+
- name: Run DuckDB tests
241+
run: go test -tags "fts5" ./internal/duckdb -count=1 -timeout=20m
242+
env:
243+
CGO_ENABLED: "1"
244+
188245
test-race:
189246
name: Go Test (race detector)
190247
runs-on: ${{ github.repository == 'kenn-io/agentsview' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.base.repo.full_name == github.repository)) && 'kenn-linux-x64-public' || 'ubuntu-latest' }}

internal/artifact/export_test.go

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package artifact
22

33
import (
44
"context"
5+
"database/sql"
56
"encoding/json"
67
"errors"
78
"fmt"
@@ -45,6 +46,38 @@ func testExportDB(t *testing.T) *db.DB {
4546
return database
4647
}
4748

49+
// seedBareExportSessions inserts the minimal rows these export cardinality
50+
// tests need in one transaction. The real sessions-table export triggers still
51+
// enqueue every local row; parser validation and message replacement are not
52+
// part of the behavior under test here.
53+
func seedBareExportSessions(
54+
t *testing.T,
55+
database *db.DB,
56+
count int,
57+
idFormat string,
58+
machine string,
59+
) {
60+
t.Helper()
61+
ctx := t.Context()
62+
require.NoError(t, database.Update(func(tx *sql.Tx) error {
63+
stmt, err := tx.PrepareContext(ctx, `INSERT INTO sessions
64+
(id, project, machine, agent, created_at)
65+
VALUES (?, 'project', ?, 'claude', '2026-06-14T01:02:03Z')`)
66+
if err != nil {
67+
return err
68+
}
69+
defer func() { _ = stmt.Close() }()
70+
for i := range count {
71+
if _, err := stmt.ExecContext(
72+
ctx, fmt.Sprintf(idFormat, i), machine,
73+
); err != nil {
74+
return err
75+
}
76+
}
77+
return nil
78+
}), "seed bare export sessions")
79+
}
80+
4881
// latestStoreCheckpointForTest locates the highest-sequence checkpoint for
4982
// origin and decodes its full session map. It stands in for peer.go's
5083
// latestStoreCheckpointSummary (out of PR2 scope; see plan hazard 6) so
@@ -1421,12 +1454,9 @@ func TestArtifactExportCardinalityLoadsOnlyDirtyBatch(t *testing.T) {
14211454
for _, archiveSize := range []int{20, 2000} {
14221455
t.Run(fmt.Sprintf("archive-%d", archiveSize), func(t *testing.T) {
14231456
database := testExportDB(t)
1424-
for i := range archiveSize {
1425-
require.NoError(t, database.UpsertSession(db.Session{
1426-
ID: fmt.Sprintf("peer-%04d", i), Project: "project",
1427-
Machine: "peer-a1b2c3", Agent: "claude",
1428-
}))
1429-
}
1457+
seedBareExportSessions(
1458+
t, database, archiveSize, "peer-%04d", "peer-a1b2c3",
1459+
)
14301460
require.NoError(t, database.UpsertSession(db.Session{
14311461
ID: "dirty", Project: "project", Machine: "local", Agent: "claude",
14321462
}))
@@ -1463,12 +1493,9 @@ func TestExportToStoreCardinalityIgnoresUnrelatedArchiveBodies(t *testing.T) {
14631493
for _, archiveSize := range []int{20, 2000} {
14641494
t.Run(fmt.Sprintf("archive-%d", archiveSize), func(t *testing.T) {
14651495
database := testExportDB(t)
1466-
for i := range archiveSize {
1467-
require.NoError(t, database.UpsertSession(db.Session{
1468-
ID: fmt.Sprintf("peer-%04d", i), Project: "project",
1469-
Machine: "peer-a1b2c3", Agent: "claude",
1470-
}))
1471-
}
1496+
seedBareExportSessions(
1497+
t, database, archiveSize, "peer-%04d", "peer-a1b2c3",
1498+
)
14721499
seedSession(t, database, "dirty", "project")
14731500
counted := &countingCanonicalExportDB{DB: database}
14741501
filesystem, err := newProtocolTestStore(t.TempDir())
@@ -1536,12 +1563,9 @@ func TestExportToStoreFullDrainsMoreThanOneClaimPage(t *testing.T) {
15361563

15371564
database := testExportDB(t)
15381565
const total = 1025
1539-
for i := range total {
1540-
require.NoError(t, database.UpsertSession(db.Session{
1541-
ID: fmt.Sprintf("session-%04d", i), Project: "project",
1542-
Machine: "local", Agent: "claude", CreatedAt: "2026-06-14T01:02:03Z",
1543-
}))
1544-
}
1566+
seedBareExportSessions(
1567+
t, database, total, "session-%04d", "local",
1568+
)
15451569
counted := &countingCanonicalExportDB{DB: database}
15461570
filesystem, err := newProtocolTestStore(t.TempDir())
15471571
require.NoError(t, err)
@@ -1577,12 +1601,7 @@ func TestExportToStoreExplicitSessionIDsClaimBeyondOldestQueuePage(t *testing.T)
15771601

15781602
database := testExportDB(t)
15791603
const total = 1025
1580-
for i := range total {
1581-
require.NoError(t, database.UpsertSession(db.Session{
1582-
ID: fmt.Sprintf("session-%04d", i), Project: "project",
1583-
Machine: "local", Agent: "claude",
1584-
}))
1585-
}
1604+
seedBareExportSessions(t, database, total, "session-%04d", "local")
15861605
filesystem, err := newProtocolTestStore(t.TempDir())
15871606
require.NoError(t, err)
15881607
t.Cleanup(func() { require.NoError(t, filesystem.Close()) })
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//go:build race
2+
3+
package db
4+
5+
import (
6+
"context"
7+
"sync"
8+
"testing"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// TestOutcomeStatsWriterCloseRaceDoesNotPanic exercises the maintenance
14+
// writer transition under the race detector. The normal suite covers the
15+
// closed-writer state deterministically without paying for hundreds of git
16+
// scans on every platform.
17+
func TestOutcomeStatsWriterCloseRaceDoesNotPanic(t *testing.T) {
18+
skipIfNoGit(t)
19+
d := testDB(t)
20+
repo := statsOutcomeRepo(t)
21+
insertSessionFixture(t, d, sessionFixture{
22+
id: "writer-race", agent: "claude", userMsgs: 5,
23+
startedAt: hoursAgo(5), cwd: repo,
24+
})
25+
26+
done := make(chan struct{})
27+
toggleErr := make(chan error, 1)
28+
var wg sync.WaitGroup
29+
wg.Go(func() {
30+
for {
31+
select {
32+
case <-done:
33+
return
34+
default:
35+
}
36+
if err := d.CloseWriter(); err != nil {
37+
toggleErr <- err
38+
return
39+
}
40+
if err := d.ReopenWriter(); err != nil {
41+
toggleErr <- err
42+
return
43+
}
44+
}
45+
})
46+
47+
for range 300 {
48+
// The race detector validates synchronization; transient errors while
49+
// the writer is closed are acceptable for this stress case.
50+
_, _ = d.GetSessionStats(context.Background(), StatsFilter{
51+
Since: "28d", IncludeGitOutcomes: true,
52+
})
53+
}
54+
close(done)
55+
wg.Wait()
56+
select {
57+
case err := <-toggleErr:
58+
require.NoError(t, err, "writer toggling failed")
59+
default:
60+
}
61+
}

internal/db/session_stats_test.go

Lines changed: 13 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2612,54 +2612,28 @@ func TestGetSessionStats_OutcomeStats_Happy(t *testing.T) {
26122612
assert.Nil(t, out.PRsMerged, "PRsMerged want nil (no GHToken)")
26132613
}
26142614

2615-
// TestOutcomeStatsWriterCloseRaceDoesNotPanic guards the writer snapshot in
2616-
// computeOutcomeStats: closing the writer for a maintenance pass concurrently
2617-
// with the git outcome-stats path must fall back to the read-only cache and
2618-
// never hand git.NewCache a nil writer pool (which would panic on first use).
2619-
func TestOutcomeStatsWriterCloseRaceDoesNotPanic(t *testing.T) {
2615+
// TestOutcomeStatsClosedWriterUsesReadOnlyCache guards the writer snapshot in
2616+
// computeOutcomeStats: when a maintenance pass has closed the writer, the git
2617+
// outcome-stats path must use the read-only cache and return the same result.
2618+
// The concurrent close/reopen stress case lives in session_stats_race_test.go.
2619+
func TestOutcomeStatsClosedWriterUsesReadOnlyCache(t *testing.T) {
26202620
skipIfNoGit(t)
26212621
d := testDB(t)
26222622
ctx := context.Background()
26232623
repo := statsOutcomeRepo(t)
26242624
insertSessionFixture(t, d, sessionFixture{
2625-
id: "race1", agent: "claude", userMsgs: 5,
2625+
id: "closed-writer", agent: "claude", userMsgs: 5,
26262626
startedAt: hoursAgo(5), cwd: repo,
26272627
})
26282628

2629-
done := make(chan struct{})
2630-
toggleErr := make(chan error, 1)
2631-
var wg sync.WaitGroup
2632-
wg.Go(func() {
2633-
for {
2634-
select {
2635-
case <-done:
2636-
return
2637-
default:
2638-
}
2639-
if err := d.CloseWriter(); err != nil {
2640-
toggleErr <- err
2641-
return
2642-
}
2643-
if err := d.ReopenWriter(); err != nil {
2644-
toggleErr <- err
2645-
return
2646-
}
2647-
}
2629+
require.NoError(t, d.CloseWriter(), "close writer")
2630+
stats, err := d.GetSessionStats(ctx, StatsFilter{
2631+
Since: "28d", IncludeGitOutcomes: true,
26482632
})
2649-
2650-
for range 300 {
2651-
// Must never panic; a transient error while the writer is closed is fine.
2652-
_, _ = d.GetSessionStats(ctx, StatsFilter{
2653-
Since: "28d", IncludeGitOutcomes: true,
2654-
})
2655-
}
2656-
close(done)
2657-
wg.Wait()
2658-
select {
2659-
case err := <-toggleErr:
2660-
require.NoError(t, err, "writer toggling failed")
2661-
default:
2662-
}
2633+
require.NoError(t, err, "GetSessionStats with closed writer")
2634+
require.NotNil(t, stats.OutcomeStats, "OutcomeStats")
2635+
assert.Equal(t, 1, stats.OutcomeStats.ReposActive, "ReposActive")
2636+
assert.Equal(t, 3, stats.OutcomeStats.Commits, "Commits")
26632637
}
26642638

26652639
// TestGetSessionStats_OutcomeStats_NoCwd verifies that sessions without

internal/parser/hermes_provider_test.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -842,19 +842,23 @@ func TestHermesMemberCoreSeedRetainedIDBytesStayBounded(t *testing.T) {
842842
stateDB := filepath.Join(root, "state.db")
843843
conn, err := sql.Open("sqlite3", stateDB)
844844
require.NoError(t, err)
845-
_, err = conn.Exec("DELETE FROM messages; DELETE FROM sessions")
845+
tx, err := conn.Begin()
846+
require.NoError(t, err)
847+
defer func() { _ = tx.Rollback() }()
848+
_, err = tx.Exec("DELETE FROM messages; DELETE FROM sessions")
846849
require.NoError(t, err)
847850
for i := range sessionCount {
848851
id := fmt.Sprintf("member-%06d", i)
849-
_, err = conn.Exec(`INSERT INTO sessions
852+
_, err = tx.Exec(`INSERT INTO sessions
850853
(id, source, started_at, estimated_cost_usd, actual_cost_usd)
851854
VALUES (?, 'cli', ?, 0, 0)`, id, i)
852855
require.NoError(t, err)
853-
_, err = conn.Exec(`INSERT INTO messages
856+
_, err = tx.Exec(`INSERT INTO messages
854857
(session_id, role, content, timestamp)
855858
VALUES (?, 'user', 'hello', ?)`, id, i)
856859
require.NoError(t, err)
857860
}
861+
require.NoError(t, tx.Commit())
858862
require.NoError(t, conn.Close())
859863

860864
var retained, peak int64

internal/parser/opencode_provider_test.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1386,13 +1386,15 @@ func TestOpenCodeChangedPathWatermarkMergeMaterializesOnlyChangedBatch(
13861386
seeder.AddProject("proj", "/home/user/app")
13871387
const base = int64(1779012000000)
13881388
var stored []StoredMemberFreshness
1389-
for i := range sessions {
1390-
id := fmt.Sprintf("ses-%06d", i)
1391-
seeder.AddSession(id, "proj", "", id, base, base)
1392-
stored = append(stored, StoredMemberFreshness{
1393-
Path: dbPath + "#" + id, CoveredThroughNS: base * 1_000_000,
1394-
})
1395-
}
1389+
seeder.InTransaction(func(seeder *OpenCodeSeeder) {
1390+
for i := range sessions {
1391+
id := fmt.Sprintf("ses-%06d", i)
1392+
seeder.AddSession(id, "proj", "", id, base, base)
1393+
stored = append(stored, StoredMemberFreshness{
1394+
Path: dbPath + "#" + id, CoveredThroughNS: base * 1_000_000,
1395+
})
1396+
}
1397+
})
13961398
// One session advances past its stored coverage.
13971399
changed := fmt.Sprintf("ses-%06d", sessions/2)
13981400
_, err := seeder.db.ExecContext(t.Context(),

0 commit comments

Comments
 (0)