Repository: duckdb/duckdb (2026-07-24T06:08:48Z)

Issue:
Read-only WAL replay incorrectly tries to persist replayed index blocks back into the database file
### What happens?

Hi team, I think currently we don't seem to open a read-only file-backed database, see repro for details.

### To Reproduce

Session-1
```sql
hjiang@hjiangs-MacBook-Pro$ ./build/reldebug/duckdb /tmp/duckdb_bug_sjdjd.db          
DuckDB v1.6.0-dev10281 (Development Version, e6b953eaef)
Enter ".help" for usage hints.
duckdb_bug_sjdjd D PRAGMA disable_checkpoint_on_shutdown;
┌─────────┐
│ Success │
│ boolean │
└─────────┘
  0 rows 
duckdb_bug_sjdjd D SET wal_autocheckpoint='1TB';
duckdb_bug_sjdjd D 
duckdb_bug_sjdjd D CREATE TABLE t(id INTEGER PRIMARY KEY, grp INTEGER);
duckdb_bug_sjdjd D INSERT INTO t VALUES (1, 10), (2, 20), (3, 10);
duckdb_bug_sjdjd D CREATE INDEX idx_grp ON t(grp);
```
Session-2
```sql
memory D ATTACH '/tmp/duckdb_ro_replay_index_min.db' AS ro_db (READ_ONLY);
IO Error:
Failure while replaying WAL file "/private/tmp/duckdb_ro_replay_index_min.db.wal": Could not write file "/private/tmp/duckdb_ro_replay_index_min.db": Bad file descriptor
```


### OS:

macos

### DuckDB Version:

71145c7b2726c29c46a2c8af71ebf7ea8051c22f

### DuckDB Client:

SQL

### Hardware:

_No response_

### Full Name:

dentiny

### Affiliation:

N/A

### Did you include all relevant configuration (e.g., CPU architecture, Linux distribution) to reproduce the issue?

- [x] Yes, I have

### Did you include all code required to reproduce the issue?

- [x] Yes, I have

### Did you include all relevant data sets for reproducing the issue?

Yes


Discussion:

Patch:
diff --git a/src/execution/index/fixed_size_allocator.cpp b/src/execution/index/fixed_size_allocator.cpp
index 5e9efbf7f2da..0dfc77d17d63 100644
--- a/src/execution/index/fixed_size_allocator.cpp
+++ b/src/execution/index/fixed_size_allocator.cpp
@@ -338,8 +338,14 @@ void FixedSizeAllocator::Init(const FixedSizeAllocatorInfo &info) {
 		auto allocation_size = info.allocation_sizes[i];
 
 		// create the FixedSizeBuffer
-		buffers[buffer_id] =
-		    make_uniq<FixedSizeBuffer>(block_manager, segment_count, allocation_size, buffer_block_pointer);
+		if (info.transient_block_handles) {
+			D_ASSERT(info.transient_block_handles->size() == info.buffer_ids.size());
+			buffers[buffer_id] = make_uniq<FixedSizeBuffer>(block_manager, segment_count, allocation_size,
+			                                                std::move((*info.transient_block_handles)[i]));
+		} else {
+			buffers[buffer_id] =
+			    make_uniq<FixedSizeBuffer>(block_manager, segment_count, allocation_size, buffer_block_pointer);
+		}
 		total_segment_count += segment_count;
 	}
 
diff --git a/src/execution/index/fixed_size_buffer.cpp b/src/execution/index/fixed_size_buffer.cpp
index 1655d41e5248..51f0470ccba7 100644
--- a/src/execution/index/fixed_size_buffer.cpp
+++ b/src/execution/index/fixed_size_buffer.cpp
@@ -47,6 +47,15 @@ FixedSizeBuffer::FixedSizeBuffer(BlockManager &block_manager, MemoryTag memory_t
 	memset(buffer_handle.GetDataMutable(), 0, block_size);
 }
 
