// Copyright 2026 Dolthub, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "database/sql" "fmt" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" driver "github.com/dolthub/doltgresql/integration-tests/go-sql-server-driver/driver" ) // Archive levels accepted by dolt_gc's --archive-level option. These mirror // chunks.GCArchiveLevel in Dolt (NoArchive=0, SimpleArchive=1); we use plain // ints here to avoid depending on Dolt's internal store/chunks package. const ( noArchive = 0 simpleArchive = 1 ) func TestGCIncremental(t *testing.T) { t.Parallel() archiveLevels := []int{noArchive, simpleArchive} // smallFileSize is chosen so that every leaf chunk gets put in its own table file. In this test, we know // exactly how many table files to expect after GC. smallFileSize := 1 // mediumFileSize is chosen so that multiple chunks are put into each table file, and ensures that we correctly handle // an in-progress table file at the end of the mark-and-sweep. mediumFileSize := 5_000 // largeFileSize is arbitrarily large and tests the case where all leaf chunks fit in a single table file. largeFileSize := 1_000_0000 fileSizes := []int{smallFileSize, mediumFileSize, largeFileSize} for _, archiveLevel := range archiveLevels { t.Run(fmt.Sprintf("archiveLevel=%d", archiveLevel), func(t *testing.T) { t.Parallel() for _, fileSize := range fileSizes { t.Run(fmt.Sprintf("fileSize=%d", fileSize), func(t *testing.T) { t.Parallel() for _, full := range []bool{true, false} { t.Run(fmt.Sprintf("full=%v", full), func(t *testing.T) { t.Parallel() runGCIncrementalTest(t, archiveLevel, fileSize, full) }) } }) } }) } } func runGCIncrementalTest(t *testing.T, archiveLevel int, fileSize int, full bool) { ports := newPorts(t) u, err := driver.NewDoltUser() require.NoError(t, err) t.Cleanup(func() { u.Cleanup() }) rs, err := u.MakeRepoStore() require.NoError(t, err) repoName := fmt.Sprintf("incremental_gc_test_archivelevel_%d_filesize_%d_full_%v", archiveLevel, fileSize, full) repo, err := rs.MakeRepo(repoName) require.NoError(t, err) ctx := context.Background() server := StartServer(t, rs, repoName, &driver.Server{ Args: []string{}, DynamicPort: "server", }, ports) // Create the database and run GC func() { db, err := server.DB(driver.Connection{}) require.NoError(t, err) defer db.Close() conn, err := db.Conn(ctx) require.NoError(t, err) defer conn.Close() populateDB(t, conn) // dolt_gc takes its options as string arguments; build the statement // with literals rather than bind parameters so the string-typed args // are passed exactly as the procedure expects. gcSQL := fmt.Sprintf("select dolt_gc('--archive-level','%d','--incremental-file-size','%d')", archiveLevel, fileSize) if full { gcSQL = fmt.Sprintf("select dolt_gc('--archive-level','%d','--incremental-file-size','%d','--full')", archiveLevel, fileSize) } _, err = conn.ExecContext(ctx, gcSQL) require.NoError(t, err) }() // Verify that the DB is in a valid state after GC. Doltgres does not expose // an fsck command (Dolt's `dolt fsck` CLI / dolt_fsck() are unavailable // here), so as a stand-in we reconnect (the previous connection is // invalidated by the online GC) and confirm the database is still // queryable and the committed data survived. requireDatabaseQueryable(t, server) repoSize, err := GetRepoSize(repo.Dir) require.NoError(t, err) // Both newgen and oldgen should contain multiple chunk files require.Greater(t, repoSize.NewGenC, 1) require.Greater(t, repoSize.OldGenC, 1) } // requireDatabaseQueryable opens a fresh connection to the server and confirms // the committed table is readable. A successful online GC invalidates prior // connections, so callers must not reuse a connection held across a GC. func requireDatabaseQueryable(t *testing.T, server *driver.SqlServer) { ctx := context.Background() db, err := server.DB(driver.Connection{}) require.NoError(t, err) defer db.Close() var n int err = db.QueryRowContext(ctx, "select count(*) from vals").Scan(&n) require.NoError(t, err) require.Greater(t, n, 0) } func populateDB(t *testing.T, conn *sql.Conn) { ctx := context.Background() // Create some chunks that will be collected into oldgen because they're referenced by a commit _, err := conn.ExecContext(ctx, "create table vals (id bigint primary key, val bigint)") require.NoError(t, err) vals := []string{} for i := 0; i <= 1024; i++ { vals = append(vals, fmt.Sprintf("(%d,0)", i)) } _, err = conn.ExecContext(ctx, "insert into vals values "+strings.Join(vals, ",")) require.NoError(t, err) _, err = conn.ExecContext(ctx, "select dolt_commit('-Am', 'create vals table')") require.NoError(t, err) // Create some chunks that will be collected into newgen because they aren't referenced by a commit _, err = conn.ExecContext(ctx, "create table vals2 (id bigint primary key, val bigint)") require.NoError(t, err) _, err = conn.ExecContext(ctx, "insert into vals2 values "+strings.Join(vals, ",")) require.NoError(t, err) } type RepoSize struct { Journal int64 NewGen int64 NewGenC int OldGen int64 OldGenC int } func GetRepoSize(dir string) (RepoSize, error) { skipFile := func(filename string) bool { return filename == "manifest" || filename == "LOCK" } var ret RepoSize entries, err := os.ReadDir(filepath.Join(dir, ".dolt/noms")) if err != nil { return ret, err } for _, e := range entries { if skipFile(e.Name()) { continue } stat, err := e.Info() if err != nil { return ret, err } if stat.IsDir() { continue } ret.NewGen += stat.Size() ret.NewGenC += 1 if e.Name() == "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv" { ret.Journal += stat.Size() } } entries, err = os.ReadDir(filepath.Join(dir, ".dolt/noms/oldgen")) if err != nil { return ret, err } for _, e := range entries { if skipFile(e.Name()) { continue } stat, err := e.Info() if err != nil { return ret, err } if stat.IsDir() { continue } ret.OldGen += stat.Size() ret.OldGenC += 1 } return ret, nil } func TestResumableGC(t *testing.T) { t.Parallel() ports := newPorts(t) u, err := driver.NewDoltUser() require.NoError(t, err) t.Cleanup(func() { u.Cleanup() }) rs, err := u.MakeRepoStore() require.NoError(t, err) repoName := "resumable_gc_test" repo, err := rs.MakeRepo(repoName) require.NoError(t, err) ctx := context.Background() server := StartServer(t, rs, repoName, &driver.Server{ Args: []string{}, DynamicPort: "server", Envs: []string{"DOLT_TEST_ABORT_GC_AFTER_INCREMENTAL_FILE_WRITE=true"}, }, ports) // Create the database and run GC func() { db, err := server.DB(driver.Connection{}) require.NoError(t, err) defer db.Close() conn, err := db.Conn(ctx) require.NoError(t, err) defer conn.Close() populateDB(t, conn) // Test 1: Oldgen is not amended when --full is set _, err = conn.ExecContext(ctx, "select dolt_gc('--archive-level','0','--incremental-file-size','1','--full')") require.Errorf(t, err, "GC aborting after writing incremental table file") requireFileAddedToManifest(t, filepath.Join(repo.Dir, ".dolt/noms/oldgen"), false) // Test 2: Oldgen is amended when --full is not set _, err = conn.ExecContext(ctx, "select dolt_gc('--archive-level','0','--incremental-file-size','1')") require.Errorf(t, err, "GC aborting after writing incremental table file") requireFileAddedToManifest(t, filepath.Join(repo.Dir, ".dolt/noms/oldgen"), true) _, err = conn.ExecContext(ctx, "select dolt_gc('--archive-level','0','--incremental-file-size','1')") // Test 3: Newgen is not amended even when --full is not set (but there are no changes to oldgen) _, err = conn.ExecContext(ctx, "insert into vals2 values (1025, 0)") require.NoError(t, err) _, err = conn.ExecContext(ctx, "select dolt_gc('--archive-level','0','--incremental-file-size','1')") require.Errorf(t, err, "GC aborting after writing incremental table file") requireFileAddedToManifest(t, filepath.Join(repo.Dir, ".dolt/noms/oldgen"), false) }() } func requireFileAddedToManifest(t *testing.T, path string, expectedResult bool) { // Verify that the manifest contains the new chunk file manifest, err := os.Open(filepath.Join(path, "manifest")) if os.IsNotExist(err) { // A nonexistent manifest contains no files require.False(t, expectedResult) return } require.NoError(t, err) entries, err := os.ReadDir(path) require.NoError(t, err) var tablefileName string for _, e := range entries { if e.Name() == "manifest" || e.Name() == "LOCK" { continue } if e.IsDir() { continue } tablefileName = e.Name() break } require.NotEmpty(t, tablefileName) manifestBuffer := make([]byte, 200) _, err = manifest.Read(manifestBuffer) require.NoError(t, err) if expectedResult { require.Contains(t, string(manifestBuffer), tablefileName) } else { require.NotContains(t, string(manifestBuffer), tablefileName) } }