Repository: questdb/questdb
Pull request #5706: fix(sql): potential memory leak in plain INSERTs
Author: login: puzpuzpuz
Labels: name: Bug
description: Incorrect or unexpected behavior
name: SQL
description: Issues or changes relating to SQL execution
Created: 2025-05-29T18:17:05Z | Merged: 2025-05-30T15:09:48Z

Fixes potential memory leak in plain INSERTs: timestamp and record SQL functions were never closed


Diff:
diff --git a/core/src/main/java/io/questdb/cairo/CairoEngine.java b/core/src/main/java/io/questdb/cairo/CairoEngine.java
index 3659930bd5c8..bb5db735ada3 100644
--- a/core/src/main/java/io/questdb/cairo/CairoEngine.java
+++ b/core/src/main/java/io/questdb/cairo/CairoEngine.java
@@ -237,14 +237,14 @@ public static void execute(
             SqlExecutionContext sqlExecutionContext,
             @Nullable SCSequence eventSubSeq
     ) throws SqlException {
-        CompiledQuery cc = compiler.compile(sqlText, sqlExecutionContext);
-        switch (cc.getType()) {
+        CompiledQuery cq = compiler.compile(sqlText, sqlExecutionContext);
+        switch (cq.getType()) {
             case CREATE_TABLE:
             case CREATE_TABLE_AS_SELECT:
             case CREATE_MAT_VIEW:
             case DROP:
                 assert sqlExecutionContext.getCairoEngine() == compiler.getEngine();
-                try (Operation op = cc.getOperation()) {
+                try (Operation op = cq.getOperation()) {
                     assert op != null;
                     try (OperationFuture fut = op.execute(sqlExecutionContext, null)) {
                         fut.await();
@@ -253,41 +253,18 @@ public static void execute(
                 break;
             case INSERT:
             case INSERT_AS_SELECT:
-                insert(cc, sqlText, sqlExecutionContext);
+                insert(cq, sqlExecutionContext);
                 break;
             case SELECT:
                 throw SqlException.$(0, "use select()");
             default:
-                try (OperationFuture future = cc.execute(eventSubSeq)) {
+                try (OperationFuture future = cq.execute(eventSubSeq)) {
                     future.await();
                 }
                 break;
         }
     }
 
-    public static void insert(
-            CompiledQuery cq,
-            CharSequence insertSql,
-            SqlExecutionContext sqlExecutionContext
-    ) throws SqlException {
-        switch (cq.getType()) {
-            case INSERT:
-            case INSERT_AS_SELECT:
-                try (InsertOperation insertOperation = cq.popInsertOperation();
-                     InsertMethod insertMethod = insertOperation.createMethod(sqlExecutionContext)) {
-                    insertMethod.execute(sqlExecutionContext);
-                    insertMethod.commit();
-                }
-                break;
-            case SELECT:
-                throw SqlException.$(0, "use select()");
-            case DROP:
-                throw SqlException.$(0, "use drop()");
-            default:
-                throw SqlException.$(0, "use ddl()");
-        }
-    }
-
     public static RecordCursorFactory select(SqlCompiler compiler, CharSequence selectSql, SqlExecutionContext sqlExecutionContext) throws SqlException {
         return compiler.compile(selectSql, sqlExecutionContext).getRecordCursorFactory();
     }
@@ -1554,6 +1531,30 @@ public void verifyTableToken(TableToken tableToken) {
         }
     }
 
+    private static void insert(
+            CompiledQuery cq,
+            SqlExecutionContext sqlExecutionContext
+    ) throws SqlException {
+        switch (cq.getType()) {
+            case INSERT:
+            case INSERT_AS_SELECT:
+                try (
+                        InsertOperation insertOperation = cq.popInsertOperation();
+                        InsertMethod insertMethod = insertOperation.createMethod(sqlExecutionContext)
+                ) {
+                    insertMethod.execute(sqlExecutionContext);
+                    insertMethod.commit();
+                }
+                break;
+            case SELECT:
+                throw SqlException.$(0, "use select()");
+            case DROP:
+                throw SqlException.$(0, "use drop()");
+            default:
+                throw SqlException.$(0, "use ddl()");
+        }
+    }
+
     // caller has to acquire the lock before this method is called and release the lock after the call
     private void createTableOrMatViewInVolumeUnsafe(MemoryMARW mem, @Nullable BlockFileWriter blockFileWriter, Path path, TableStructure struct, TableToken tableToken) {
         if (TableUtils.TABLE_DOES_NOT_EXIST != TableUtils.existsInVolume(configuration.getFilesFacade(), path, tableToken.getDirName())) {
diff --git a/core/src/main/java/io/questdb/cairo/sql/InsertOperation.java b/core/src/main/java/io/questdb/cairo/sql/InsertOperation.java
index 0aaa143a5797..b31ce9e02f22 100644
--- a/core/src/main/java/io/questdb/cairo/sql/InsertOperation.java
+++ b/core/src/main/java/io/questdb/cairo/sql/InsertOperation.java
@@ -28,15 +28,12 @@
 import io.questdb.griffin.InsertRowImpl;
 import io.questdb.griffin.SqlException;
 import io.questdb.griffin.SqlExecutionContext;
+import io.questdb.std.QuietCloseable;
 
-import java.io.Closeable;
+public interface InsertOperation extends QuietCloseable {
 
-public interface InsertOperation extends Closeable {
     void addInsertRow(InsertRowImpl row);
 
-    default void close() {
-    }
-
     InsertMethod createMethod(SqlExecutionContext executionContext) throws SqlException;
 
     InsertMethod createMethod(SqlExecutionContext executionContext, WriterSource writerSource) throws SqlException;
diff --git a/core/src/main/java/io/questdb/cutlass/pgwire/modern/PGConnectionContextModern.java b/core/src/main/java/io/questdb/cutlass/pgwire/modern/PGConnectionContextModern.java
index 4b52624af549..c8945d6a05ec 100644
--- a/core/src/main/java/io/questdb/cutlass/pgwire/modern/PGConnectionContextModern.java
+++ b/core/src/main/java/io/questdb/cutlass/pgwire/modern/PGConnectionContextModern.java
@@ -591,8 +591,7 @@ private void freePipelineEntriesFrom(Utf8SequenceObjHashMap<PGPipelineEntry> cac
         for (int i = 0, n = names.size(); i < n; i++) {
             PGPipelineEntry pe = cache.get(names.getQuick(i));
             pe.setStateClosed(true, isStatementClose);
-            // return the factory back to global cache in case of a select
-            pe.cacheIfPossible(tasCache, null);
+            pe.cacheIfPossible(tasCache, taiCache);
             releaseToPool(pe);
         }
         cache.clear();
@@ -956,7 +955,6 @@ private void msgExecute(long lo, long msgLimit) throws BadProtocolException {
         transactionState = pipelineCurrentEntry.msgExecute(
                 sqlExecutionContext,
                 transactionState,
-                taiCache,
                 taiPool,
                 pendingWriters,
                 this,
@@ -1070,15 +1068,13 @@ private void msgParse(long address, long lo, long msgLimit) throws BadProtocolEx
         // will have the supplied parameter types.
 
         int cachedStatus = CACHE_MISS;
-        int taiKeyIndex = taiCache.keyIndex(utf16SqlText);
-        final TypesAndInsertModern tai = taiCache.peek(taiKeyIndex);
+        final TypesAndInsertModern tai = taiCache.poll(utf16SqlText);
         if (tai != null) {
             if (pipelineCurrentEntry.msgParseReconcileParameterTypes(parameterTypeCount, tai)) {
                 pipelineCurrentEntry.ofCachedInsert(utf16SqlText, tai);
                 cachedStatus = CACHE_HIT_INSERT_VALID;
             } else {
-                TypesAndInsertModern tai2 = taiCache.poll(taiKeyIndex);
-                assert tai2 == tai;
+                // Cache miss, get rid of this entry.
                 tai.close();
                 cachedStatus = CACHE_HIT_INSERT_INVALID;
             }
@@ -1559,7 +1555,6 @@ public void postCompile(SqlCompiler compiler, CompiledQuery cq, CharSequence que
             transactionState = pipelineCurrentEntry.msgExecute(
                     sqlExecutionContext,
                     transactionState,
-                    taiCache,
                     taiPool,
                     pendingWriters,
                     PGConnectionContextModern.this,
diff --git a/core/src/main/java/io/questdb/cutlass/pgwire/modern/PGPipelineEntry.java b/core/src/main/java/io/questdb/cutlass/pgwire/modern/PGPipelineEntry.java
index caa18b0bd023..40db5e75af84 100644
--- a/core/src/main/java/io/questdb/cutlass/pgwire/modern/PGPipelineEntry.java
+++ b/core/src/main/java/io/questdb/cutlass/pgwire/modern/PGPipelineEntry.java
@@ -97,7 +97,6 @@
 import io.questdb.std.str.Utf8StringSink;
 import io.questdb.std.str.Utf8s;
 import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
 
 import java.util.function.Consumer;
 
@@ -161,7 +160,6 @@ public class PGPipelineEntry implements QuietCloseable, Mutable {
     // this is a "union", so should only be one, depending on SQL type
     // SELECT or EXPLAIN
     private RecordCursorFactory factory = null;
-    private InsertOperation insertOp = null;
     private int msgBindParameterValueCount;
     private short msgBindSelectFormatCodeCount = 0;
     private Utf8String namedPortal;
@@ -223,7 +221,10 @@ public void bindPortalName(Utf8String portalName) {
         namedPortals.add(portalName);
     }
 
-    public void cacheIfPossible(@NotNull AssociativeCache<TypesAndSelectModern> tasCache, @Nullable SimpleAssociativeCache<TypesAndInsertModern> taiCache) {
+    public void cacheIfPossible(
+            @NotNull AssociativeCache<TypesAndSelectModern> tasCache,
+            @NotNull SimpleAssociativeCache<TypesAndInsertModern> taiCache
+    ) {
         if (isPortal() || isPreparedStatement()) {
             // must not cache prepared statements etc.; we must only cache abandoned pipeline entries (their contents)
             return;
@@ -237,10 +238,10 @@ public void cacheIfPossible(@NotNull AssociativeCache<TypesAndSelectModern> tasC
             // we don't have to use immutable string since ConcurrentAssociativeCache does it when needed
             tasCache.put(sqlText, tas);
             tas = null;
-        } else if (tai != null && taiCache != null) {
+        } else if (tai != null) {
             taiCache.put(sqlText, tai);
             // make sure we don't close insert operation when the pipeline entry is closed
-            insertOp = null;
+            tai = null;
         }
     }
 
@@ -272,13 +273,13 @@ public void close() {
         if (!isCopy) {
             // if we are a copy, we do not own operations -> we cannot close them
             // so we just null them out and let the original entry close them
-            insertOp = Misc.free(insertOp);
+            tai = Misc.free(tai);
             operation = Misc.free(operation);
             if (compiledQuery != null) {
                 Misc.free(compiledQuery.getUpdateOperation());
             }
         } else {
-            insertOp = null;
+            tai = null;
             operation = null;
         }
 
@@ -327,7 +328,6 @@ public void close() {
         stateParse = false;
         stateParseExecuted = false;
         stateSync = SYNC_PARSE;
-        tai = null;
         tas = null;
         utf8StringSink.clear();
     }
@@ -384,7 +384,7 @@ public void compileNewSQL(
                     // variables.
                     msgParseDefineBindVariableTypes(sqlExecutionContext.getBindVariableService());
                 }
-                CompiledQuery cq = compiler.compile(this.sqlText, sqlExecutionContext);
+                CompiledQuery cq = compiler.compile(sqlText, sqlExecutionContext);
                 // copy actual bind variable types as supplied by the client + defined by the SQL compiler
                 msgParseCopyOutTypeDescriptionTypeOIDs(sqlExecutionContext.getBindVariableService());
                 setupEntryAfterSQLCompilation(sqlExecutionContext, taiPool, cq);
@@ -568,7 +568,6 @@ public void msgBindCopySelectFormatCodes(long lo, short selectFormatCodeCount) {
     public int msgExecute(
             SqlExecutionContext sqlExecutionContext,
             int transactionState,
-            SimpleAssociativeCache<TypesAndInsertModern> taiCache,
             WeakSelfReturningObjectPool<TypesAndInsertModern> taiPool,
             ObjObjHashMap<TableToken, TableWriterAPI> pendingWriters,
             WriterSource writerSource,
@@ -611,7 +610,7 @@ public int msgExecute(
                     break;
                 case CompiledQuery.INSERT:
                 case CompiledQuery.INSERT_AS_SELECT:
-                    msgExecuteInsert(sqlExecutionContext, transactionState, taiCache, pendingWriters, writerSource, taiPool);
+                    msgExecuteInsert(sqlExecutionContext, transactionState, pendingWriters, writerSource, taiPool);
                     break;
                 case CompiledQuery.UPDATE:
                     msgExecuteUpdate(sqlExecutionContext, transactionState, pendingWriters, tempSequence, taiPool);
@@ -860,7 +859,6 @@ public void msgSync(
 
     public void ofCachedInsert(CharSequence utf16SqlText, TypesAndInsertModern tai) {
         this.sqlText = utf16SqlText;
-        this.insertOp = tai.getInsert();
         this.sqlTag = tai.getSqlTag();
         this.sqlType = tai.getSqlType();
         this.cacheHit = true;
@@ -1094,7 +1092,6 @@ private void copyOf(PGPipelineEntry blueprint) {
         this.isCopy = true;
         this.cacheHit = blueprint.cacheHit;
         this.empty = blueprint.empty;
-        this.insertOp = blueprint.insertOp;
         this.operation = blueprint.operation;
         this.parentPreparedStatementPipelineEntry = blueprint.parentPreparedStatementPipelineEntry;
         this.namedStatement = blueprint.namedStatement;
@@ -1417,10 +1414,9 @@ private void msgExecuteDDL(
     private void msgExecuteInsert(
             SqlExecutionContext sqlExecutionContext,
             int transactionState,
+            ObjObjHashMap<TableToken, TableWriterAPI> pendingWriters,
             // todo: WriterSource is the interface used exclusively in PG Wire. We should not need to pass
             //    around heaps of state in very long call stacks
-            SimpleAssociativeCache<TypesAndInsertModern> taiCache,
-            ObjObjHashMap<TableToken, TableWriterAPI> pendingWriters,
             WriterSource writerSource,
             WeakSelfReturningObjectPool<TypesAndInsertModern> taiPool
     ) throws SqlException, BadProtocolException {
@@ -1432,6 +1428,7 @@ private void msgExecuteInsert(
                 engine.getMetrics().pgWireMetrics().markStart();
                 try {
                     for (int attempt = 1; ; attempt++) {
+                        final InsertOperation insertOp = tai.getInsert();
                         InsertMethod m;
                         try {
                             m = insertOp.createMethod(sqlExecutionContext, writerSource);
@@ -1439,20 +1436,17 @@ private void msgExecuteInsert(
                                 sqlAffectedRowCount = m.execute(sqlExecutionContext);
                                 TableWriterAPI writer = m.popWriter();
                                 pendingWriters.put(writer.getTableToken(), writer);
-                                if (tai.hasBindVariables()) {
-                                    taiCache.put(sqlText, tai);
-                                }
-                            } catch (Throwable e) {
+                            } catch (Throwable th) {
                                 TableWriterAPI w = m.popWriter();
                                 if (w != null) {
                                     pendingWriters.remove(w.getTableToken());
                                 }
                                 Misc.free(w);
-                                throw e;
+                                throw th;
                             }
                             break;
                         } catch (TableReferenceOutOfDateException e) {
-                            insertOp = Misc.free(insertOp);
+                            tai = Misc.free(tai);
                             if (attempt == maxRecompileAttempts) {
                                 throw e;
                             }
@@ -2546,8 +2540,8 @@ private void setupEntryAfterSQLCompilation(
                 sqlTag = TAG_OK;
                 break;
             case CompiledQuery.EXPLAIN:
-                this.sqlTag = TAG_EXPLAIN;
-                this.factory = cq.getRecordCursorFactory();
+                sqlTag = TAG_EXPLAIN;
+                factory = cq.getRecordCursorFactory();
                 tas = new TypesAndSelectModern(
                         this.factory,
                         sqlType,
@@ -2557,8 +2551,8 @@ private void setupEntryAfterSQLCompilation(
                 );
                 break;
             case CompiledQuery.SELECT:
-                this.sqlTag = TAG_SELECT;
-                this.factory = cq.getRecordCursorFactory();
+                sqlTag = TAG_SELECT;
+                factory = cq.getRecordCursorFactory();
                 tas = new TypesAndSelectModern(
                         factory,
                         sqlType,
@@ -2572,12 +2566,12 @@ private void setupEntryAfterSQLCompilation(
                 // we do not intend to cache it. The fact we don't have
                 // TypesAndSelect instance here should be enough to tell the
                 // system not to cache.
-                this.sqlTag = TAG_PSEUDO_SELECT;
-                this.factory = cq.getRecordCursorFactory();
+                sqlTag = TAG_PSEUDO_SELECT;
+                factory = cq.getRecordCursorFactory();
                 break;
             case CompiledQuery.INSERT:
             case CompiledQuery.INSERT_AS_SELECT:
-                this.insertOp = cq.popInsertOperation();
+                final InsertOperation insertOp = cq.popInsertOperation();
                 tai = taiPool.pop();
                 sqlTag = sqlType == CompiledQuery.INSERT ? TAG_INSERT : TAG_INSERT_AS_SELECT;
                 tai.of(
@@ -2738,8 +2732,8 @@ boolean isDirty() {
     // It is irrelevant which types were defined by the SQL compiler. We are assuming that same SQL text will
     // produce the same parameter definitions for every compilation.
     boolean msgParseReconcileParameterTypes(short parameterTypeCount, TypeContainer typeContainer) {
-        IntList cachedTypes = typeContainer.getPgInParameterTypeOIDs();
-        int cachedTypeCount = cachedTypes.size();
+        final IntList cachedTypes = typeContainer.getPgInParameterTypeOIDs();
+        final int cachedTypeCount = cachedTypes.size();
         if (parameterTypeCount != cachedTypeCount) {
             return false;
         }
diff --git a/core/src/main/java/io/questdb/cutlass/pgwire/modern/TypesAndInsertModern.java b/core/src/main/java/io/questdb/cutlass/pgwire/modern/TypesAndInsertModern.java
index 1299f99d92c9..4d00a3640e53 100644
--- a/core/src/main/java/io/questdb/cutlass/pgwire/modern/TypesAndInsertModern.java
+++ b/core/src/main/java/io/questdb/cutlass/pgwire/modern/TypesAndInsertModern.java
@@ -57,11 +57,11 @@ public void close() {
         if (!closing) {
             closing = true;
             super.close();
+            insert = Misc.free(insert);
             pgInParameterTypeOIDs.clear();
             pgOutParameterTypes.clear();
             closing = false;
         }
-        insert = Misc.free(insert);
     }
 
     public InsertOperation getInsert() {
diff --git a/core/src/main/java/io/questdb/griffin/InsertRowImpl.java b/core/src/main/java/io/questdb/griffin/InsertRowImpl.java
index 16ebaacaf9b0..e1296152f11b 100644
--- a/core/src/main/java/io/questdb/griffin/InsertRowImpl.java
+++ b/core/src/main/java/io/questdb/griffin/InsertRowImpl.java
@@ -31,15 +31,17 @@
 import io.questdb.cairo.TableWriterAPI;
 import io.questdb.cairo.sql.Function;
 import io.questdb.cairo.sql.VirtualRecord;
+import io.questdb.std.Misc;
 import io.questdb.std.ObjList;
+import io.questdb.std.QuietCloseable;
 
-public final class InsertRowImpl {
+public final class InsertRowImpl implements QuietCloseable {
     private final RecordToRowCopier copier;
     private final RowFactory rowFactory;
     private final Function timestampFunction;
+    private final int timestampFunctionPosition;
     private final int tupleIndex;
     private final VirtualRecord virtualRecord;
-    private final int timestampFunctionPosition;
 
     public InsertRowImpl(
             VirtualRecord virtualRecord,
@@ -76,6 +78,12 @@ public void append(TableWriterAPI writer) {
         }
     }
 
+    @Override
+    public void close() {
+        Misc.free(timestampFunction);
+        Misc.freeObjList(virtualRecord.getFunctions());
+    }
+
     public void initContext(SqlExecutionContext executionContext) throws SqlException {
         final ObjList<? extends Function> functions = virtualRecord.getFunctions();
         Function.init(functions, null, executionContext, null);
diff --git a/core/src/main/java/io/questdb/griffin/SqlCompilerImpl.java b/core/src/main/java/io/questdb/griffin/SqlCompilerImpl.java
index f6a3471b8049..4dfd26f2148b 100644
--- a/core/src/main/java/io/questdb/griffin/SqlCompilerImpl.java
+++ b/core/src/main/java/io/questdb/griffin/SqlCompilerImpl.java
@@ -2294,18 +2294,20 @@ private void compileInner(
     private InsertOperation compileInsert(InsertModel insertModel, SqlExecutionContext executionContext) throws SqlException {
         // todo: consider moving this method to InsertModel
         final ExpressionNode tableNameExpr = insertModel.getTableNameExpr();
+        InsertOperationImpl insertOperation = null;
+        Function timestampFunction = null;
         ObjList<Function> valueFunctions = null;
         TableToken token = tableExistsOrFail(tableNameExpr.position, tableNameExpr.token, executionContext);
 
         try (TableRecordMetadata metadata = executionContext.getMetadataForWrite(token)) {
             final long metadataVersion = metadata.getMetadataVersion();
-            final InsertOperationImpl insertOperation = new InsertOperationImpl(engine, metadata.getTableToken(), metadataVersion);
+            insertOperation = new InsertOperationImpl(engine, metadata.getTableToken(), metadataVersion);
             final int metadataTimestampIndex = metadata.getTimestampIndex();
             final ObjList<CharSequence> columnNameList = insertModel.getColumnNameList();
             final int columnSetSize = columnNameList.size();
             int designatedTimestampPosition = 0;
             for (int tupleIndex = 0, n = insertModel.getRowTupleCount(); tupleIndex < n; tupleIndex++) {
-                Function timestampFunction = null;
+                timestampFunction = null;
                 listColumnFilter.clear();
                 if (columnSetSize > 0) {
                     valueFunctions = new ObjList<>(columnSetSize);
@@ -2386,15 +2388,16 @@ private InsertOperation compileInsert(InsertModel insertModel, SqlExecutionConte
                     }
                 }
 
-
                 VirtualRecord record = new VirtualRecord(valueFunctions);
                 RecordToRowCopier copier = RecordToRowCopierUtils.generateCopier(asm, record, metadata, listColumnFilter);
                 insertOperation.addInsertRow(new InsertRowImpl(record, copier, timestampFunction, designatedTimestampPosition, tupleIndex));
             }
             return insertOperation;
-        } catch (SqlException e) {
+        } catch (Throwable th) {
+            Misc.free(insertOperation);
+            Misc.free(timestampFunction);
             Misc.freeObjList(valueFunctions);
-            throw e;
+            throw th;
         }
     }
 
diff --git a/core/src/main/java/io/questdb/griffin/engine/ops/InsertAsSelectOperationImpl.java b/core/src/main/java/io/questdb/griffin/engine/ops/InsertAsSelectOperationImpl.java
index ef28aaf3649a..1138a81a8a60 100644
--- a/core/src/main/java/io/questdb/griffin/engine/ops/InsertAsSelectOperationImpl.java
+++ b/core/src/main/java/io/questdb/griffin/engine/ops/InsertAsSelectOperationImpl.java
@@ -68,7 +68,8 @@ public InsertAsSelectOperationImpl(
             long metadataVersion,
             int timestampIndex,
             long batchSize,
-            long o3MaxLag) {
+            long o3MaxLag
+    ) {
         this.batchSize = batchSize;
         this.copier = copier;
         this.engine = engine;
diff --git a/core/src/main/java/io/questdb/griffin/engine/ops/InsertOperationImpl.java b/core/src/main/java/io/questdb/griffin/engine/ops/InsertOperationImpl.java
index 02e52f0ec051..eb90190bf684 100644
--- a/core/src/main/java/io/questdb/griffin/engine/ops/InsertOperationImpl.java
+++ b/core/src/main/java/io/questdb/griffin/engine/ops/InsertOperationImpl.java
@@ -41,8 +41,6 @@
 import io.questdb.std.ObjList;
 
 public class InsertOperationImpl implements InsertOperation {
-
-    // type inference fails on java 8 if <CharSequence> is removed
     private final InsertOperationFuture doneFuture = new InsertOperationFuture();
     private final CairoEngine engine;
     private final InsertMethodImpl insertMethod = new InsertMethodImpl();
@@ -61,6 +59,11 @@ public void addInsertRow(InsertRowImpl row) {
         insertRows.add(row);
     }
 
+    @Override
+    public void close() {
+        Misc.freeObjList(insertRows);
+    }
+
     @Override
     public InsertMethod createMethod(SqlExecutionContext executionContext, WriterSource writerSource) throws SqlException {
         SecurityContext securityContext = executionContext.getSecurityContext();
diff --git a/core/src/test/java/io/questdb/test/cutlass/http/IODispatcherTest.java b/core/src/test/java/io/questdb/test/cutlass/http/IODispatcherTest.java
index 35f290ce4407..9817abd6601c 100644
--- a/core/src/test/java/io/questdb/test/cutlass/http/IODispatcherTest.java
+++ b/core/src/test/java/io/questdb/test/cutlass/http/IODispatcherTest.java
@@ -3003,6 +3003,18 @@ public void testImportWithSingleCharacterColumnName() throws Exception {
         );
     }
 
+    @Test
+    public void testInsertNoMemLeak() throws Exception {
+        getSimpleTester().run((engine, sqlExecutionContext) -> {
+            testHttpClient.assertGet("{\"ddl\":\"OK\"}", "CREATE TABLE test(id LONG);");
+            testHttpClient.assertGet("{\"dml\":\"OK\"}", "INSERT INTO test values(alloc(42));");
+            testHttpClient.assertGet(
+                    "{\"query\":\"test;\",\"columns\":[{\"name\":\"id\",\"type\":\"LONG\"}],\"timestamp\":-1,\"dataset\":[[42]],\"count\":1}",
+                    "test;"
+            );
+        });
+    }
+
     @Test
     public void testInterval() throws Exception {
         testJsonQuery(
diff --git a/core/src/test/java/io/questdb/test/cutlass/pgwire/PGJobContextTest.java b/core/src/test/java/io/questdb/test/cutlass/pgwire/PGJobContextTest.java
index 4125345b003c..370992c0fbec 100644
--- a/core/src/test/java/io/questdb/test/cutlass/pgwire/PGJobContextTest.java
+++ b/core/src/test/java/io/questdb/test/cutlass/pgwire/PGJobContextTest.java
@@ -5925,6 +5925,34 @@ public void testInsertFomNodeJsWith2Parameters_WithTableCreation() throws Except
         );
     }
 
+    @Test
+    public void testInsertNoMemLeak() throws Exception {
+        Assume.assumeFalse(legacyMode);
+        skipOnWalRun(); // non-partitioned table
+        assertWithPgServer(CONN_AWARE_EXTENDED, (connection, binary, mode, port) -> {
+            try (Statement statement = connection.createStatement()) {
+                statement.executeUpdate("CREATE TABLE test(id LONG);");
+            }
+
+            try (PreparedStatement insert = connection.prepareStatement("INSERT INTO test values(alloc(42));")) {
+                insert.execute();
+                // execute insert multiple times to verify cache interaction
+                insert.execute();
+            }
+
+            try (Statement statement = connection.createStatement()) {
+                ResultSet rs = statement.executeQuery("test;");
+                assertResultSet(
+                        "id[BIGINT]\n" +
+                                "42\n" +
+                                "42\n",
+                        sink,
+                        rs
+                );
+            }
+        });
+    }
+
     @Test
     public void testInsertPreparedRenameInsert() throws Exception {
         assertWithPgServer(CONN_AWARE_ALL, (connection, binary, mode, port) -> {
@@ -5936,6 +5964,11 @@ public void testInsertPreparedRenameInsert() throws Exception {
                 insert.execute();
                 connection.commit();
 
+                insert.setInt(1, 1);
+                insert.setTimestamp(2, new Timestamp(1632761103203L));
+                insert.execute();
+                connection.commit();
+
                 connection.prepareStatement("rename table ts to ts2").execute();
                 try {
                     insert.execute();
@@ -5955,7 +5988,8 @@ public void testInsertPreparedRenameInsert() throws Exception {
             ) {
                 assertResultSet(
                         "id[INTEGER],ts[TIMESTAMP]\n" +
-                                "0,2021-09-27 16:45:03.202\n",
+                                "0,2021-09-27 16:45:03.202\n" +
+                                "1,2021-09-27 16:45:03.203\n",
                         sink,
                         rs
                 );


Reviews:
user: login: jerrinot
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T08:35:31Z
user: login: mtopolnik
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T11:18:18Z
user: login: mtopolnik
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T11:23:29Z
user: login: puzpuzpuz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T11:52:14Z
user: login: puzpuzpuz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T11:57:45Z
user: login: puzpuzpuz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T11:59:15Z
user: login: jerrinot
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T12:09:57Z

Review comments:
diff_hunk: @@ -272,13 +273,13 @@ public void close() {
         if (!isCopy) {
             // if we are a copy, we do not own operations -> we cannot close them
             // so we just null them out and let the original entry close them
-            insertOp = Misc.free(insertOp);
+            tai = Misc.free(tai);
path: core/src/main/java/io/questdb/cutlass/pgwire/modern/PGPipelineEntry.java
user: login: jerrinot
body: Can you please explain the logic behind this change? 

I am only just starting to review it, but I read it like this:
When closing an entry (e.g. when returning the entry back to a pool) this change `free()` the entire INSERT cache. This looks add as the INSERT cache lifecycle is bound to a lifecycle of a session (context), not to a life-cycle of an individual entry. What am I missing? 
created_at: 2025-05-30T08:35:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -272,13 +273,13 @@ public void close() {
         if (!isCopy) {
             // if we are a copy, we do not own operations -> we cannot close them
             // so we just null them out and let the original entry close them
-            insertOp = Misc.free(insertOp);
+            tai = Misc.free(tai);
path: core/src/main/java/io/questdb/cutlass/pgwire/modern/PGPipelineEntry.java
user: login: mtopolnik
body: Isn't `tai` just one cache entry, and `taiCache` the whole cache?
created_at: 2025-05-30T11:18:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -272,13 +273,13 @@ public void close() {
         if (!isCopy) {
             // if we are a copy, we do not own operations -> we cannot close them
             // so we just null them out and let the original entry close them
-            insertOp = Misc.free(insertOp);
+            tai = Misc.free(tai);
path: core/src/main/java/io/questdb/cutlass/pgwire/modern/PGPipelineEntry.java
user: login: mtopolnik
body: But, since `insertOp` is the only closeable thing in `tai`, this wouldn't avoid any memory leak.
created_at: 2025-05-30T11:23:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -272,13 +273,13 @@ public void close() {
         if (!isCopy) {
             // if we are a copy, we do not own operations -> we cannot close them
             // so we just null them out and let the original entry close them
-            insertOp = Misc.free(insertOp);
+            tai = Misc.free(tai);
path: core/src/main/java/io/questdb/cutlass/pgwire/modern/PGPipelineEntry.java
user: login: puzpuzpuz
body: @mtopolnik tai is a single insert + bind var types; taiCache is the associative cache for tai objects; taiPool is an object pool for tai objects.
created_at: 2025-05-30T11:52:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -272,13 +273,13 @@ public void close() {
         if (!isCopy) {
             // if we are a copy, we do not own operations -> we cannot close them
             // so we just null them out and let the original entry close them
-            insertOp = Misc.free(insertOp);
+            tai = Misc.free(tai);
path: core/src/main/java/io/questdb/cutlass/pgwire/modern/PGPipelineEntry.java
user: login: puzpuzpuz
body: @jerrinot the problem here was that we were closing insert operation without closing its tai object. When tai object is closed, it's returned to taiPool. So, we should close tai instead of closing the insert operation directly. Otherwise, if we don't close tai, it'll contain the closed insert operation leading to use-after-free. Hope this makes sense.
created_at: 2025-05-30T11:57:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -272,13 +273,13 @@ public void close() {
         if (!isCopy) {
             // if we are a copy, we do not own operations -> we cannot close them
             // so we just null them out and let the original entry close them
-            insertOp = Misc.free(insertOp);
+            tai = Misc.free(tai);
path: core/src/main/java/io/questdb/cutlass/pgwire/modern/PGPipelineEntry.java
user: login: puzpuzpuz
body: > this wouldn't avoid any memory leak

@mtopolnik this change fixes use-after-free, not mem leak. The problem is twofold: we never closed insert op's SQL function, but also our tai cache/pool manipulations were problematic.
created_at: 2025-05-30T11:59:14Z
author_association: CONTRIBUTOR

PR comments:
user: login: jerrinot
created_at: 2025-05-30T07:36:57Z
body: How come this was not caught by existing tests? 
author_association: CONTRIBUTOR
user: login: puzpuzpuz
created_at: 2025-05-30T07:41:09Z
body: > How come this was not caught by existing tests?

That's simple: we had no tests with off-heap SQL functions in INSERT.
author_association: CONTRIBUTOR
user: login: jerrinot
created_at: 2025-05-30T07:51:03Z
body: @mtopolnik this is like a deje-vu:
> That's simple: we had no tests with off-heap SQL functions in INSERT.

I swear we dealt with this before. 
author_association: CONTRIBUTOR
user: login: puzpuzpuz
created_at: 2025-05-30T07:59:18Z
body: `¯\_(ツ)_/¯`
author_association: CONTRIBUTOR
user: login: puzpuzpuz
created_at: 2025-05-30T08:06:07Z
body: @jerrinot fancy doing a code review?
author_association: CONTRIBUTOR
user: login: jerrinot
created_at: 2025-05-30T08:27:02Z
body: @puzpuzpuz sure
author_association: CONTRIBUTOR
user: login: glasstiger
created_at: 2025-05-30T10:55:39Z
body: #### [PR Coverage check]

:heart_eyes: **pass** : 48 / 51 (94.12%)




#### file detail

|   |path|covered line|new line|coverage|
|----|----|----|----|----|
|:large_blue_circle:|io/questdb/cairo/CairoEngine.java|12|15|80.00%|
|:large_blue_circle:|io/questdb/cutlass/pgwire/modern/PGPipelineEntry.java|19|19|100.00%|
|:large_blue_circle:|io/questdb/griffin/engine/ops/InsertAsSelectOperationImpl.java|1|1|100.00%|
|:large_blue_circle:|io/questdb/cutlass/pgwire/modern/PGConnectionContextModern.java|2|2|100.00%|
|:large_blue_circle:|io/questdb/griffin/SqlCompilerImpl.java|8|8|100.00%|
|:large_blue_circle:|io/questdb/griffin/engine/ops/InsertOperationImpl.java|2|2|100.00%|
|:large_blue_circle:|io/questdb/griffin/InsertRowImpl.java|3|3|100.00%|
|:large_blue_circle:|io/questdb/cutlass/pgwire/modern/TypesAndInsertModern.java|1|1|100.00%|



author_association: CONTRIBUTOR
user: login: puzpuzpuz
created_at: 2025-05-30T12:04:08Z
body: @jerrinot BTW many thanks for adding pgwire driver tests - they're of a great help when it comes to tricky scenarios. Without them this patch would certainly break some clients.
author_association: CONTRIBUTOR
user: login: puzpuzpuz
created_at: 2025-05-30T13:28:20Z
body: @jerrinot @mtopolnik thanks for the review!
author_association: CONTRIBUTOR