+FixedSizeBuffer::FixedSizeBuffer(BlockManager &block_manager, const idx_t segment_count, const idx_t allocation_size,
+                                 shared_ptr<BlockHandle> block_handle_p)
+    : block_manager(block_manager), readers(0), segment_count(segment_count), allocation_size(allocation_size),
+      dirty(false), vacuum(false), loaded(false), block_pointer(), block_handle(std::move(block_handle_p)) {
+	D_ASSERT(block_handle);
+	buffer_handle = block_manager.buffer_manager.Pin(block_handle);
+	D_ASSERT(buffer_handle.IsValid());
+}
+
 FixedSizeBuffer::FixedSizeBuffer(BlockManager &block_manager, const idx_t segment_count, const idx_t allocation_size,
                                  const BlockPointer &block_pointer)
     : block_manager(block_manager), readers(0), segment_count(segment_count), allocation_size(allocation_size),
diff --git a/src/include/duckdb/execution/index/fixed_size_buffer.hpp b/src/include/duckdb/execution/index/fixed_size_buffer.hpp
index e7ee6878abd2..a2119c50a9ba 100644
--- a/src/include/duckdb/execution/index/fixed_size_buffer.hpp
+++ b/src/include/duckdb/execution/index/fixed_size_buffer.hpp
@@ -44,6 +44,8 @@ class FixedSizeBuffer {
 public:
 	//! Constructor for a new in-memory buffer
 	explicit FixedSizeBuffer(BlockManager &block_manager, MemoryTag memory_tag);
+	FixedSizeBuffer(BlockManager &block_manager, const idx_t segment_count, const idx_t allocation_size,
+	                shared_ptr<BlockHandle> block_handle);
 	//! Constructor for deserializing buffer metadata from disk
 	FixedSizeBuffer(BlockManager &block_manager, const idx_t segment_count, const idx_t allocation_size,
 	                const BlockPointer &block_pointer);
diff --git a/src/include/duckdb/storage/index_storage_info.hpp b/src/include/duckdb/storage/index_storage_info.hpp
index 704b6c809870..3f29aebd795b 100644
--- a/src/include/duckdb/storage/index_storage_info.hpp
+++ b/src/include/duckdb/storage/index_storage_info.hpp
@@ -10,9 +10,11 @@
 
 #include "duckdb/common/case_insensitive_map.hpp"
 #include "duckdb/common/identifier.hpp"
+#include "duckdb/common/shared_ptr.hpp"
 #include "duckdb/common/types/value.hpp"
 #include "duckdb/common/unordered_set.hpp"
 #include "duckdb/storage/block.hpp"
+#include "duckdb/storage/buffer/block_handle.hpp"
 
 namespace duckdb {
 
@@ -24,6 +26,9 @@ struct FixedSizeAllocatorInfo {
 	vector<idx_t> segment_counts;
 	vector<idx_t> allocation_sizes;
 	vector<idx_t> buffers_with_free_space;
+	//! Transient block handles used to initialize read-only entities (i.e., index entries during WAL replay)
+	//! These are transient runtime state and are never serialized.
+	shared_ptr<vector<shared_ptr<BlockHandle>>> transient_block_handles;
 
 	void Serialize(Serializer &serializer) const;
 	static FixedSizeAllocatorInfo Deserialize(Deserializer &deserializer);
diff --git a/src/storage/wal_replay.cpp b/src/storage/wal_replay.cpp
index 40085f12d3f8..00f321c43461 100644
--- a/src/storage/wal_replay.cpp
+++ b/src/storage/wal_replay.cpp
@@ -62,7 +62,8 @@ class ReplayState {
 	struct ReplayIndexInfo {
 		ReplayIndexInfo(TableIndexList &index_list, unique_ptr<Index> index, const Identifier &table_schema,
 		                const Identifier &table_name)
-		    : index_list(index_list), index(std::move(index)), table_schema(table_schema), table_name(table_name) {};
+		    : index_list(index_list), index(std::move(index)), table_schema(table_schema), table_name(table_name) {
+		}
 
 		reference<TableIndexList> index_list;
 		unique_ptr<Index> index;
@@ -796,6 +797,16 @@ void WriteAheadLogDeserializer::ReplayIndexData(IndexStorageInfo &info) {
 
 			list.ReadElement<bool>(data_ptr, data_info.allocation_sizes[j]);
 
+			// For read-only mode, retain the transient block handle and release the pin held by the buffer handle. The
+			// buffer can then be evicted to temporary storage until the index is bound.
+			if (db.IsReadOnly()) {
+				if (!data_info.transient_block_handles) {
+					data_info.transient_block_handles = make_shared_ptr<vector<shared_ptr<BlockHandle>>>();
+				}
+				data_info.transient_block_handles->push_back(std::move(block_handle));
+				continue;
+			}
+
 			// Convert the buffer handle to a persistent block and store the block id.
 			if (!deserialize_only) {
 				auto block_id = block_manager->GetFreeBlockIdForCheckpoint();


Test patch:
diff --git a/test/sql/storage/test_read_only_wal_replay.test b/test/sql/storage/test_read_only_wal_replay.test
index 690b11068d2b..b84f13cc6bf5 100644
--- a/test/sql/storage/test_read_only_wal_replay.test
+++ b/test/sql/storage/test_read_only_wal_replay.test
@@ -25,3 +25,108 @@ ATTACH '{TEST_DIR}/read_only_wal_replay.db' AS db (READ_ONLY)
 
 statement ok
 DETACH db
+
+statement ok
+ATTACH '{TEST_DIR}/read_only_wal_replay_index.db' AS db;
+
+statement ok
+CREATE TABLE db.index_tbl(id INTEGER PRIMARY KEY, grp INTEGER);
+
+statement ok
+INSERT INTO db.index_tbl VALUES (1, 10), (2, 20), (3, 10);
+
+statement ok
+CREATE INDEX idx_grp ON db.index_tbl(grp);
+
+statement ok
+DETACH db;
+
+statement ok
+ATTACH '{TEST_DIR}/read_only_wal_replay_index.db' AS db (READ_ONLY);
+
+query I
+SELECT count(*) FROM db.index_tbl;
+----
+3
+
+query I
+SELECT id FROM db.index_tbl WHERE grp = 10 ORDER BY id;
+----
+1
+3
+
+statement ok
+DETACH db;
+
+statement ok
+ATTACH '{TEST_DIR}/read_only_wal_replay_create_index_add_column.db' AS db;
+
+statement ok
+CREATE TABLE db.tbl(i INTEGER);
+
+statement ok
+INSERT INTO db.tbl VALUES (1), (2);
+
+statement ok
+BEGIN;
+
+statement ok
+CREATE INDEX idx ON db.tbl(i);
+
+statement ok
+ALTER TABLE db.tbl ADD COLUMN j INTEGER DEFAULT 42;
+
+statement ok
+COMMIT;
+
+statement ok
+DETACH db;
+
+statement ok
+ATTACH '{TEST_DIR}/read_only_wal_replay_create_index_add_column.db' AS db (READ_ONLY);
+
+statement ok
+SET index_scan_percentage=1;
+
+statement ok
+SET index_scan_max_count=999999999;
+
+query II
+SELECT i, j FROM db.tbl WHERE i = 2;
+----
+2	42
+
+statement ok
+DETACH db;
+
+statement ok
+ATTACH '{TEST_DIR}/read_only_wal_replay_alter_primary_key.db' AS db;
+
+statement ok
+CREATE TABLE db.pk_tbl(i INTEGER);
+
+statement ok
+INSERT INTO db.pk_tbl VALUES (1), (2);
+
+statement ok
+ALTER TABLE db.pk_tbl ADD PRIMARY KEY(i);
+
+statement ok
+DETACH db;
+
+statement ok
+ATTACH '{TEST_DIR}/read_only_wal_replay_alter_primary_key.db' AS db (READ_ONLY);
+
+statement ok
+SET index_scan_percentage=1;
+
+statement ok
+SET index_scan_max_count=999999999;
+
+query I
+SELECT i FROM db.pk_tbl WHERE i = 2;
+----
+2
+
+statement ok
+DETACH db;