@@ -204,7 +204,16 @@ func (c *CompactionService) warnMaintenanceS3Failure(op string, err error) {
204204// Start begins the compaction service
205205func (c * CompactionService ) Start () error {
206206 if ! c .config .Enable {
207- logger .Info ("CompactionService disabled" )
207+ // Compaction (merge/expire/cleanup) is off, but inlined data must
208+ // still be drained. Disabling data inlining only stops NEW inlining;
209+ // rows inlined earlier (e.g. a catalog created before inlining was
210+ // disabled) stay in the catalog and resident in the DuckLake
211+ // extension's memory until flushed. Without this, an upgraded node
212+ // with compaction off would never release its legacy inline backlog.
213+ logger .Info ("CompactionService: compaction disabled; starting inline-flush-only maintenance" ,
214+ "interval" , fmt .Sprintf ("%ds" , c .config .CheckIntervalSec ))
215+ c .wg .Add (1 )
216+ go c .inlineFlushOnlyLoop ()
208217 return nil
209218 }
210219
@@ -301,6 +310,67 @@ func (c *CompactionService) withCatalogLock(fn func()) {
301310 fn ()
302311}
303312
313+ // flushInlinedData drains DuckLake inlined rows into Parquet and drops the
314+ // backing ducklake_inlined_data_* tables. Disabling data inlining only stops
315+ // NEW inlining; rows inlined earlier stay in the catalog and resident in the
316+ // DuckLake extension's memory until flushed. Cheap no-op once nothing is
317+ // inlined. Shared by the full compaction cycle (step 0) and the flush-only
318+ // loop used when compaction is disabled.
319+ func (c * CompactionService ) flushInlinedData () {
320+ c .withCatalogLock (func () {
321+ c .ensureS3ClientSettings ()
322+ logger .Info ("CompactionService: Flush inlined data" , "lake" , c .lakeName )
323+ flushSQL := fmt .Sprintf ("CALL ducklake_flush_inlined_data('%s')" , c .lakeName )
324+ if _ , err := c .execWithRetry (flushSQL ); err != nil {
325+ logger .Warn ("CompactionService: flush_inlined_data failed" , "error" , err )
326+ }
327+ })
328+ }
329+
330+ // inlineFlushOnlyLoop periodically drains inlined data when full compaction is
331+ // disabled, so an upgraded node with compaction off still releases its legacy
332+ // inline backlog from the DuckLake extension's memory. Paced by
333+ // CheckIntervalSec; the flush is a no-op once nothing is inlined.
334+ func (c * CompactionService ) inlineFlushOnlyLoop () {
335+ defer c .wg .Done ()
336+ defer func () {
337+ if r := recover (); r != nil {
338+ logger .Error ("CompactionService: inline-flush loop panic" , "panic" , r )
339+ }
340+ }()
341+
342+ interval := time .Duration (c .config .CheckIntervalSec ) * time .Second
343+ if interval <= 0 {
344+ interval = time .Hour
345+ }
346+
347+ // First run shortly after startup so a legacy backlog drains promptly
348+ // instead of waiting a full interval.
349+ firstDelay := 1 * time .Minute
350+ if interval < firstDelay {
351+ firstDelay = interval
352+ }
353+ timer := time .NewTimer (firstDelay )
354+ select {
355+ case <- c .ctx .Done ():
356+ timer .Stop ()
357+ return
358+ case <- timer .C :
359+ c .flushInlinedData ()
360+ }
361+
362+ ticker := time .NewTicker (interval )
363+ defer ticker .Stop ()
364+ for {
365+ select {
366+ case <- c .ctx .Done ():
367+ return
368+ case <- ticker .C :
369+ c .flushInlinedData ()
370+ }
371+ }
372+ }
373+
304374// runCompaction performs a single compaction cycle.
305375// Instead of holding the catalog lock for the entire cycle (which can take
306376// minutes and block flush + queries), the lock is acquired and released
@@ -424,21 +494,10 @@ func (c *CompactionService) runMerge(tables []string) error {
424494 logger .Info ("CompactionService: Active snapshots before merge" , "count" , snapshotCount )
425495 }
426496
427- // 0. Flush inlined data to Parquet FIRST. DuckLake inlines small writes
428- // (DATA_INLINING_ROW_LIMIT) directly into the catalog DB; with inlining
429- // left enabled and no periodic flush, those rows accumulate inside the
430- // catalog forever — an 800 MB catalog backing only a few dozen Parquet
431- // files is the classic symptom, and DuckLake mirrors the catalog in
432- // memory (multi-GB RSS). Flushing first also lets the subsequent merge /
433- // expire act on freshly written Parquet instead of catalog-resident rows.
434- // Harmless no-op when inlining is disabled (the recommended default).
435- c .withCatalogLock (func () {
436- logger .Info ("CompactionService: Flush inlined data" , "lake" , c .lakeName )
437- flushSQL := fmt .Sprintf ("CALL ducklake_flush_inlined_data('%s')" , c .lakeName )
438- if _ , err := c .execWithRetry (flushSQL ); err != nil {
439- logger .Warn ("CompactionService: flush_inlined_data failed" , "error" , err )
440- }
441- })
497+ // 0. Flush inlined data to Parquet FIRST, so the subsequent merge/expire
498+ // act on freshly written Parquet rather than catalog-resident rows. See
499+ // flushInlinedData for why this matters even when inlining is disabled.
500+ c .flushInlinedData ()
442501
443502 // 1. Merge adjacent small files FIRST — lock per table
444503 for _ , table := range tables {
0 commit comments