Skip to content

Commit 9c46496

Browse files
authored
Merge pull request #767 from sipcapture/fix/ducklake-inline-table-leak
fix(ducklake): GC leaked inline tables to stop multi-GB RSS growth
2 parents 2efd323 + 0e3fb8c commit 9c46496

4 files changed

Lines changed: 143 additions & 24 deletions

File tree

src/storage/ducklake/ducklake.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,14 @@ func (mtw *MultiTableWriter) connect() error {
343343
if err := EnableSQLiteWALMode(mtw.config.CatalogPath); err != nil {
344344
logger.Warn(fmt.Sprintf("Failed to enable WAL mode for SQLite catalog (may cause lock errors): %v", err))
345345
}
346+
// Drain the leaked inline-table backlog before DuckLake opens the catalog
347+
// (upstream duckdb/ducklake#1065). Must run before ATTACH so we have
348+
// exclusive access to the sqlite file. Non-fatal.
349+
if n, err := GCOrphanInlineTables(mtw.config.CatalogPath); err != nil {
350+
logger.Warn("DuckLake inline GC failed (non-fatal)", "err", err)
351+
} else if n > 0 {
352+
logger.Info("DuckLake inline GC: dropped empty ducklake_inlined_data_* tables (upstream #1065)", "dropped", n)
353+
}
346354

347355
if err := ApplyDuckDBS3ClientSettings(db,
348356
mtw.config.S3Region,
@@ -736,3 +744,94 @@ func EnableSQLiteWALMode(catalogPath string) error {
736744

737745
return fmt.Errorf("failed to enable WAL mode, got: %s", outputStr)
738746
}
747+
748+
// duckLakeTableExists reports whether a table is already present in the
749+
// attached DuckLake catalog. Used to avoid re-issuing schema-bumping DDL
750+
// (SET PARTITIONED BY / SET SORTED BY) on every startup. Best-effort: any
751+
// query error is treated as "does not exist" so the caller falls back to the
752+
// safe (configure-the-table) path.
753+
func duckLakeTableExists(db *sql.DB, lakeName, tableName string) bool {
754+
row := db.QueryRow(
755+
"SELECT 1 FROM information_schema.tables WHERE table_catalog = ? AND table_name = ? LIMIT 1",
756+
lakeName, tableName)
757+
var x int
758+
return row.Scan(&x) == nil
759+
}
760+
761+
// runSQLiteCLI feeds a SQL script to the sqlite3 CLI on stdin and returns its
762+
// stdout. Used for catalog maintenance that must run with exclusive access,
763+
// before DuckDB ATTACHes the catalog.
764+
func runSQLiteCLI(catalogPath, sql string) (string, error) {
765+
cmd := exec.Command("sqlite3", "-batch", "-noheader", catalogPath)
766+
cmd.Stdin = strings.NewReader(sql)
767+
out, err := cmd.Output()
768+
return string(out), err
769+
}
770+
771+
func sqliteLines(catalogPath, sql string) ([]string, error) {
772+
out, err := runSQLiteCLI(catalogPath, sql)
773+
if err != nil {
774+
return nil, err
775+
}
776+
var lines []string
777+
for _, l := range strings.Split(out, "\n") {
778+
l = strings.TrimRight(l, "\r")
779+
if strings.TrimSpace(l) != "" {
780+
lines = append(lines, l)
781+
}
782+
}
783+
return lines, nil
784+
}
785+
786+
// GCOrphanInlineTables drops empty `ducklake_inlined_data_*` physical tables
787+
// left behind in the catalog. DuckLake never DROPs them: schema_version bumps
788+
// on every DDL (incl. SET PARTITIONED BY / SET SORTED BY), flush only DELETEs
789+
// the rows, and expire/cleanup remove registry rows but not the tables
790+
// (upstream bug duckdb/ducklake#1065). They accumulate per
791+
// (table_id, schema_version) and, although tiny on disk, the DuckLake
792+
// extension rebuilds an in-memory stats map over ALL of them on every catalog
793+
// refresh (DuckLakeCatalog::ConstructStatsMap) — which grows RSS to multiple GB.
794+
//
795+
// Must run BEFORE ATTACH, while the sqlite file is not yet opened by DuckDB, so
796+
// we have exclusive access (same window EnableSQLiteWALMode uses). Only EMPTY
797+
// tables are dropped: their rows were already flushed to Parquet, so this is
798+
// lossless. Best-effort; returns the number of tables dropped.
799+
func GCOrphanInlineTables(catalogPath string) (int, error) {
800+
if _, err := os.Stat(catalogPath); os.IsNotExist(err) {
801+
return 0, nil // fresh catalog, nothing to GC
802+
}
803+
804+
names, err := sqliteLines(catalogPath,
805+
`SELECT name FROM sqlite_master WHERE type='table' `+
806+
`AND name LIKE 'ducklake_inlined_data\_%' ESCAPE '\' `+
807+
`AND name <> 'ducklake_inlined_data_tables';`)
808+
if err != nil || len(names) == 0 {
809+
return 0, err
810+
}
811+
812+
// Identify the empty ones in a single query (one row per empty table).
813+
var q strings.Builder
814+
for i, n := range names {
815+
if i > 0 {
816+
q.WriteString("\nUNION ALL ")
817+
}
818+
fmt.Fprintf(&q, `SELECT '%s' WHERE (SELECT count(*) FROM "%s")=0`, n, n)
819+
}
820+
empties, err := sqliteLines(catalogPath, q.String())
821+
if err != nil || len(empties) == 0 {
822+
return 0, err
823+
}
824+
825+
var script strings.Builder
826+
script.WriteString("BEGIN;\n")
827+
for _, t := range empties {
828+
fmt.Fprintf(&script, "DROP TABLE IF EXISTS \"%s\";\n", t)
829+
fmt.Fprintf(&script, "DELETE FROM ducklake_inlined_data_tables WHERE table_name='%s';\n", t)
830+
}
831+
script.WriteString("COMMIT;\n")
832+
833+
if _, err := runSQLiteCLI(catalogPath, script.String()); err != nil {
834+
return 0, err
835+
}
836+
return len(empties), nil
837+
}

src/storage/ducklake/otlp_storage.go

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -142,24 +142,33 @@ func (s *OTLPStorage) EnsureOTLPSchema(ctx context.Context) error {
142142
return fmt.Errorf("otlp storage: nil database handle")
143143
}
144144
s.once.Do(func() {
145-
stmts := []string{
146-
fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s.otlp_traces (%s);", s.lakeName, otlpTracesTableSQL),
147-
fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s.otlp_metrics (%s);", s.lakeName, otlpMetricsTableSQL),
148-
fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s.otlp_logs (%s);", s.lakeName, otlpLogsTableSQL),
145+
tables := []struct {
146+
name string
147+
createSQL string
148+
}{
149+
{"otlp_traces", otlpTracesTableSQL},
150+
{"otlp_metrics", otlpMetricsTableSQL},
151+
{"otlp_logs", otlpLogsTableSQL},
149152
}
150-
for _, q := range stmts {
151-
if _, err := s.db.ExecContext(ctx, q); err != nil {
153+
for _, t := range tables {
154+
fqn := fmt.Sprintf("%s.%s", s.lakeName, t.name)
155+
// Check existence BEFORE create: SET PARTITIONED BY / SET SORTED BY
156+
// bump schema_version on every run, and each bump leaks another
157+
// ducklake_inlined_data_* table (upstream duckdb/ducklake#1065).
158+
// Only configure freshly created tables, not on every restart.
159+
alreadyExisted := duckLakeTableExists(s.db, s.lakeName, t.name)
160+
161+
if _, err := s.db.ExecContext(ctx,
162+
fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (%s);", fqn, t.createSQL)); err != nil {
152163
s.ddlErr = fmt.Errorf("otlp storage: %w", err)
153164
return
154165
}
155-
}
156-
// Best-effort: time-partition the OTLP tables the same way HEP
157-
// tables are partitioned so range scans hit a single date
158-
// folder. Failures are logged, not fatal — older DuckLake
159-
// builds without ALTER...PARTITIONED BY simply keep a single
160-
// partition.
161-
for _, t := range []string{"otlp_traces", "otlp_metrics", "otlp_logs"} {
162-
fqn := fmt.Sprintf("%s.%s", s.lakeName, t)
166+
if alreadyExisted {
167+
continue
168+
}
169+
// Best-effort: time-partition + sort the OTLP tables the same way
170+
// HEP tables are. Failures are logged, not fatal — older DuckLake
171+
// builds without ALTER...PARTITIONED BY keep a single partition.
163172
if _, err := s.db.ExecContext(ctx, fmt.Sprintf("ALTER TABLE %s SET PARTITIONED BY (date);", fqn)); err != nil {
164173
logger.Warn(fmt.Sprintf("otlp storage: failed to set partitioning for %s: %v", fqn, err))
165174
}

src/storage/ducklake/tables.go

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -474,22 +474,33 @@ func NewTableWriter(db *sql.DB, lakeName string, schema *TableSchema, batchSize
474474
tw.flushTruncateSQL[i] = "TRUNCATE TABLE " + tw.memTables[i]
475475
}
476476

477+
// Did the table already exist before this CREATE? SET PARTITIONED BY /
478+
// SET SORTED BY are DDL that bump the DuckLake schema_version every time
479+
// they run, and each new schema_version spawns another (leaked)
480+
// ducklake_inlined_data_* table (upstream duckdb/ducklake#1065). Re-issuing
481+
// them on every restart is pure churn, so only configure a freshly created
482+
// table.
483+
tableName := fmt.Sprintf("hep_proto_%s", schema.TableSuffix)
484+
alreadyExisted := duckLakeTableExists(db, lakeName, tableName)
485+
477486
// Create DuckLake persistent table
478487
createSQL := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (%s);", tableFQN, schema.CreateSQL)
479488
if _, err := db.Exec(createSQL); err != nil {
480489
return nil, fmt.Errorf("failed to create table %s: %w", tableFQN, err)
481490
}
482491

483-
// Set partitioning by date for efficient time-range queries
484-
partitionSQL := fmt.Sprintf("ALTER TABLE %s SET PARTITIONED BY (date);", tableFQN)
485-
if _, err := db.Exec(partitionSQL); err != nil {
486-
logger.Warn(fmt.Sprintf("Failed to set partitioning for %s: %v", tableFQN, err))
487-
}
492+
if !alreadyExisted {
493+
// Set partitioning by date for efficient time-range queries
494+
partitionSQL := fmt.Sprintf("ALTER TABLE %s SET PARTITIONED BY (date);", tableFQN)
495+
if _, err := db.Exec(partitionSQL); err != nil {
496+
logger.Warn(fmt.Sprintf("Failed to set partitioning for %s: %v", tableFQN, err))
497+
}
488498

489-
// Sort rows by timestamp within each file (DuckLake v1.0).
490-
sortSQL := fmt.Sprintf("ALTER TABLE %s SET SORTED BY (timestamp ASC);", tableFQN)
491-
if _, err := db.Exec(sortSQL); err != nil {
492-
logger.Warn(fmt.Sprintf("Failed to set sort order for %s: %v", tableFQN, err))
499+
// Sort rows by timestamp within each file (DuckLake v1.0).
500+
sortSQL := fmt.Sprintf("ALTER TABLE %s SET SORTED BY (timestamp ASC);", tableFQN)
501+
if _, err := db.Exec(sortSQL); err != nil {
502+
logger.Warn(fmt.Sprintf("Failed to set sort order for %s: %v", tableFQN, err))
503+
}
493504
}
494505

495506
// Create both in-memory buffer tables (plain DuckDB, not DuckLake)

src/version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import (
2424
// Version information for homer-core
2525
var (
2626
// VERSION_APPLICATION is the application version
27-
VERSION_APPLICATION = "11.0.234"
27+
VERSION_APPLICATION = "11.0.235"
2828

2929
// BuildDate is the build date
3030
BuildDate = ""

0 commit comments

Comments
 (0)