From 9b12dfffc279221d3da1e83a458124c5ca3d70b0 Mon Sep 17 00:00:00 2001
From: Daniel <dhaavi@users.noreply.github.com>
Date: Mon, 17 Mar 2025 14:13:53 +0100
Subject: [PATCH] Add option to use prepared statements for SQLite PutMany

---
 base/database/storage/sqlite/prepared.go      | 125 ++++++++++++++++++
 base/database/storage/sqlite/prepared_test.go |  86 ++++++++++++
 base/database/storage/sqlite/sqlite.go        |   5 +
 3 files changed, 216 insertions(+)
 create mode 100644 base/database/storage/sqlite/prepared.go
 create mode 100644 base/database/storage/sqlite/prepared_test.go

diff --git a/base/database/storage/sqlite/prepared.go b/base/database/storage/sqlite/prepared.go
new file mode 100644
index 00000000..11136dd8
--- /dev/null
+++ b/base/database/storage/sqlite/prepared.go
@@ -0,0 +1,125 @@
+package sqlite
+
+import (
+	"context"
+	"strconv"
+
+	"github.com/stephenafamo/bob"
+	"github.com/stephenafamo/bob/dialect/sqlite/im"
+	"github.com/stephenafamo/bob/expr"
+
+	"github.com/safing/portmaster/base/database/record"
+	"github.com/safing/portmaster/base/database/storage/sqlite/models"
+	"github.com/safing/structures/dsd"
+)
+
+var UsePreparedStatements bool = true
+
+// PutMany stores many records in the database.
+func (db *SQLite) putManyWithPreparedStmts(shadowDelete bool) (chan<- record.Record, <-chan error) {
+	batch := make(chan record.Record, 100)
+	errs := make(chan error, 1)
+
+	// Simulate upsert with custom selection on conflict.
+	rawQuery, _, err := models.Records.Insert(
+		im.Into("records", "key", "format", "value", "created", "modified", "expires", "deleted", "secret", "crownjewel"),
+		im.Values(expr.Arg("key"), expr.Arg("format"), expr.Arg("value"), expr.Arg("created"), expr.Arg("modified"), expr.Arg("expires"), expr.Arg("deleted"), expr.Arg("secret"), expr.Arg("crownjewel")),
+		im.OnConflict("key").DoUpdate(
+			im.SetExcluded("format", "value", "created", "modified", "expires", "deleted", "secret", "crownjewel"),
+		),
+	).Build(db.ctx)
+	if err != nil {
+		errs <- err
+		return batch, errs
+	}
+
+	// Start transaction.
+	tx, err := db.bob.BeginTx(db.ctx, nil)
+	if err != nil {
+		errs <- err
+		return batch, errs
+	}
+
+	// Create prepared statement WITHIN TRANSACTION.
+	preparedStmt, err := tx.PrepareContext(db.ctx, rawQuery)
+	if err != nil {
+		errs <- err
+		return batch, errs
+	}
+
+	// start handler
+	go func() {
+		// Read all put records.
+	writeBatch:
+		for {
+			select {
+			case r := <-batch:
+				if r != nil {
+					// Write record.
+					err := writeWithPreparedStatement(db.ctx, &preparedStmt, r)
+					if err != nil {
+						errs <- err
+						break writeBatch
+					}
+				} else {
+					// Finalize transcation.
+					errs <- tx.Commit()
+					return
+				}
+
+			case <-db.ctx.Done():
+				break writeBatch
+			}
+		}
+
+		// Rollback transaction.
+		errs <- tx.Rollback()
+	}()
+
+	return batch, errs
+}
+
+func writeWithPreparedStatement(ctx context.Context, pStmt *bob.StdPrepared, r record.Record) error {
+	r.Lock()
+	defer r.Unlock()
+
+	// Serialize to JSON.
+	data, err := r.MarshalDataOnly(r, dsd.JSON)
+	if err != nil {
+		return err
+	}
+
+	// Get Meta.
+	m := r.Meta()
+
+	// Insert.
+	if len(data) > 0 {
+		format := strconv.Itoa(dsd.JSON)
+		_, err = pStmt.ExecContext(
+			ctx,
+			r.DatabaseKey(),
+			format,
+			data,
+			m.Created,
+			m.Modified,
+			m.Expires,
+			m.Deleted,
+			m.IsSecret(),
+			m.IsCrownJewel(),
+		)
+	} else {
+		_, err = pStmt.ExecContext(
+			ctx,
+			r.DatabaseKey(),
+			nil,
+			nil,
+			m.Created,
+			m.Modified,
+			m.Expires,
+			m.Deleted,
+			m.IsSecret(),
+			m.IsCrownJewel(),
+		)
+	}
+	return err
+}
diff --git a/base/database/storage/sqlite/prepared_test.go b/base/database/storage/sqlite/prepared_test.go
new file mode 100644
index 00000000..8a90bcb9
--- /dev/null
+++ b/base/database/storage/sqlite/prepared_test.go
@@ -0,0 +1,86 @@
+package sqlite
+
+import (
+	"strconv"
+	"testing"
+)
+
+func BenchmarkPutMany(b *testing.B) {
+	// Configure prepared statement usage.
+	origSetting := UsePreparedStatements
+	UsePreparedStatements = false
+	defer func() {
+		UsePreparedStatements = origSetting
+	}()
+
+	// Run benchmark.
+	benchPutMany(b)
+}
+
+func BenchmarkPutManyPreparedStmt(b *testing.B) {
+	// Configure prepared statement usage.
+	origSetting := UsePreparedStatements
+	UsePreparedStatements = true
+	defer func() {
+		UsePreparedStatements = origSetting
+	}()
+
+	// Run benchmark.
+	benchPutMany(b)
+}
+
+func benchPutMany(b *testing.B) { //nolint:thelper
+	// Start database.
+	testDir := b.TempDir()
+	db, err := openSQLite("test", testDir, false)
+	if err != nil {
+		b.Fatal(err)
+	}
+	defer func() {
+		// shutdown
+		err = db.Shutdown()
+		if err != nil {
+			b.Fatal(err)
+		}
+	}()
+
+	// Start benchmarking.
+	b.ResetTimer()
+
+	// Benchmark PutMany.
+	records, errs := db.PutMany(false)
+	for i := range b.N {
+		// Create test record.
+		newTestRecord := &TestRecord{
+			S:    "banana",
+			I:    42,
+			I8:   42,
+			I16:  42,
+			I32:  42,
+			I64:  42,
+			UI:   42,
+			UI8:  42,
+			UI16: 42,
+			UI32: 42,
+			UI64: 42,
+			F32:  42.42,
+			F64:  42.42,
+			B:    true,
+		}
+		newTestRecord.UpdateMeta()
+		newTestRecord.SetKey("test:" + strconv.Itoa(i))
+
+		select {
+		case records <- newTestRecord:
+		case err := <-errs:
+			b.Fatal(err)
+		}
+	}
+
+	// Finalize.
+	close(records)
+	err = <-errs
+	if err != nil {
+		b.Fatal(err)
+	}
+}
diff --git a/base/database/storage/sqlite/sqlite.go b/base/database/storage/sqlite/sqlite.go
index 76e1554a..c6288322 100644
--- a/base/database/storage/sqlite/sqlite.go
+++ b/base/database/storage/sqlite/sqlite.go
@@ -205,6 +205,11 @@ func (db *SQLite) PutMany(shadowDelete bool) (chan<- record.Record, <-chan error
 	db.wg.Add(1)
 	defer db.wg.Done()
 
+	// Check if we should use prepared statement optimized inserting.
+	if UsePreparedStatements {
+		return db.putManyWithPreparedStmts(shadowDelete)
+	}
+
 	batch := make(chan record.Record, 100)
 	errs := make(chan error, 1)