Repository: rqlite/rqlite (2025-07-31T21:10:05Z)

Issue:
Rollback transaction if restore fails
Restoring from a SQLite dump file means a transaction is started. It would be best that if the restore fails, a transaction is left open. Ideally it would be explicitly rolled back if the restore fails.


Discussion:
I believe this is happening again in 7.6.1
Thanks for your report, looking at the code I suspect you're right. This regression happened when I made connection handling much better a year or two ago.

Did you hit this?
> Did you hit this?

tbh I haven't dig into it
I was just trying to restore a sqlite dump file over the same instance twice (through the /db/load endpoint). Second request leaves the transaction open because it fails to create an existing table. Subsequent requests complain about that (trying to create a transaction inside an opened transaction)




Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 59e6dcff6..636d08c58 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
 
 ### Implementation changes and bug fixes
 - [PR #2186](https://github.com/rqlite/rqlite/pull/2186): Add "running inside Kubernetes" hint to `/status`
+- [PR #2190](https://github.com/rqlite/rqlite/pull/2190): Rollback transaction if restore fails. Fixes issue [#385](https://github.com/rqlite/rqlite/issues/385).
 
 ## v8.42.0 (July 25th 2025)
 ### New features
diff --git a/command/proto/command.pb.go b/command/proto/command.pb.go
index 5f923c1fe..495ac8577 100644
--- a/command/proto/command.pb.go
+++ b/command/proto/command.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
 // 	protoc-gen-go v1.36.6
-// 	protoc        v3.21.12
+// 	protoc        v3.6.1
 // source: command.proto
 
 package proto
@@ -495,12 +495,13 @@ func (x *Statement) GetForceQuery() bool {
 }
 
 type Request struct {
-	state         protoimpl.MessageState `protogen:"open.v1"`
-	Transaction   bool                   `protobuf:"varint,1,opt,name=transaction,proto3" json:"transaction,omitempty"`
-	Statements    []*Statement           `protobuf:"bytes,2,rep,name=statements,proto3" json:"statements,omitempty"`
-	DbTimeout     int64                  `protobuf:"varint,3,opt,name=dbTimeout,proto3" json:"dbTimeout,omitempty"`
-	unknownFields protoimpl.UnknownFields
-	sizeCache     protoimpl.SizeCache
+	state           protoimpl.MessageState `protogen:"open.v1"`
+	Transaction     bool                   `protobuf:"varint,1,opt,name=transaction,proto3" json:"transaction,omitempty"`
+	Statements      []*Statement           `protobuf:"bytes,2,rep,name=statements,proto3" json:"statements,omitempty"`
+	DbTimeout       int64                  `protobuf:"varint,3,opt,name=dbTimeout,proto3" json:"dbTimeout,omitempty"`
+	RollbackOnError bool                   `protobuf:"varint,4,opt,name=rollbackOnError,proto3" json:"rollbackOnError,omitempty"`
+	unknownFields   protoimpl.UnknownFields
+	sizeCache       protoimpl.SizeCache
 }
 
 func (x *Request) Reset() {
@@ -554,6 +555,13 @@ func (x *Request) GetDbTimeout() int64 {
 	return 0
 }
 
+func (x *Request) GetRollbackOnError() bool {
+	if x != nil {
+		return x.RollbackOnError
+	}
+	return false
+}
+
 type QueryRequest struct {
 	state               protoimpl.MessageState `protogen:"open.v1"`
 	Request             *Request               `protobuf:"bytes,1,opt,name=request,proto3" json:"request,omitempty"`
@@ -2010,13 +2018,14 @@ const file_command_proto_rawDesc = "" +
 	"parameters\x12\x1e\n" +
 	"\n" +
 	"forceQuery\x18\x03 \x01(\bR\n" +
-	"forceQuery\"}\n" +
+	"forceQuery\"\xa7\x01\n" +
 	"\aRequest\x12 \n" +
 	"\vtransaction\x18\x01 \x01(\bR\vtransaction\x122\n" +
 	"\n" +
 	"statements\x18\x02 \x03(\v2\x12.command.StatementR\n" +
 	"statements\x12\x1c\n" +
-	"\tdbTimeout\x18\x03 \x01(\x03R\tdbTimeout\"\xad\x03\n" +
+	"\tdbTimeout\x18\x03 \x01(\x03R\tdbTimeout\x12(\n" +
+	"\x0frollbackOnError\x18\x04 \x01(\bR\x0frollbackOnError\"\xad\x03\n" +
 	"\fQueryRequest\x12*\n" +
 	"\arequest\x18\x01 \x01(\v2\x10.command.RequestR\arequest\x12\x18\n" +
 	"\atimings\x18\x02 \x01(\bR\atimings\x121\n" +
diff --git a/command/proto/command.proto b/command/proto/command.proto
index bf26eecdb..b062d4856 100644
--- a/command/proto/command.proto
+++ b/command/proto/command.proto
@@ -25,6 +25,7 @@ message Request {
 	bool transaction = 1;
 	repeated Statement statements = 2;
 	int64 dbTimeout = 3;
+	bool rollbackOnError = 4;
 }
 
 message QueryRequest {
diff --git a/db/db.go b/db/db.go
index 77ec825ce..4997c7f4b 100644
--- a/db/db.go
+++ b/db/db.go
@@ -896,6 +896,10 @@ func (db *DB) executeWithConn(ctx context.Context, req *command.Request, xTime b
 			tx = nil
 			return false
 		}
+		if req.RollbackOnError {
+			db.executeStmtWithConn(ctx, &command.Statement{Sql: "ROLLBACK"}, false, eqer, time.Duration(req.DbTimeout))
+			return false
+		}
 		return true
 	}
 
diff --git a/http/service.go b/http/service.go
index 192a4c91c..08d79163f 100644
--- a/http/service.go
+++ b/http/service.go
@@ -770,6 +770,7 @@ func (s *Service) handleLoad(w http.ResponseWriter, r *http.Request, qp QueryPar
 		// No JSON structure expected for this API, just a bunch of SQL statements.
 		queries := []string{string(b)}
 		er := executeRequestFromStrings(queries, qp.Timings(), false)
+		er.Request.RollbackOnError = true
 
 		response, _, err := s.store.Execute(er)
 		if err != nil {


Test patch:
diff --git a/store/store_test.go b/store/store_test.go
index db08ad0fa..d9c398cad 100644
--- a/store/store_test.go
+++ b/store/store_test.go
@@ -2165,6 +2165,59 @@ COMMIT;
 	}
 }
 
+func Test_SingleNodeLoad_SQLFail(t *testing.T) {
+	s, ln := mustNewStore(t)
+	defer ln.Close()
+
+	if err := s.Open(); err != nil {
+		t.Fatalf("failed to open single-node store: %s", err.Error())
+	}
+	if err := s.Bootstrap(NewServer(s.ID(), s.Addr(), true)); err != nil {
+		t.Fatalf("failed to bootstrap single-node store: %s", err.Error())
+	}
+	defer s.Close(true)
+	if _, err := s.WaitForLeader(10 * time.Second); err != nil {
+		t.Fatalf("Error waiting for leader: %s", err)
+	}
+
+	dump := `PRAGMA foreign_keys=OFF;
+BEGIN TRANSACTION;
+CREATE TABLE bar (id integer not null primary key, name text);
+INSERT INTO "bar" VALUES(1,'declan');
+INSERT INTO "bar" VALUES(1,'fiona');
+COMMIT;
+`
+	er := executeRequestFromString(dump, false, false)
+	er.Request.RollbackOnError = true
+	_, _, err := s.Execute(er)
+	if err != nil {
+		t.Fatalf("failed to load simple dump: %s", err.Error())
+	}
+
+	// Check that data were loaded correctly.
+	qr := queryRequestFromString("SELECT * FROM bar", false, true)
+	qr.Level = proto.QueryRequest_QUERY_REQUEST_LEVEL_STRONG
+	r, _, err := s.Query(qr)
+	if err != nil {
+		t.Fatalf("failed to query single node: %s", err.Error())
+	}
+	if exp, got := `[{"error":"no such table: bar"}]`, asJSON(r); exp != got {
+		t.Fatalf("unexpected results for query\nexp: %s\ngot: %s", exp, got)
+	}
+
+	// Check that the transaction was rolled back by trying to start a new transaction.
+	// It should succeed.
+	er = executeRequestFromString("BEGIN TRANSACTION; COMMIT;", false, false)
+	rer, _, err := s.Execute(er)
+	if err != nil {
+		t.Fatalf("failed to start new transaction after SQL error: %s", err.Error())
+	}
+	if asJSON(rer) != `[{"last_insert_id":1}]` {
+		t.Fatalf("expected no results after starting new transaction, got %s", asJSON(rer))
+	}
+
+}
+
 func Test_SingleNodeAutoRestore(t *testing.T) {
 	s, ln := mustNewStore(t)
 	defer ln.Close()