@@ -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 ("\n UNION 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+ }
0 commit comments