diff --git a/.config/nextest.toml b/.config/nextest.toml index 8826cc8..beb0ea8 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -13,9 +13,22 @@ fail-fast = false test-threads = "num-cpus" slow-timeout = { period = "30s", terminate-after = 4 } +[profile.sim-pr] +retries = 0 +fail-fast = true +test-threads = "num-cpus" +slow-timeout = { period = "60s", terminate-after = 4 } + +[profile.sim-nightly] +retries = 0 +fail-fast = false +test-threads = "num-cpus" +slow-timeout = { period = "300s", terminate-after = 2 } + [test-groups] serial-env-tests = { max-threads = 1 } heavy-load-tests = { max-threads = 4 } +io-heavy-sim = { max-threads = 2 } [[profile.default.overrides]] filter = "test(/import_with_verification/) | test(/plc_migration/)" @@ -45,6 +58,20 @@ test-group = "heavy-load-tests" filter = "binary(repo_lifecycle)" test-group = "heavy-load-tests" +[[profile.default.overrides]] +filter = "binary(sim_soak)" +slow-timeout = { period = "300s", terminate-after = 10 } +test-group = "io-heavy-sim" + +[[profile.default.overrides]] +filter = "binary(metastore_crash) | binary(sim_cross_store) | binary(sim_gc_concurrent) | binary(sim_reachability) | binary(sim_eventlog) | test(/sim_/)" +slow-timeout = { period = "120s", terminate-after = 8 } +test-group = "io-heavy-sim" + +[[profile.default.overrides]] +filter = "test(/test_scale_/) | test(/full_backup_and_restore/)" +slow-timeout = { period = "120s", terminate-after = 4 } + [[profile.ci.overrides]] filter = "test(/import_with_verification/) | test(/plc_migration/)" test-group = "serial-env-tests" diff --git a/.sqlx/query-033ed5638ef6e30f1648c1acdbc477c173798cb57eb0127c3a582a9633fa5012.json b/.sqlx/query-033ed5638ef6e30f1648c1acdbc477c173798cb57eb0127c3a582a9633fa5012.json deleted file mode 100644 index d35257a..0000000 --- a/.sqlx/query-033ed5638ef6e30f1648c1acdbc477c173798cb57eb0127c3a582a9633fa5012.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT seq, did, commit_cid\n FROM repo_seq\n WHERE event_type = 'commit'\n AND prev_cid IS NULL\n AND (blocks_cids IS NULL OR array_length(blocks_cids, 1) IS NULL OR array_length(blocks_cids, 1) = 0)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "seq", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "commit_cid", - "type_info": "Text" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - true - ] - }, - "hash": "033ed5638ef6e30f1648c1acdbc477c173798cb57eb0127c3a582a9633fa5012" -} diff --git a/.sqlx/query-d8524ad3f5dc03eb09ed60396a78df5003f804c43ad253d6476523eacdebf811.json b/.sqlx/query-0349b2af4d21105029ecc2bf81ad7be9987ec17ac088730596849d7a45558e57.json similarity index 77% rename from .sqlx/query-d8524ad3f5dc03eb09ed60396a78df5003f804c43ad253d6476523eacdebf811.json rename to .sqlx/query-0349b2af4d21105029ecc2bf81ad7be9987ec17ac088730596849d7a45558e57.json index b4fe68c..ad77640 100644 --- a/.sqlx/query-d8524ad3f5dc03eb09ed60396a78df5003f804c43ad253d6476523eacdebf811.json +++ b/.sqlx/query-0349b2af4d21105029ecc2bf81ad7be9987ec17ac088730596849d7a45558e57.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC", + "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2", "describe": { "columns": [ { @@ -50,26 +50,36 @@ }, { "ordinal": 9, + "name": "block_cids", + "type_info": "ByteaArray" + }, + { + "ordinal": 10, + "name": "block_data", + "type_info": "ByteaArray" + }, + { + "ordinal": 11, "name": "blocks_cids", "type_info": "TextArray" }, { - "ordinal": 10, + "ordinal": 12, "name": "handle", "type_info": "Text" }, { - "ordinal": 11, + "ordinal": 13, "name": "active", "type_info": "Bool" }, { - "ordinal": 12, + "ordinal": 14, "name": "status", "type_info": "Text" }, { - "ordinal": 13, + "ordinal": 15, "name": "rev", "type_info": "Text" } @@ -94,8 +104,10 @@ true, true, true, + true, + true, true ] }, - "hash": "d8524ad3f5dc03eb09ed60396a78df5003f804c43ad253d6476523eacdebf811" + "hash": "0349b2af4d21105029ecc2bf81ad7be9987ec17ac088730596849d7a45558e57" } diff --git a/.sqlx/query-12f5864ebff622fc52643de7151a40e984082851741b22f63a170728e734763b.json b/.sqlx/query-12f5864ebff622fc52643de7151a40e984082851741b22f63a170728e734763b.json deleted file mode 100644 index 4b705cb..0000000 --- a/.sqlx/query-12f5864ebff622fc52643de7151a40e984082851741b22f63a170728e734763b.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT cid FROM blocks ORDER BY created_at ASC LIMIT $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "cid", - "type_info": "Bytea" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false - ] - }, - "hash": "12f5864ebff622fc52643de7151a40e984082851741b22f63a170728e734763b" -} diff --git a/.sqlx/query-18fa821e4bd00ccf5d1d8395ba728e4905d69f9fe527b4d4b49c69deff52cea8.json b/.sqlx/query-18fa821e4bd00ccf5d1d8395ba728e4905d69f9fe527b4d4b49c69deff52cea8.json deleted file mode 100644 index a4d22d1..0000000 --- a/.sqlx/query-18fa821e4bd00ccf5d1d8395ba728e4905d69f9fe527b4d4b49c69deff52cea8.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT t.cid FROM UNNEST($1::bytea[]) AS t(cid)\n WHERE NOT EXISTS (\n SELECT 1 FROM user_blocks WHERE block_cid = t.cid\n )\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "cid", - "type_info": "Bytea" - } - ], - "parameters": { - "Left": [ - "ByteaArray" - ] - }, - "nullable": [ - null - ] - }, - "hash": "18fa821e4bd00ccf5d1d8395ba728e4905d69f9fe527b4d4b49c69deff52cea8" -} diff --git a/.sqlx/query-3b791fdb8e29043c980963d4d18e1e492c73c39818a8648a7af70555418fb5d1.json b/.sqlx/query-3b791fdb8e29043c980963d4d18e1e492c73c39818a8648a7af70555418fb5d1.json deleted file mode 100644 index 973efac..0000000 --- a/.sqlx/query-3b791fdb8e29043c980963d4d18e1e492c73c39818a8648a7af70555418fb5d1.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE repo_seq SET blocks_cids = $1 WHERE seq = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "TextArray", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "3b791fdb8e29043c980963d4d18e1e492c73c39818a8648a7af70555418fb5d1" -} diff --git a/.sqlx/query-b26bf97a27783eb7fb524a92dda3e68ef8470a9751fcaefe5fd2d7909dead54b.json b/.sqlx/query-4702a94384ce938d3f5384bc2106a48500f8749134e6bfd1c619a9151f5d3772.json similarity index 79% rename from .sqlx/query-b26bf97a27783eb7fb524a92dda3e68ef8470a9751fcaefe5fd2d7909dead54b.json rename to .sqlx/query-4702a94384ce938d3f5384bc2106a48500f8749134e6bfd1c619a9151f5d3772.json index 48ebe1f..cb58635 100644 --- a/.sqlx/query-b26bf97a27783eb7fb524a92dda3e68ef8470a9751fcaefe5fd2d7909dead54b.json +++ b/.sqlx/query-4702a94384ce938d3f5384bc2106a48500f8749134e6bfd1c619a9151f5d3772.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2", + "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1", "describe": { "columns": [ { @@ -50,33 +50,42 @@ }, { "ordinal": 9, + "name": "block_cids", + "type_info": "ByteaArray" + }, + { + "ordinal": 10, + "name": "block_data", + "type_info": "ByteaArray" + }, + { + "ordinal": 11, "name": "blocks_cids", "type_info": "TextArray" }, { - "ordinal": 10, + "ordinal": 12, "name": "handle", "type_info": "Text" }, { - "ordinal": 11, + "ordinal": 13, "name": "active", "type_info": "Bool" }, { - "ordinal": 12, + "ordinal": 14, "name": "status", "type_info": "Text" }, { - "ordinal": 13, + "ordinal": 15, "name": "rev", "type_info": "Text" } ], "parameters": { "Left": [ - "Int8", "Int8" ] }, @@ -94,8 +103,10 @@ true, true, true, + true, + true, true ] }, - "hash": "b26bf97a27783eb7fb524a92dda3e68ef8470a9751fcaefe5fd2d7909dead54b" + "hash": "4702a94384ce938d3f5384bc2106a48500f8749134e6bfd1c619a9151f5d3772" } diff --git a/.sqlx/query-bce9edc597592d2e7d672061883639bcdeff80be85e2ecacb6c75d8c027e6710.json b/.sqlx/query-5996774d097a484f81353d49e4a99b07c30a003377c924add576bfb48ddde29b.json similarity index 56% rename from .sqlx/query-bce9edc597592d2e7d672061883639bcdeff80be85e2ecacb6c75d8c027e6710.json rename to .sqlx/query-5996774d097a484f81353d49e4a99b07c30a003377c924add576bfb48ddde29b.json index 6c9bc29..77d94ed 100644 --- a/.sqlx/query-bce9edc597592d2e7d672061883639bcdeff80be85e2ecacb6c75d8c027e6710.json +++ b/.sqlx/query-5996774d097a484f81353d49e4a99b07c30a003377c924add576bfb48ddde29b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, rev)\n VALUES ($1, 'sync', $2, $3)\n RETURNING seq\n ", + "query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, rev, block_cids, block_data)\n VALUES ($1, 'sync', $2, $3, $4, $5)\n RETURNING seq\n ", "describe": { "columns": [ { @@ -13,12 +13,14 @@ "Left": [ "Text", "Text", - "Text" + "Text", + "ByteaArray", + "ByteaArray" ] }, "nullable": [ false ] }, - "hash": "bce9edc597592d2e7d672061883639bcdeff80be85e2ecacb6c75d8c027e6710" + "hash": "5996774d097a484f81353d49e4a99b07c30a003377c924add576bfb48ddde29b" } diff --git a/.sqlx/query-746ca87e98f6eaa9c060770c0d1bca6f0d4da60df2dc2db088e57bdc914cf233.json b/.sqlx/query-746ca87e98f6eaa9c060770c0d1bca6f0d4da60df2dc2db088e57bdc914cf233.json new file mode 100644 index 0000000..6ab6736 --- /dev/null +++ b/.sqlx/query-746ca87e98f6eaa9c060770c0d1bca6f0d4da60df2dc2db088e57bdc914cf233.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM repo_seq WHERE created_at < $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "746ca87e98f6eaa9c060770c0d1bca6f0d4da60df2dc2db088e57bdc914cf233" +} diff --git a/.sqlx/query-b8101757a50075d20147014e450cb7deb7e58f84310690c7bde61e1834dc5903.json b/.sqlx/query-89c8ce80d8c52b4668c34f63e48b51cacc7e8ff300958a192378f1d72de04f9a.json similarity index 75% rename from .sqlx/query-b8101757a50075d20147014e450cb7deb7e58f84310690c7bde61e1834dc5903.json rename to .sqlx/query-89c8ce80d8c52b4668c34f63e48b51cacc7e8ff300958a192378f1d72de04f9a.json index eb74ec8..739c4ce 100644 --- a/.sqlx/query-b8101757a50075d20147014e450cb7deb7e58f84310690c7bde61e1834dc5903.json +++ b/.sqlx/query-89c8ce80d8c52b4668c34f63e48b51cacc7e8ff300958a192378f1d72de04f9a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC", + "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2", "describe": { "columns": [ { @@ -50,32 +50,43 @@ }, { "ordinal": 9, + "name": "block_cids", + "type_info": "ByteaArray" + }, + { + "ordinal": 10, + "name": "block_data", + "type_info": "ByteaArray" + }, + { + "ordinal": 11, "name": "blocks_cids", "type_info": "TextArray" }, { - "ordinal": 10, + "ordinal": 12, "name": "handle", "type_info": "Text" }, { - "ordinal": 11, + "ordinal": 13, "name": "active", "type_info": "Bool" }, { - "ordinal": 12, + "ordinal": 14, "name": "status", "type_info": "Text" }, { - "ordinal": 13, + "ordinal": 15, "name": "rev", "type_info": "Text" } ], "parameters": { "Left": [ + "Int8", "Int8" ] }, @@ -93,8 +104,10 @@ true, true, true, + true, + true, true ] }, - "hash": "b8101757a50075d20147014e450cb7deb7e58f84310690c7bde61e1834dc5903" + "hash": "89c8ce80d8c52b4668c34f63e48b51cacc7e8ff300958a192378f1d72de04f9a" } diff --git a/.sqlx/query-8eecf8fef308716be88815eb59bb67ec7c534b3c821d55481b110e3e462ee366.json b/.sqlx/query-8eecf8fef308716be88815eb59bb67ec7c534b3c821d55481b110e3e462ee366.json deleted file mode 100644 index afdba00..0000000 --- a/.sqlx/query-8eecf8fef308716be88815eb59bb67ec7c534b3c821d55481b110e3e462ee366.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM blocks WHERE cid = ANY($1)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "ByteaArray" - ] - }, - "nullable": [] - }, - "hash": "8eecf8fef308716be88815eb59bb67ec7c534b3c821d55481b110e3e462ee366" -} diff --git a/.sqlx/query-40e848f48afb50f5e4820445aee874a482333e9090ef14ce44db3fd9721cfff0.json b/.sqlx/query-97dd3cfe72b74b4dac686c030a07f6eb56dabb2df72815f3248760b4e3f7950e.json similarity index 59% rename from .sqlx/query-40e848f48afb50f5e4820445aee874a482333e9090ef14ce44db3fd9721cfff0.json rename to .sqlx/query-97dd3cfe72b74b4dac686c030a07f6eb56dabb2df72815f3248760b4e3f7950e.json index 3df6a62..d27f516 100644 --- a/.sqlx/query-40e848f48afb50f5e4820445aee874a482333e9090ef14ce44db3fd9721cfff0.json +++ b/.sqlx/query-97dd3cfe72b74b4dac686c030a07f6eb56dabb2df72815f3248760b4e3f7950e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid, rev)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n RETURNING seq\n ", + "query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n RETURNING seq\n ", "describe": { "columns": [ { @@ -17,7 +17,8 @@ "Text", "Jsonb", "TextArray", - "TextArray", + "ByteaArray", + "ByteaArray", "Text", "Text" ] @@ -26,5 +27,5 @@ false ] }, - "hash": "40e848f48afb50f5e4820445aee874a482333e9090ef14ce44db3fd9721cfff0" + "hash": "97dd3cfe72b74b4dac686c030a07f6eb56dabb2df72815f3248760b4e3f7950e" } diff --git a/.sqlx/query-52df8ae809b990fa76e3ac45f37e352a9fc8f92f911a7b9c9be2fd9e8032b18c.json b/.sqlx/query-a325862f897484bb1a8863fd7a1bc3fd17ab20b6e30498f9900daf80609c413d.json similarity index 58% rename from .sqlx/query-52df8ae809b990fa76e3ac45f37e352a9fc8f92f911a7b9c9be2fd9e8032b18c.json rename to .sqlx/query-a325862f897484bb1a8863fd7a1bc3fd17ab20b6e30498f9900daf80609c413d.json index 0ba3dff..8beebee 100644 --- a/.sqlx/query-52df8ae809b990fa76e3ac45f37e352a9fc8f92f911a7b9c9be2fd9e8032b18c.json +++ b/.sqlx/query-a325862f897484bb1a8863fd7a1bc3fd17ab20b6e30498f9900daf80609c413d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, rev)\n VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7)\n RETURNING seq\n ", + "query": "\n INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, rev)\n VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7, $8)\n RETURNING seq\n ", "describe": { "columns": [ { @@ -16,7 +16,8 @@ "Text", "Jsonb", "TextArray", - "TextArray", + "ByteaArray", + "ByteaArray", "Text" ] }, @@ -24,5 +25,5 @@ false ] }, - "hash": "52df8ae809b990fa76e3ac45f37e352a9fc8f92f911a7b9c9be2fd9e8032b18c" + "hash": "a325862f897484bb1a8863fd7a1bc3fd17ab20b6e30498f9900daf80609c413d" } diff --git a/.sqlx/query-e7aa1080be9eb3a8ddf1f050c93dc8afd10478f41e22307014784b4ee3740b4a.json b/.sqlx/query-ad784aff9bb5c1f19f90a3b1c3b3694a3e1aa889b0fe3e55cfebb7af8c620256.json similarity index 78% rename from .sqlx/query-e7aa1080be9eb3a8ddf1f050c93dc8afd10478f41e22307014784b4ee3740b4a.json rename to .sqlx/query-ad784aff9bb5c1f19f90a3b1c3b3694a3e1aa889b0fe3e55cfebb7af8c620256.json index 01108ef..3887719 100644 --- a/.sqlx/query-e7aa1080be9eb3a8ddf1f050c93dc8afd10478f41e22307014784b4ee3740b4a.json +++ b/.sqlx/query-ad784aff9bb5c1f19f90a3b1c3b3694a3e1aa889b0fe3e55cfebb7af8c620256.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC\n LIMIT $2", + "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1 AND seq < $2\n ORDER BY seq ASC", "describe": { "columns": [ { @@ -50,26 +50,36 @@ }, { "ordinal": 9, + "name": "block_cids", + "type_info": "ByteaArray" + }, + { + "ordinal": 10, + "name": "block_data", + "type_info": "ByteaArray" + }, + { + "ordinal": 11, "name": "blocks_cids", "type_info": "TextArray" }, { - "ordinal": 10, + "ordinal": 12, "name": "handle", "type_info": "Text" }, { - "ordinal": 11, + "ordinal": 13, "name": "active", "type_info": "Bool" }, { - "ordinal": 12, + "ordinal": 14, "name": "status", "type_info": "Text" }, { - "ordinal": 13, + "ordinal": 15, "name": "rev", "type_info": "Text" } @@ -94,8 +104,10 @@ true, true, true, + true, + true, true ] }, - "hash": "e7aa1080be9eb3a8ddf1f050c93dc8afd10478f41e22307014784b4ee3740b4a" + "hash": "ad784aff9bb5c1f19f90a3b1c3b3694a3e1aa889b0fe3e55cfebb7af8c620256" } diff --git a/.sqlx/query-c15d538fece4cd7db0056f9e0374a2290e26f5766881fd9dbf4f05788845aac0.json b/.sqlx/query-c15d538fece4cd7db0056f9e0374a2290e26f5766881fd9dbf4f05788845aac0.json deleted file mode 100644 index 2d1daaf..0000000 --- a/.sqlx/query-c15d538fece4cd7db0056f9e0374a2290e26f5766881fd9dbf4f05788845aac0.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT blocks_cids, commit_cid\n FROM repo_seq\n WHERE did = $1 AND rev > $2\n ORDER BY seq DESC", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "blocks_cids", - "type_info": "TextArray" - }, - { - "ordinal": 1, - "name": "commit_cid", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - true, - true - ] - }, - "hash": "c15d538fece4cd7db0056f9e0374a2290e26f5766881fd9dbf4f05788845aac0" -} diff --git a/.sqlx/query-0d32a592a97ad47c65aa37cf0d45417f2966fcbd688be7434626ae5f6971fa1f.json b/.sqlx/query-c47746c256fd8c9d386551f3bd270e9a96fecdb713642d07d60fb4aeb02c8426.json similarity index 77% rename from .sqlx/query-0d32a592a97ad47c65aa37cf0d45417f2966fcbd688be7434626ae5f6971fa1f.json rename to .sqlx/query-c47746c256fd8c9d386551f3bd270e9a96fecdb713642d07d60fb4aeb02c8426.json index 14a2d09..308502d 100644 --- a/.sqlx/query-0d32a592a97ad47c65aa37cf0d45417f2966fcbd688be7434626ae5f6971fa1f.json +++ b/.sqlx/query-c47746c256fd8c9d386551f3bd270e9a96fecdb713642d07d60fb4aeb02c8426.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq = $1", + "query": "SELECT seq, did, created_at, event_type as \"event_type: RepoEventType\", commit_cid, prev_cid, prev_data_cid,\n ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev\n FROM repo_seq\n WHERE seq > $1\n ORDER BY seq ASC", "describe": { "columns": [ { @@ -50,26 +50,36 @@ }, { "ordinal": 9, + "name": "block_cids", + "type_info": "ByteaArray" + }, + { + "ordinal": 10, + "name": "block_data", + "type_info": "ByteaArray" + }, + { + "ordinal": 11, "name": "blocks_cids", "type_info": "TextArray" }, { - "ordinal": 10, + "ordinal": 12, "name": "handle", "type_info": "Text" }, { - "ordinal": 11, + "ordinal": 13, "name": "active", "type_info": "Bool" }, { - "ordinal": 12, + "ordinal": 14, "name": "status", "type_info": "Text" }, { - "ordinal": 13, + "ordinal": 15, "name": "rev", "type_info": "Text" } @@ -93,8 +103,10 @@ true, true, true, + true, + true, true ] }, - "hash": "0d32a592a97ad47c65aa37cf0d45417f2966fcbd688be7434626ae5f6971fa1f" + "hash": "c47746c256fd8c9d386551f3bd270e9a96fecdb713642d07d60fb4aeb02c8426" } diff --git a/Cargo.lock b/Cargo.lock index ad1f7ff..3f13904 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7405,7 +7405,7 @@ dependencies = [ [[package]] name = "tranquil-api" -version = "0.4.7" +version = "0.5.0" dependencies = [ "anyhow", "axum", @@ -7456,7 +7456,7 @@ dependencies = [ [[package]] name = "tranquil-auth" -version = "0.4.7" +version = "0.5.0" dependencies = [ "anyhow", "base32", @@ -7479,7 +7479,7 @@ dependencies = [ [[package]] name = "tranquil-cache" -version = "0.4.7" +version = "0.5.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7493,7 +7493,7 @@ dependencies = [ [[package]] name = "tranquil-comms" -version = "0.4.7" +version = "0.5.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7511,7 +7511,7 @@ dependencies = [ [[package]] name = "tranquil-config" -version = "0.4.7" +version = "0.5.0" dependencies = [ "confique", "serde", @@ -7519,7 +7519,7 @@ dependencies = [ [[package]] name = "tranquil-crypto" -version = "0.4.7" +version = "0.5.0" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -7535,7 +7535,7 @@ dependencies = [ [[package]] name = "tranquil-db" -version = "0.4.7" +version = "0.5.0" dependencies = [ "async-trait", "chrono", @@ -7552,7 +7552,7 @@ dependencies = [ [[package]] name = "tranquil-db-traits" -version = "0.4.7" +version = "0.5.0" dependencies = [ "async-trait", "base64 0.22.1", @@ -7568,7 +7568,7 @@ dependencies = [ [[package]] name = "tranquil-infra" -version = "0.4.7" +version = "0.5.0" dependencies = [ "async-trait", "bytes", @@ -7579,7 +7579,7 @@ dependencies = [ [[package]] name = "tranquil-lexicon" -version = "0.4.7" +version = "0.5.0" dependencies = [ "chrono", "hickory-resolver", @@ -7597,7 +7597,7 @@ dependencies = [ [[package]] name = "tranquil-oauth" -version = "0.4.7" +version = "0.5.0" dependencies = [ "anyhow", "axum", @@ -7620,7 +7620,7 @@ dependencies = [ [[package]] name = "tranquil-oauth-server" -version = "0.4.7" +version = "0.5.0" dependencies = [ "axum", "base64 0.22.1", @@ -7653,7 +7653,7 @@ dependencies = [ [[package]] name = "tranquil-pds" -version = "0.4.7" +version = "0.5.0" dependencies = [ "aes-gcm", "anyhow", @@ -7744,7 +7744,7 @@ dependencies = [ [[package]] name = "tranquil-repo" -version = "0.4.7" +version = "0.5.0" dependencies = [ "bytes", "cid", @@ -7756,7 +7756,7 @@ dependencies = [ [[package]] name = "tranquil-ripple" -version = "0.4.7" +version = "0.5.0" dependencies = [ "async-trait", "backon", @@ -7781,7 +7781,7 @@ dependencies = [ [[package]] name = "tranquil-scopes" -version = "0.4.7" +version = "0.5.0" dependencies = [ "axum", "futures", @@ -7797,7 +7797,7 @@ dependencies = [ [[package]] name = "tranquil-server" -version = "0.4.7" +version = "0.5.0" dependencies = [ "axum", "clap", @@ -7818,7 +7818,7 @@ dependencies = [ [[package]] name = "tranquil-signal" -version = "0.4.7" +version = "0.5.0" dependencies = [ "async-trait", "chrono", @@ -7829,17 +7829,19 @@ dependencies = [ "serde", "serde_json", "sqlx", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", "tracing", + "tranquil-signal", "url", "uuid", ] [[package]] name = "tranquil-storage" -version = "0.4.7" +version = "0.5.0" dependencies = [ "async-trait", "aws-config", @@ -7856,7 +7858,7 @@ dependencies = [ [[package]] name = "tranquil-store" -version = "0.4.7" +version = "0.5.0" dependencies = [ "async-trait", "bytes", @@ -7876,6 +7878,7 @@ dependencies = [ "postcard", "proptest", "rand 0.8.5", + "rayon", "serde", "serde_ipld_dagcbor", "serde_json", @@ -7888,10 +7891,12 @@ dependencies = [ "tikv-jemallocator", "tokio", "tracing", + "tracing-subscriber", "tranquil-db", "tranquil-db-traits", "tranquil-oauth", "tranquil-repo", + "tranquil-store", "tranquil-types", "uuid", "xxhash-rust", @@ -7899,7 +7904,7 @@ dependencies = [ [[package]] name = "tranquil-sync" -version = "0.4.7" +version = "0.5.0" dependencies = [ "anyhow", "axum", @@ -7921,7 +7926,7 @@ dependencies = [ [[package]] name = "tranquil-types" -version = "0.4.7" +version = "0.5.0" dependencies = [ "chrono", "cid", diff --git a/crates/tranquil-api/src/admin/account/mod.rs b/crates/tranquil-api/src/admin/account/mod.rs index 4142316..81bfd85 100644 --- a/crates/tranquil-api/src/admin/account/mod.rs +++ b/crates/tranquil-api/src/admin/account/mod.rs @@ -11,6 +11,7 @@ pub use info::{ }; pub use search::{SearchAccountsOutput, SearchAccountsParams, search_accounts}; pub use update::{ - UpdateAccountEmailInput, UpdateAccountHandleInput, UpdateAccountPasswordInput, - update_account_email, update_account_handle, update_account_password, + SetAdminStatusInput, UpdateAccountEmailInput, UpdateAccountHandleInput, + UpdateAccountPasswordInput, set_admin_status, update_account_email, update_account_handle, + update_account_password, }; diff --git a/crates/tranquil-api/src/admin/account/update.rs b/crates/tranquil-api/src/admin/account/update.rs index 2c8f6a6..489d20b 100644 --- a/crates/tranquil-api/src/admin/account/update.rs +++ b/crates/tranquil-api/src/admin/account/update.rs @@ -1,6 +1,6 @@ use axum::{Json, extract::State}; use serde::Deserialize; -use tracing::{error, warn}; +use tracing::{error, info, warn}; use tranquil_pds::api::EmptyResponse; use tranquil_pds::api::error::ApiError; use tranquil_pds::auth::{Admin, Auth}; @@ -166,3 +166,34 @@ pub async fn update_account_password( } } } + +#[derive(Deserialize)] +pub struct SetAdminStatusInput { + pub did: Did, + pub admin: bool, +} + +pub async fn set_admin_status( + State(state): State, + auth: Auth, + Json(input): Json, +) -> Result, ApiError> { + info!( + actor = %auth.did, + target = %input.did, + admin = input.admin, + "admin status change" + ); + + state + .repos + .user + .set_admin_status(&input.did, input.admin) + .await + .map_err(|e| { + error!("DB error setting admin status: {:?}", e); + ApiError::InternalError(None) + })?; + + Ok(Json(EmptyResponse {})) +} diff --git a/crates/tranquil-api/src/admin/mod.rs b/crates/tranquil-api/src/admin/mod.rs index c6f72b9..d25b81d 100644 --- a/crates/tranquil-api/src/admin/mod.rs +++ b/crates/tranquil-api/src/admin/mod.rs @@ -7,7 +7,7 @@ pub mod status; pub use account::{ delete_account, get_account_info, get_account_infos, search_accounts, send_email, - update_account_email, update_account_handle, update_account_password, + set_admin_status, update_account_email, update_account_handle, update_account_password, }; pub use config::{get_server_config, update_server_config}; pub use invite::{ diff --git a/crates/tranquil-api/src/lib.rs b/crates/tranquil-api/src/lib.rs index 933bd74..a7b4047 100644 --- a/crates/tranquil-api/src/lib.rs +++ b/crates/tranquil-api/src/lib.rs @@ -330,6 +330,7 @@ pub fn api_routes() -> axum::Router { get(admin::get_invite_codes), ) .route("/_admin.getServerStats", get(admin::get_server_stats)) + .route("/_admin.setAdminStatus", post(admin::set_admin_status)) .route("/_admin.getSignalStatus", get(admin::get_signal_status)) .route("/_admin.linkSignalDevice", post(admin::link_signal_device)) .route( diff --git a/crates/tranquil-api/src/repo/import.rs b/crates/tranquil-api/src/repo/import.rs index b4d3945..87973c0 100644 --- a/crates/tranquil-api/src/repo/import.rs +++ b/crates/tranquil-api/src/repo/import.rs @@ -328,7 +328,8 @@ pub async fn import_repo( new_root_str, new_rev_str ); if !is_migration - && let Err(e) = sequence_import_event(&state, did, &new_root_cid_link).await + && let Err(e) = + sequence_import_event(&state, did, &new_root_cid_link, &commit_bytes).await { warn!("Failed to sequence import event: {:?}", e); } @@ -395,15 +396,23 @@ async fn sequence_import_event( state: &AppState, did: &Did, commit_cid: &CidLink, -) -> Result<(), tranquil_db::DbError> { - let data = tranquil_db::CommitEventData { + commit_bytes: &[u8], +) -> Result<(), tranquil_db_traits::DbError> { + let commit_cid_parsed = commit_cid + .to_cid() + .expect("CidLink invariant: validated at construction"); + let inline_commit = tranquil_db_traits::EventBlockInline { + cid_bytes: commit_cid_parsed.to_bytes(), + data: commit_bytes.to_vec(), + }; + let data = tranquil_db_traits::CommitEventData { did: did.clone(), - event_type: tranquil_db::RepoEventType::Commit, + event_type: tranquil_db_traits::RepoEventType::Commit, commit_cid: Some(commit_cid.clone()), prev_cid: None, ops: Some(serde_json::json!([])), blobs: Some(vec![]), - blocks_cids: Some(vec![]), + blocks: Some(vec![inline_commit]), prev_data_cid: None, rev: None, }; diff --git a/crates/tranquil-api/src/repo/record/batch.rs b/crates/tranquil-api/src/repo/record/batch.rs index 5e6e630..299d7e0 100644 --- a/crates/tranquil-api/src/repo/record/batch.rs +++ b/crates/tranquil-api/src/repo/record/batch.rs @@ -94,7 +94,7 @@ async fn process_single_write( ops.push(RecordOp::Create { collection: collection.clone(), rkey: rkey.clone(), - cid: record_cid, + cid: tranquil_pds::cid_types::RecordCid::from(record_cid), }); Ok(WriteAccumulator { mst: new_mst, @@ -134,7 +134,15 @@ async fn process_single_write( .map_err(|_| ApiError::InternalError(Some("Failed to store record".into())))?; let key = format!("{}/{}", collection, rkey); modified_keys.push(key.clone()); - let prev_record_cid = mst.get(&key).await.ok().flatten(); + let prev_record_cid = mst + .get(&key) + .await + .map_err(|e| { + ApiError::InternalError(Some(format!("Failed to read prev record: {}", e))) + })? + .ok_or_else(|| { + ApiError::InvalidRequest("Update target record does not exist".into()) + })?; let new_mst = mst .update(&key, record_cid) .await @@ -150,8 +158,8 @@ async fn process_single_write( ops.push(RecordOp::Update { collection: collection.clone(), rkey: rkey.clone(), - cid: record_cid, - prev: prev_record_cid, + cid: tranquil_pds::cid_types::RecordCid::from(record_cid), + prev: tranquil_pds::cid_types::RecordCid::from(prev_record_cid), }); Ok(WriteAccumulator { mst: new_mst, @@ -166,7 +174,15 @@ async fn process_single_write( WriteOp::Delete { collection, rkey } => { let key = format!("{}/{}", collection, rkey); modified_keys.push(key.clone()); - let prev_record_cid = mst.get(&key).await.ok().flatten(); + let prev_record_cid = mst + .get(&key) + .await + .map_err(|e| { + ApiError::InternalError(Some(format!("Failed to read prev record: {}", e))) + })? + .ok_or_else(|| { + ApiError::InvalidRequest("Delete target record does not exist".into()) + })?; let new_mst = mst .delete(&key) .await @@ -176,7 +192,7 @@ async fn process_single_write( ops.push(RecordOp::Delete { collection: collection.clone(), rkey: rkey.clone(), - prev: prev_record_cid, + prev: tranquil_pds::cid_types::RecordCid::from(prev_record_cid), }); Ok(WriteAccumulator { mst: new_mst, diff --git a/crates/tranquil-api/src/repo/record/delete.rs b/crates/tranquil-api/src/repo/record/delete.rs index a1b804b..6ffd57e 100644 --- a/crates/tranquil-api/src/repo/record/delete.rs +++ b/crates/tranquil-api/src/repo/record/delete.rs @@ -1,20 +1,16 @@ use crate::repo::record::write::{CommitInfo, prepare_repo_write}; use axum::{Json, extract::State}; use cid::Cid; -use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore}; use serde::{Deserialize, Serialize}; use serde_json::json; use std::str::FromStr; -use std::sync::Arc; use tracing::error; use tranquil_pds::api::error::ApiError; use tranquil_pds::auth::{Active, Auth, VerifyScope}; -use tranquil_pds::repo::TrackingBlockStore; -use tranquil_pds::repo_ops::{ - CommitError, FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write, -}; +use tranquil_pds::cid_types::RecordCid; +use tranquil_pds::repo_ops::{FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write}; use tranquil_pds::state::AppState; -use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey}; +use tranquil_pds::types::{AtIdentifier, AtUri, Nsid, Rkey}; #[derive(Deserialize)] pub struct DeleteRecordInput { @@ -59,20 +55,23 @@ pub async fn delete_record( } } - let prev_record_cid = mst.get(&key).await.ok().flatten(); - if prev_record_cid.is_none() { + let prev_record_cid = mst.get(&key).await.map_err(|e| { + error!("Failed to read prev record from MST: {}", e); + ApiError::InternalError(Some("Failed to read MST".into())) + })?; + let Some(prev_record_cid) = prev_record_cid else { return Ok(Json(DeleteRecordOutput { commit: None })); - } + }; let new_mst = mst.delete(&key).await.map_err(|e| { - error!("Failed to delete from MST: {:?}", e); + error!("Failed to delete from MST: {}", e); ApiError::InternalError(Some("Failed to delete from MST".into())) })?; let op = RecordOp::Delete { collection: input.collection.clone(), rkey: input.rkey.clone(), - prev: prev_record_cid, + prev: RecordCid::from(prev_record_cid), }; let modified_keys = [key]; @@ -109,124 +108,3 @@ pub async fn delete_record( }), })) } - -use uuid::Uuid; - -pub async fn delete_record_internal( - state: &AppState, - did: &Did, - user_id: Uuid, - collection: &Nsid, - rkey: &Rkey, -) -> Result<(), CommitError> { - use tranquil_pds::repo_ops::{CommitParams, RecordOp, commit_and_log}; - - let _write_lock = state.repo_write_locks.lock(user_id).await; - - let root_cid_str = state - .repos - .repo - .get_repo_root_cid_by_user_id(user_id) - .await - .map_err(|e| CommitError::DatabaseError(e.to_string()))? - .ok_or(CommitError::RepoNotFound)?; - - let current_root_cid = - Cid::from_str(root_cid_str.as_str()).map_err(|e| CommitError::InvalidCid(e.to_string()))?; - - let tracking_store = TrackingBlockStore::new(state.block_store.clone()); - let commit_bytes = tracking_store - .get(¤t_root_cid) - .await - .map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))? - .ok_or(CommitError::BlockStoreFailed( - "Commit block not found".into(), - ))?; - - let commit = Commit::from_cbor(&commit_bytes) - .map_err(|e| CommitError::CommitParseFailed(format!("{:?}", e)))?; - - let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); - let key = format!("{}/{}", collection, rkey); - - let prev_record_cid = mst - .get(&key) - .await - .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; - - let Some(prev_cid) = prev_record_cid else { - return Ok(()); - }; - - let new_mst = mst - .delete(&key) - .await - .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; - - let new_mst_root = new_mst - .persist() - .await - .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; - - let op = RecordOp::Delete { - collection: collection.clone(), - rkey: rkey.clone(), - prev: Some(prev_cid), - }; - - let mut new_mst_blocks = std::collections::BTreeMap::new(); - let mut old_mst_blocks = std::collections::BTreeMap::new(); - - new_mst - .blocks_for_path(&key, &mut new_mst_blocks) - .await - .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; - - mst.blocks_for_path(&key, &mut old_mst_blocks) - .await - .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; - - let obsolete_cids: Vec = std::iter::once(current_root_cid) - .chain( - old_mst_blocks - .keys() - .filter(|cid| !new_mst_blocks.contains_key(*cid)) - .copied(), - ) - .chain(std::iter::once(prev_cid)) - .collect(); - - let mut relevant_blocks = new_mst_blocks; - relevant_blocks.extend(old_mst_blocks); - - let written_cids: Vec = tracking_store - .get_all_relevant_cids() - .into_iter() - .chain(relevant_blocks.keys().copied()) - .collect::>() - .into_iter() - .collect(); - - let written_cids_str: Vec = written_cids.iter().map(ToString::to_string).collect(); - - let deleted_uri = AtUri::from_parts(did.as_str(), collection.as_str(), rkey.as_str()); - commit_and_log( - state, - CommitParams { - did, - user_id, - current_root_cid: Some(current_root_cid), - prev_data_cid: Some(commit.data), - new_mst_root, - ops: vec![op], - blocks_cids: &written_cids_str, - blobs: &[], - obsolete_cids, - backlinks_to_add: vec![], - backlinks_to_remove: vec![deleted_uri], - }, - ) - .await?; - - Ok(()) -} diff --git a/crates/tranquil-api/src/repo/record/mod.rs b/crates/tranquil-api/src/repo/record/mod.rs index 230548c..3440200 100644 --- a/crates/tranquil-api/src/repo/record/mod.rs +++ b/crates/tranquil-api/src/repo/record/mod.rs @@ -10,7 +10,7 @@ pub use pagination::PaginationDirection; pub use validation_mode::ValidationMode; pub use batch::apply_writes; -pub use delete::{DeleteRecordInput, delete_record, delete_record_internal}; +pub use delete::{DeleteRecordInput, delete_record}; pub use read::{GetRecordInput, ListRecordsInput, ListRecordsOutput, get_record, list_records}; pub use tranquil_pds::repo_ops::*; pub use write::{ diff --git a/crates/tranquil-api/src/repo/record/write.rs b/crates/tranquil-api/src/repo/record/write.rs index b039a76..64724fd 100644 --- a/crates/tranquil-api/src/repo/record/write.rs +++ b/crates/tranquil-api/src/repo/record/write.rs @@ -172,7 +172,7 @@ pub async fn create_record( ops.push(RecordOp::Delete { collection: conflict_collection, rkey: conflict_rkey, - prev: Some(prev_cid), + prev: tranquil_pds::cid_types::RecordCid::from(prev_cid), }); conflict_uris_to_cleanup.push(conflict_uri); } @@ -197,7 +197,7 @@ pub async fn create_record( ops.push(RecordOp::Create { collection: input.collection.clone(), rkey: rkey.clone(), - cid: record_cid, + cid: tranquil_pds::cid_types::RecordCid::from(record_cid), }); let modified_keys: Vec = ops @@ -338,41 +338,38 @@ pub async fn put_record( })); } - let is_update = existing_cid.is_some(); - let new_mst = if is_update { - mst.update(&key, record_cid) - .await - .map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))? - } else { - mst.add(&key, record_cid) - .await - .map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))? - }; - - let op = if is_update { - RecordOp::Update { - collection: input.collection.clone(), - rkey: input.rkey.clone(), - cid: record_cid, - prev: existing_cid, + let record_uri = AtUri::from_parts(&did, &input.collection, &input.rkey); + let (new_mst, op, is_update, backlinks_to_remove) = match existing_cid { + Some(prev_cid) => { + let new_mst = mst + .update(&key, record_cid) + .await + .map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?; + let op = RecordOp::Update { + collection: input.collection.clone(), + rkey: input.rkey.clone(), + cid: tranquil_pds::cid_types::RecordCid::from(record_cid), + prev: tranquil_pds::cid_types::RecordCid::from(prev_cid), + }; + (new_mst, op, true, vec![record_uri.clone()]) } - } else { - RecordOp::Create { - collection: input.collection.clone(), - rkey: input.rkey.clone(), - cid: record_cid, + None => { + let new_mst = mst + .add(&key, record_cid) + .await + .map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?; + let op = RecordOp::Create { + collection: input.collection.clone(), + rkey: input.rkey.clone(), + cid: tranquil_pds::cid_types::RecordCid::from(record_cid), + }; + (new_mst, op, false, vec![]) } }; let modified_keys = [key]; let blob_cids = extract_blob_cids(&input.record); - - let record_uri = AtUri::from_parts(&did, &input.collection, &input.rkey); let backlinks_to_add = extract_backlinks(&record_uri, &input.record); - let backlinks_to_remove = match is_update { - true => vec![record_uri.clone()], - false => vec![], - }; let commit_result = finalize_repo_write( &state, diff --git a/crates/tranquil-api/src/server/passkey_account.rs b/crates/tranquil-api/src/server/passkey_account.rs index 9b3ff3e..a1fc772 100644 --- a/crates/tranquil-api/src/server/passkey_account.rs +++ b/crates/tranquil-api/src/server/passkey_account.rs @@ -393,13 +393,13 @@ pub async fn create_passkey_account( Ok(token_meta) => { let refresh_jti = uuid::Uuid::new_v4().to_string(); let refresh_expires = chrono::Utc::now() + chrono::Duration::hours(24); - let session_data = tranquil_db::SessionTokenCreate { + let session_data = tranquil_db_traits::SessionTokenCreate { did: did_typed.clone(), access_jti: token_meta.jti.clone(), refresh_jti, access_expires_at: token_meta.expires_at, refresh_expires_at: refresh_expires, - login_type: tranquil_db::LoginType::Modern, + login_type: tranquil_db_traits::LoginType::Modern, mfa_verified: false, scope: Some("transition:generic".to_string()), controller_did: None, diff --git a/crates/tranquil-config/src/lib.rs b/crates/tranquil-config/src/lib.rs index 4ae86d1..b686b89 100644 --- a/crates/tranquil-config/src/lib.rs +++ b/crates/tranquil-config/src/lib.rs @@ -269,6 +269,38 @@ impl TranquilConfig { { errors.push("tranquil_store.handler_threads must be at least 1".to_string()); } + if self.tranquil_store.eventlog_max_event_payload == 0 { + errors.push( + "tranquil_store.eventlog_max_event_payload \ + (TRANQUIL_STORE_EVENTLOG_MAX_EVENT_PAYLOAD) must be at least 1; \ + a value of 0 would reject every event" + .to_string(), + ); + } + + // -- scheduled / event retention -------------------------------------- + const MAX_RETENTION_SECS: u64 = (i64::MAX / 1000) as u64; + if self.scheduled.event_retention_max_age_secs > MAX_RETENTION_SECS { + errors.push(format!( + "scheduled.event_retention_max_age_secs (EVENT_RETENTION_MAX_AGE_SECS) \ + must be at most {MAX_RETENTION_SECS} (chrono::Duration limit); got {}", + self.scheduled.event_retention_max_age_secs + )); + } + if self.scheduled.event_retention_interval_secs > 0 { + let backfill_secs = u64::try_from(self.firehose.backfill_hours.max(0)) + .unwrap_or(0) + .saturating_mul(3600); + if self.scheduled.event_retention_max_age_secs < backfill_secs { + errors.push(format!( + "scheduled.event_retention_max_age_secs ({}) is shorter than \ + firehose.backfill_hours ({}h = {backfill_secs}s): \ + relays would receive cursor responses pointing at pruned events. \ + Increase event_retention_max_age_secs or decrease firehose.backfill_hours.", + self.scheduled.event_retention_max_age_secs, self.firehose.backfill_hours, + )); + } + } // -- cache ------------------------------------------------------------ match self.cache.backend.as_str() { @@ -1057,9 +1089,45 @@ pub struct ScheduledConfig { #[config(env = "SCHEDULED_DELETE_CHECK_INTERVAL_SECS", default = 3600)] pub delete_check_interval_secs: u64, - /// Interval in seconds between block garbage collection cycles. - #[config(env = "BLOCK_GC_INTERVAL_SECS", default = 21600)] - pub block_gc_interval_secs: u64, + /// Interval in seconds between data file compaction scans (tranquil-store only). + /// Set to 0 to disable. + #[config(env = "COMPACTION_INTERVAL_SECS", default = 3600)] + pub compaction_interval_secs: u64, + + /// Liveness ratio threshold below which a data file is compacted (0.0-1.0). + #[config(env = "COMPACTION_LIVENESS_THRESHOLD", default = 0.7)] + pub compaction_liveness_threshold: f64, + + /// Grace period in milliseconds before a zero-refcount block can be removed by compaction. + #[config(env = "COMPACTION_GRACE_PERIOD_MS", default = 600000)] + pub compaction_grace_period_ms: u64, + + /// Interval in seconds between reachability walk runs (tranquil-store only). + /// Set to 0 to disable. Default: weekly. + #[config(env = "REACHABILITY_WALK_INTERVAL_SECS", default = 604800)] + pub reachability_walk_interval_secs: u64, + + /// Interval in seconds between continuous archival passes (tranquil-store only). + /// Sealed eventlog segments are copied to the archival destination each tick. + /// Set to 0 to disable. Default: 60 seconds. + #[config(env = "ARCHIVAL_INTERVAL_SECS", default = 60)] + pub archival_interval_secs: u64, + + /// Archival destination directory for sealed eventlog segments. + /// If unset, archival is disabled. + #[config(env = "ARCHIVAL_DEST_DIR")] + pub archival_dest_dir: Option, + + /// Maximum age of events retained in the eventlog before pruning. + /// Per the atproto firehose spec, the relay backfill window only needs + /// to cover "hours or days". Default: 7 days. + #[config(env = "EVENT_RETENTION_MAX_AGE_SECS", default = 604800)] + pub event_retention_max_age_secs: u64, + + /// Interval in seconds between event retention prune passes. + /// Set to 0 to disable. Default: hourly. + #[config(env = "EVENT_RETENTION_INTERVAL_SECS", default = 3600)] + pub event_retention_interval_secs: u64, } #[derive(Debug, Config)] @@ -1079,6 +1147,38 @@ pub struct TranquilStoreConfig { /// Number of handler threads. Defaults to available_parallelism / 2. #[config(env = "TRANQUIL_STORE_HANDLER_THREADS")] pub handler_threads: Option, + + /// Maximum total bytes of pending (unsynced) eventlog payloads. Appenders + /// block once this budget is exhausted until in-flight events drain via + /// fsync. Set to 0 to disable backpressure (unbounded). Default: 1 GiB. + #[config( + env = "TRANQUIL_STORE_EVENTLOG_PENDING_BYTES_BUDGET", + default = 1_073_741_824 + )] + pub eventlog_pending_bytes_budget: u64, + + /// Maximum size of an individual eventlog payload in bytes. Single events + /// larger than this are rejected at append time. Default: 256 MiB. + #[config( + env = "TRANQUIL_STORE_EVENTLOG_MAX_EVENT_PAYLOAD", + default = 268_435_456 + )] + pub eventlog_max_event_payload: u32, + + /// Maximum size of an individual blockstore data file in bytes. When the + /// active data file reaches this size it is rolled over and becomes + /// eligible for compaction. Default: 256 MiB. + #[config(env = "TRANQUIL_STORE_MAX_BLOCKSTORE_FILE_SIZE", default = 268_435_456)] + pub max_blockstore_file_size: u64, + + /// Maximum size of an individual eventlog segment file in bytes. When the + /// active segment reaches this size it is sealed and a new one is created. + /// Safe to change on a running instance. Default: 256 MiB. + #[config( + env = "TRANQUIL_STORE_MAX_EVENTLOG_SEGMENT_SIZE", + default = 268_435_456 + )] + pub max_eventlog_segment_size: u64, } /// Generate a TOML configuration template with all available options, diff --git a/crates/tranquil-db-traits/src/lib.rs b/crates/tranquil-db-traits/src/lib.rs index d0d6e74..9d5c53e 100644 --- a/crates/tranquil-db-traits/src/lib.rs +++ b/crates/tranquil-db-traits/src/lib.rs @@ -33,11 +33,12 @@ pub use oauth::{ ScopePreference, TokenFamilyId, TrustedDeviceRow, TwoFactorChallenge, }; pub use repo::{ - AccountStatus, ApplyCommitError, ApplyCommitInput, ApplyCommitResult, BrokenGenesisCommit, - CommitEventData, EventBlocksCids, FullRecordInfo, ImportBlock, ImportRecord, ImportRepoError, - RecordDelete, RecordInfo, RecordUpsert, RecordWithTakedown, RepoAccountInfo, RepoEventNotifier, - RepoEventReceiver, RepoEventType, RepoInfo, RepoListItem, RepoRepository, RepoSeqEvent, - RepoWithoutRev, SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks, + AccountStatus, ApplyCommitError, ApplyCommitInput, ApplyCommitResult, CommitEventData, + EventBlockInline, EventBlocks, FullRecordInfo, ImportBlock, ImportRecord, ImportRepoError, + PruneCount, RecordDelete, RecordInfo, RecordUpsert, RecordWithTakedown, RepoAccountInfo, + RepoEventNotifier, RepoEventReceiver, RepoEventType, RepoInfo, RepoListItem, RepoRepository, + RepoSeqEvent, RepoWithoutRev, SequencedEvent, UserNeedingRecordBlobsBackfill, + UserWithoutBlocks, }; pub use scope::{DbScope, InvalidScopeError}; pub use sequence::{SequenceNumber, deserialize_optional_sequence}; diff --git a/crates/tranquil-db-traits/src/repo.rs b/crates/tranquil-db-traits/src/repo.rs index 706fdb7..92bb210 100644 --- a/crates/tranquil-db-traits/src/repo.rs +++ b/crates/tranquil-db-traits/src/repo.rs @@ -165,13 +165,6 @@ pub struct RepoWithoutRev { pub repo_root_cid: CidLink, } -#[derive(Debug, Clone)] -pub struct BrokenGenesisCommit { - pub seq: SequenceNumber, - pub did: Did, - pub commit_cid: Option, -} - #[derive(Debug, Clone)] pub struct UserWithoutBlocks { pub user_id: Uuid, @@ -190,6 +183,51 @@ pub struct RepoSeqEvent { pub seq: SequenceNumber, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PruneCount { + Rows(u64), + Segments(u64), +} + +impl PruneCount { + pub fn is_zero(&self) -> bool { + match self { + Self::Rows(n) | Self::Segments(n) => *n == 0, + } + } + + pub fn count(&self) -> u64 { + match self { + Self::Rows(n) | Self::Segments(n) => *n, + } + } + + pub fn unit(&self) -> &'static str { + match self { + Self::Rows(_) => "rows", + Self::Segments(_) => "segments", + } + } +} + +impl std::fmt::Display for PruneCount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} {}", self.count(), self.unit()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventBlockInline { + pub cid_bytes: Vec, + pub data: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum EventBlocks { + Inline(Vec), + LegacyCids(Vec), +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SequencedEvent { pub seq: SequenceNumber, @@ -201,7 +239,7 @@ pub struct SequencedEvent { pub prev_data_cid: Option, pub ops: Option, pub blobs: Option>, - pub blocks_cids: Option>, + pub blocks: Option, pub handle: Option, pub active: Option, pub status: Option, @@ -216,17 +254,11 @@ pub struct CommitEventData { pub prev_cid: Option, pub ops: Option, pub blobs: Option>, - pub blocks_cids: Option>, + pub blocks: Option>, pub prev_data_cid: Option, pub rev: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EventBlocksCids { - pub blocks_cids: Option>, - pub commit_cid: Option, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RepoListItem { pub did: Did, @@ -413,11 +445,6 @@ pub trait RepoRepository: Send + Sync { async fn count_user_blocks(&self, user_id: Uuid) -> Result; - async fn find_unreferenced_blocks( - &self, - candidate_cids: &[Vec], - ) -> Result>, DbError>; - async fn insert_commit_event(&self, data: &CommitEventData) -> Result; async fn insert_identity_event( @@ -437,6 +464,7 @@ pub trait RepoRepository: Send + Sync { did: &Did, commit_cid: &CidLink, rev: Option<&str>, + commit_bytes: &[u8], ) -> Result; async fn insert_genesis_commit_event( @@ -445,20 +473,18 @@ pub trait RepoRepository: Send + Sync { commit_cid: &CidLink, mst_root_cid: &CidLink, rev: &str, + commit_bytes: &[u8], + mst_root_bytes: &[u8], ) -> Result; - async fn update_seq_blocks_cids( - &self, - seq: SequenceNumber, - blocks_cids: &[String], - ) -> Result<(), DbError>; - async fn delete_sequences_except( &self, did: &Did, keep_seq: SequenceNumber, ) -> Result<(), DbError>; + async fn prune_events_older_than(&self, cutoff: DateTime) -> Result; + async fn get_max_seq(&self) -> Result; async fn get_min_seq_since( @@ -491,12 +517,6 @@ pub trait RepoRepository: Send + Sync { limit: i64, ) -> Result, DbError>; - async fn get_events_since_rev( - &self, - did: &Did, - since_rev: &str, - ) -> Result, DbError>; - async fn list_repos_paginated( &self, cursor_did: Option<&Did>, @@ -521,8 +541,6 @@ pub trait RepoRepository: Send + Sync { input: ApplyCommitInput, ) -> Result; - async fn get_broken_genesis_commits(&self) -> Result, DbError>; - async fn get_users_without_blocks(&self) -> Result, DbError>; async fn get_users_needing_record_blobs_backfill( diff --git a/crates/tranquil-db/src/postgres/repo.rs b/crates/tranquil-db/src/postgres/repo.rs index 37ccec8..e9eea04 100644 --- a/crates/tranquil-db/src/postgres/repo.rs +++ b/crates/tranquil-db/src/postgres/repo.rs @@ -2,10 +2,10 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::PgPool; use tranquil_db_traits::{ - AccountStatus, BrokenGenesisCommit, CommitEventData, DbError, EventBlocksCids, FullRecordInfo, - ImportBlock, ImportRecord, ImportRepoError, RecordInfo, RecordWithTakedown, RepoAccountInfo, - RepoEventType, RepoInfo, RepoListItem, RepoRepository, RepoWithoutRev, SequenceNumber, - SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks, + AccountStatus, CommitEventData, DbError, EventBlockInline, EventBlocks, FullRecordInfo, + ImportBlock, ImportRecord, ImportRepoError, PruneCount, RecordInfo, RecordWithTakedown, + RepoAccountInfo, RepoEventType, RepoInfo, RepoListItem, RepoRepository, RepoWithoutRev, + SequenceNumber, SequencedEvent, UserNeedingRecordBlobsBackfill, UserWithoutBlocks, }; use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey}; use uuid::Uuid; @@ -27,6 +27,8 @@ struct SequencedEventRow { prev_data_cid: Option, ops: Option, blobs: Option>, + block_cids: Option>>, + block_data: Option>>, blocks_cids: Option>, handle: Option, active: Option, @@ -34,6 +36,81 @@ struct SequencedEventRow { rev: Option, } +fn row_to_event_blocks( + block_cids: Option>>, + block_data: Option>>, + legacy_blocks_cids: Option>, +) -> Result, DbError> { + match (block_cids, block_data) { + (Some(cids), Some(data)) if cids.len() == data.len() => match cids.is_empty() { + true => Ok(legacy_fallback(legacy_blocks_cids)), + false => Ok(Some(EventBlocks::Inline( + cids.into_iter() + .zip(data) + .map(|(cid_bytes, data)| EventBlockInline { cid_bytes, data }) + .collect(), + ))), + }, + (Some(_), Some(_)) => Err(DbError::CorruptData( + "repo_seq.block_cids/block_data length mismatch", + )), + (Some(_), None) | (None, Some(_)) => Err(DbError::CorruptData( + "repo_seq.block_cids/block_data partially populated", + )), + (None, None) => Ok(legacy_fallback(legacy_blocks_cids)), + } +} + +fn legacy_fallback(legacy_blocks_cids: Option>) -> Option { + match legacy_blocks_cids { + Some(cids) if !cids.is_empty() => Some(EventBlocks::LegacyCids(cids)), + _ => None, + } +} + +fn inline_to_paired_blocks(blocks: Option<&[EventBlockInline]>) -> (Vec>, Vec>) { + blocks + .map(|bs| { + bs.iter() + .map(|b| (b.cid_bytes.clone(), b.data.clone())) + .unzip() + }) + .unwrap_or_default() +} + +fn inline_into_paired_blocks( + blocks: Option>, +) -> (Vec>, Vec>) { + blocks + .map(|bs| bs.into_iter().map(|b| (b.cid_bytes, b.data)).unzip()) + .unwrap_or_default() +} + +fn map_sequenced_row(r: SequencedEventRow) -> Result { + let status = r + .status + .as_deref() + .and_then(AccountStatus::parse) + .or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active)); + let blocks = row_to_event_blocks(r.block_cids, r.block_data, r.blocks_cids)?; + Ok(SequencedEvent { + seq: r.seq.into(), + did: Did::from(r.did), + created_at: r.created_at, + event_type: r.event_type, + commit_cid: r.commit_cid.map(CidLink::from), + prev_cid: r.prev_cid.map(CidLink::from), + prev_data_cid: r.prev_data_cid.map(CidLink::from), + ops: r.ops, + blobs: r.blobs, + blocks, + handle: r.handle.map(Handle::from), + active: r.active, + status, + rev: r.rev, + }) +} + pub struct PostgresRepoRepository { pool: PgPool, } @@ -618,30 +695,6 @@ impl RepoRepository for PostgresRepoRepository { Ok(count) } - async fn find_unreferenced_blocks( - &self, - candidate_cids: &[Vec], - ) -> Result>, DbError> { - match candidate_cids.is_empty() { - true => Ok(Vec::new()), - false => { - let rows = sqlx::query!( - r#" - SELECT t.cid FROM UNNEST($1::bytea[]) AS t(cid) - WHERE NOT EXISTS ( - SELECT 1 FROM user_blocks WHERE block_cid = t.cid - ) - "#, - candidate_cids, - ) - .fetch_all(&self.pool) - .await - .map_err(map_sqlx_error)?; - Ok(rows.into_iter().filter_map(|r| r.cid).collect()) - } - } - } - async fn get_user_block_cids_since_rev( &self, user_id: Uuid, @@ -664,10 +717,11 @@ impl RepoRepository for PostgresRepoRepository { } async fn insert_commit_event(&self, data: &CommitEventData) -> Result { + let (block_cids, block_data) = inline_to_paired_blocks(data.blocks.as_deref()); let seq = sqlx::query_scalar!( r#" - INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid, rev) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING seq "#, data.did.as_str(), @@ -676,7 +730,8 @@ impl RepoRepository for PostgresRepoRepository { data.prev_cid.as_ref().map(|c| c.as_str()), data.ops, data.blobs.as_deref(), - data.blocks_cids.as_deref(), + &block_cids as &[Vec], + &block_data as &[Vec], data.prev_data_cid.as_ref().map(|c| c.as_str()), data.rev ) @@ -748,16 +803,25 @@ impl RepoRepository for PostgresRepoRepository { did: &Did, commit_cid: &CidLink, rev: Option<&str>, + commit_bytes: &[u8], ) -> Result { + let cid_bytes = commit_cid + .to_cid() + .map(|c| c.to_bytes()) + .unwrap_or_default(); + let block_cids: Vec> = vec![cid_bytes]; + let block_data: Vec> = vec![commit_bytes.to_vec()]; let seq = sqlx::query_scalar!( r#" - INSERT INTO repo_seq (did, event_type, commit_cid, rev) - VALUES ($1, 'sync', $2, $3) + INSERT INTO repo_seq (did, event_type, commit_cid, rev, block_cids, block_data) + VALUES ($1, 'sync', $2, $3, $4, $5) RETURNING seq "#, did.as_str(), commit_cid.as_str(), - rev + rev, + &block_cids as &[Vec], + &block_data as &[Vec] ) .fetch_one(&self.pool) .await @@ -777,16 +841,27 @@ impl RepoRepository for PostgresRepoRepository { commit_cid: &CidLink, mst_root_cid: &CidLink, rev: &str, + commit_bytes: &[u8], + mst_root_bytes: &[u8], ) -> Result { let ops = serde_json::json!([]); let blobs: Vec = vec![]; - let blocks_cids: Vec = vec![mst_root_cid.to_string(), commit_cid.to_string()]; + let commit_cid_bytes = commit_cid + .to_cid() + .map(|c| c.to_bytes()) + .unwrap_or_default(); + let mst_cid_bytes = mst_root_cid + .to_cid() + .map(|c| c.to_bytes()) + .unwrap_or_default(); + let block_cids: Vec> = vec![commit_cid_bytes, mst_cid_bytes]; + let block_data: Vec> = vec![commit_bytes.to_vec(), mst_root_bytes.to_vec()]; let prev_cid: Option<&str> = None; let seq = sqlx::query_scalar!( r#" - INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, rev) - VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7) + INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, rev) + VALUES ($1, 'commit', $2, $3::TEXT, $4, $5, $6, $7, $8) RETURNING seq "#, did.as_str(), @@ -794,7 +869,8 @@ impl RepoRepository for PostgresRepoRepository { prev_cid, ops, &blobs, - &blocks_cids, + &block_cids as &[Vec], + &block_data as &[Vec], rev ) .fetch_one(&self.pool) @@ -809,23 +885,6 @@ impl RepoRepository for PostgresRepoRepository { Ok(seq.into()) } - async fn update_seq_blocks_cids( - &self, - seq: SequenceNumber, - blocks_cids: &[String], - ) -> Result<(), DbError> { - sqlx::query!( - "UPDATE repo_seq SET blocks_cids = $1 WHERE seq = $2", - blocks_cids, - seq.as_i64() - ) - .execute(&self.pool) - .await - .map_err(map_sqlx_error)?; - - Ok(()) - } - async fn delete_sequences_except( &self, did: &Did, @@ -843,6 +902,15 @@ impl RepoRepository for PostgresRepoRepository { Ok(()) } + async fn prune_events_older_than(&self, cutoff: DateTime) -> Result { + let result = sqlx::query!("DELETE FROM repo_seq WHERE created_at < $1", cutoff) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + + Ok(PruneCount::Rows(result.rows_affected())) + } + async fn get_max_seq(&self) -> Result { let seq = sqlx::query_scalar!(r#"SELECT COALESCE(MAX(seq), 0) as "max!" FROM repo_seq"#) .fetch_one(&self.pool) @@ -893,35 +961,12 @@ impl RepoRepository for PostgresRepoRepository { since_seq: SequenceNumber, limit: Option, ) -> Result, DbError> { - let map_row = |r: SequencedEventRow| { - let status = r - .status - .as_deref() - .and_then(AccountStatus::parse) - .or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active)); - SequencedEvent { - seq: r.seq.into(), - did: Did::from(r.did), - created_at: r.created_at, - event_type: r.event_type, - commit_cid: r.commit_cid.map(CidLink::from), - prev_cid: r.prev_cid.map(CidLink::from), - prev_data_cid: r.prev_data_cid.map(CidLink::from), - ops: r.ops, - blobs: r.blobs, - blocks_cids: r.blocks_cids, - handle: r.handle.map(Handle::from), - active: r.active, - status, - rev: r.rev, - } - }; match limit { Some(lim) => { let rows = sqlx::query_as!( SequencedEventRow, r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, - ops, blobs, blocks_cids, handle, active, status, rev + ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev FROM repo_seq WHERE seq > $1 ORDER BY seq ASC @@ -932,13 +977,13 @@ impl RepoRepository for PostgresRepoRepository { .fetch_all(&self.pool) .await .map_err(map_sqlx_error)?; - Ok(rows.into_iter().map(map_row).collect()) + rows.into_iter().map(map_sequenced_row).collect() } None => { let rows = sqlx::query_as!( SequencedEventRow, r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, - ops, blobs, blocks_cids, handle, active, status, rev + ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev FROM repo_seq WHERE seq > $1 ORDER BY seq ASC"#, @@ -947,7 +992,7 @@ impl RepoRepository for PostgresRepoRepository { .fetch_all(&self.pool) .await .map_err(map_sqlx_error)?; - Ok(rows.into_iter().map(map_row).collect()) + rows.into_iter().map(map_sequenced_row).collect() } } } @@ -957,9 +1002,10 @@ impl RepoRepository for PostgresRepoRepository { start_seq: SequenceNumber, end_seq: SequenceNumber, ) -> Result, DbError> { - let rows = sqlx::query!( + let rows = sqlx::query_as!( + SequencedEventRow, r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, - ops, blobs, blocks_cids, handle, active, status, rev + ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev FROM repo_seq WHERE seq > $1 AND seq < $2 ORDER BY seq ASC"#, @@ -969,41 +1015,17 @@ impl RepoRepository for PostgresRepoRepository { .fetch_all(&self.pool) .await .map_err(map_sqlx_error)?; - Ok(rows - .into_iter() - .map(|r| { - let status = r - .status - .as_deref() - .and_then(AccountStatus::parse) - .or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active)); - SequencedEvent { - seq: r.seq.into(), - did: Did::from(r.did), - created_at: r.created_at, - event_type: r.event_type, - commit_cid: r.commit_cid.map(CidLink::from), - prev_cid: r.prev_cid.map(CidLink::from), - prev_data_cid: r.prev_data_cid.map(CidLink::from), - ops: r.ops, - blobs: r.blobs, - blocks_cids: r.blocks_cids, - handle: r.handle.map(Handle::from), - active: r.active, - status, - rev: r.rev, - } - }) - .collect()) + rows.into_iter().map(map_sequenced_row).collect() } async fn get_event_by_seq( &self, seq: SequenceNumber, ) -> Result, DbError> { - let row = sqlx::query!( + let row = sqlx::query_as!( + SequencedEventRow, r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, - ops, blobs, blocks_cids, handle, active, status, rev + ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev FROM repo_seq WHERE seq = $1"#, seq.as_i64() @@ -1011,29 +1033,7 @@ impl RepoRepository for PostgresRepoRepository { .fetch_optional(&self.pool) .await .map_err(map_sqlx_error)?; - Ok(row.map(|r| { - let status = r - .status - .as_deref() - .and_then(AccountStatus::parse) - .or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active)); - SequencedEvent { - seq: r.seq.into(), - did: Did::from(r.did), - created_at: r.created_at, - event_type: r.event_type, - commit_cid: r.commit_cid.map(CidLink::from), - prev_cid: r.prev_cid.map(CidLink::from), - prev_data_cid: r.prev_data_cid.map(CidLink::from), - ops: r.ops, - blobs: r.blobs, - blocks_cids: r.blocks_cids, - handle: r.handle.map(Handle::from), - active: r.active, - status, - rev: r.rev, - } - })) + row.map(map_sequenced_row).transpose() } async fn get_events_since_cursor( @@ -1041,9 +1041,10 @@ impl RepoRepository for PostgresRepoRepository { cursor: SequenceNumber, limit: i64, ) -> Result, DbError> { - let rows = sqlx::query!( + let rows = sqlx::query_as!( + SequencedEventRow, r#"SELECT seq, did, created_at, event_type as "event_type: RepoEventType", commit_cid, prev_cid, prev_data_cid, - ops, blobs, blocks_cids, handle, active, status, rev + ops, blobs, block_cids, block_data, blocks_cids, handle, active, status, rev FROM repo_seq WHERE seq > $1 ORDER BY seq ASC @@ -1054,58 +1055,7 @@ impl RepoRepository for PostgresRepoRepository { .fetch_all(&self.pool) .await .map_err(map_sqlx_error)?; - Ok(rows - .into_iter() - .map(|r| { - let status = r - .status - .as_deref() - .and_then(AccountStatus::parse) - .or_else(|| r.active.filter(|a| *a).map(|_| AccountStatus::Active)); - SequencedEvent { - seq: r.seq.into(), - did: Did::from(r.did), - created_at: r.created_at, - event_type: r.event_type, - commit_cid: r.commit_cid.map(CidLink::from), - prev_cid: r.prev_cid.map(CidLink::from), - prev_data_cid: r.prev_data_cid.map(CidLink::from), - ops: r.ops, - blobs: r.blobs, - blocks_cids: r.blocks_cids, - handle: r.handle.map(Handle::from), - active: r.active, - status, - rev: r.rev, - } - }) - .collect()) - } - - async fn get_events_since_rev( - &self, - did: &Did, - since_rev: &str, - ) -> Result, DbError> { - let rows = sqlx::query!( - r#"SELECT blocks_cids, commit_cid - FROM repo_seq - WHERE did = $1 AND rev > $2 - ORDER BY seq DESC"#, - did.as_str(), - since_rev - ) - .fetch_all(&self.pool) - .await - .map_err(map_sqlx_error)?; - - Ok(rows - .into_iter() - .map(|r| EventBlocksCids { - blocks_cids: r.blocks_cids, - commit_cid: r.commit_cid.map(CidLink::from), - }) - .collect()) + rows.into_iter().map(map_sequenced_row).collect() } async fn list_repos_paginated( @@ -1450,22 +1400,24 @@ impl RepoRepository for PostgresRepoRepository { .map_err(|e| ApplyCommitError::Database(e.to_string()))?; } - let event = &input.commit_event; + let event = input.commit_event; + let (event_block_cids, event_block_data) = inline_into_paired_blocks(event.blocks); let seq: i64 = sqlx::query_scalar( r#" - INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, blocks_cids, prev_data_cid, rev) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, ops, blobs, block_cids, block_data, prev_data_cid, rev) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING seq "#, ) - .bind(&event.did) + .bind(event.did.as_str()) .bind(event.event_type.as_str()) - .bind(&event.commit_cid) - .bind(&event.prev_cid) + .bind(event.commit_cid.as_ref().map(|c| c.as_str())) + .bind(event.prev_cid.as_ref().map(|c| c.as_str())) .bind(&event.ops) .bind(&event.blobs) - .bind(&event.blocks_cids) - .bind(&event.prev_data_cid) + .bind(&event_block_cids) + .bind(&event_block_data) + .bind(event.prev_data_cid.as_ref().map(|c| c.as_str())) .bind(&event.rev) .fetch_one(&mut *tx) .await @@ -1486,32 +1438,6 @@ impl RepoRepository for PostgresRepoRepository { }) } - async fn get_broken_genesis_commits( - &self, - ) -> Result, DbError> { - let rows = sqlx::query!( - r#" - SELECT seq, did, commit_cid - FROM repo_seq - WHERE event_type = 'commit' - AND prev_cid IS NULL - AND (blocks_cids IS NULL OR array_length(blocks_cids, 1) IS NULL OR array_length(blocks_cids, 1) = 0) - "# - ) - .fetch_all(&self.pool) - .await - .map_err(map_sqlx_error)?; - - Ok(rows - .into_iter() - .map(|r| BrokenGenesisCommit { - seq: r.seq.into(), - did: Did::from(r.did), - commit_cid: r.commit_cid.map(CidLink::from), - }) - .collect()) - } - async fn get_users_without_blocks(&self) -> Result, DbError> { let rows: Vec<(Uuid, String, Option)> = sqlx::query_as( r#" diff --git a/crates/tranquil-pds/src/auth/mod.rs b/crates/tranquil-pds/src/auth/mod.rs index 6a83aee..5329d81 100644 --- a/crates/tranquil-pds/src/auth/mod.rs +++ b/crates/tranquil-pds/src/auth/mod.rs @@ -7,8 +7,7 @@ use crate::api::ApiError; use crate::cache::Cache; use crate::oauth::scopes::ScopePermissions; use crate::types::Did; -use tranquil_db::UserRepository; -use tranquil_db_traits::OAuthRepository; +use tranquil_db_traits::{OAuthRepository, UserRepository}; pub mod account_verified; pub mod email_token; diff --git a/crates/tranquil-pds/src/crawlers.rs b/crates/tranquil-pds/src/crawlers.rs index c061e12..a9ed6c2 100644 --- a/crates/tranquil-pds/src/crawlers.rs +++ b/crates/tranquil-pds/src/crawlers.rs @@ -75,8 +75,8 @@ impl Crawlers { self.last_notified.store(now, Ordering::Relaxed); } - pub async fn notify_of_update(&self) { - if !self.should_notify() { + pub async fn notify_of_update(&self, force: bool) { + if !force && !self.should_notify() { debug!("Skipping crawler notification due to debounce"); return; } @@ -157,13 +157,17 @@ pub async fn start_crawlers_service( result = firehose_rx.recv() => { match result { Ok(event) => { - if event.event_type == RepoEventType::Commit { - crawlers.notify_of_update().await; + match event.event_type { + RepoEventType::Commit => crawlers.notify_of_update(false).await, + RepoEventType::Account | RepoEventType::Identity => { + crawlers.notify_of_update(true).await + } + RepoEventType::Sync => {} } } Err(broadcast::error::RecvError::Lagged(n)) => { warn!(skipped = n, "Crawlers service lagged behind firehose"); - crawlers.notify_of_update().await; + crawlers.notify_of_update(false).await; } Err(broadcast::error::RecvError::Closed) => { error!("Firehose channel closed, stopping crawlers service"); diff --git a/crates/tranquil-pds/src/oauth/mod.rs b/crates/tranquil-pds/src/oauth/mod.rs index 68acb96..a919b85 100644 --- a/crates/tranquil-pds/src/oauth/mod.rs +++ b/crates/tranquil-pds/src/oauth/mod.rs @@ -3,7 +3,7 @@ pub mod db; pub mod scopes; pub mod verify; -pub fn db_err_to_oauth(err: tranquil_db::DbError) -> OAuthError { +pub fn db_err_to_oauth(err: tranquil_db_traits::DbError) -> OAuthError { tracing::error!("Database error in OAuth flow: {}", err); OAuthError::ServerError("An internal error occurred".to_string()) } diff --git a/crates/tranquil-pds/src/repo/mod.rs b/crates/tranquil-pds/src/repo/mod.rs index 761d87f..894391a 100644 --- a/crates/tranquil-pds/src/repo/mod.rs +++ b/crates/tranquil-pds/src/repo/mod.rs @@ -22,6 +22,20 @@ impl AnyBlockStore { Self::TranquilStore(_) => None, } } + + pub fn as_tranquil_store(&self) -> Option<&TranquilBlockStore> { + match self { + Self::TranquilStore(s) => Some(s), + Self::Postgres(_) => None, + } + } + + pub async fn decrement_refs(&self, cids: &[Cid]) -> Result<(), RepoError> { + match self { + Self::Postgres(_) => Ok(()), + Self::TranquilStore(s) => s.decrement_refs(cids).await, + } + } } impl BlockStore for AnyBlockStore { diff --git a/crates/tranquil-pds/src/repo_ops.rs b/crates/tranquil-pds/src/repo_ops.rs index e17d623..249bfb9 100644 --- a/crates/tranquil-pds/src/repo_ops.rs +++ b/crates/tranquil-pds/src/repo_ops.rs @@ -1,16 +1,19 @@ use crate::api::error::ApiError; -use crate::cid_types::CommitCid; +use crate::cid_types::{CommitCid, RecordCid}; use crate::repo::TrackingBlockStore; use crate::state::AppState; use crate::types::{Did, Handle, Nsid, Rkey}; +use backon::{ExponentialBuilder, Retryable}; use bytes::Bytes; use cid::Cid; use jacquard_common::types::{integer::LimitedU32, string::Tid}; use jacquard_repo::commit::Commit; use jacquard_repo::mst::Mst; +use jacquard_repo::mst::util::compute_cid; use jacquard_repo::storage::BlockStore; use k256::ecdsa::SigningKey; use serde_json::{Value, json}; +use std::collections::BTreeSet; use std::str::FromStr; use std::sync::Arc; use tokio::sync::OwnedMutexGuard; @@ -147,6 +150,7 @@ pub fn extract_backlinks(uri: &AtUri, record: &Value) -> Vec { pub struct RepoWriteContext { pub tracking_store: TrackingBlockStore, pub current_root_cid: Cid, + pub prev_commit_bytes: Bytes, pub prev_data_cid: Cid, pub write_lock: OwnedMutexGuard<()>, } @@ -197,28 +201,55 @@ pub async fn begin_repo_write( .get(¤t_root_cid) .await .map_err(|e| { - error!("Failed to load commit block: {:?}", e); + error!("Failed to load commit block: {}", e); ApiError::InternalError(None) })? .ok_or_else(|| ApiError::InternalError(Some("Commit block not found".into())))?; - let commit = Commit::from_cbor(&commit_bytes).map_err(|e| { - error!("Failed to parse commit: {:?}", e); - ApiError::InternalError(None) - })?; + let prev_data_cid = Commit::from_cbor(&commit_bytes) + .map_err(|e| { + error!("Failed to parse commit: {}", e); + ApiError::InternalError(None) + })? + .data; - let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); + let mst = Mst::load(Arc::new(tracking_store.clone()), prev_data_cid, None); let ctx = RepoWriteContext { tracking_store, current_root_cid, - prev_data_cid: commit.data, + prev_commit_bytes: commit_bytes, + prev_data_cid, write_lock, }; Ok((ctx, mst)) } +pub async fn compute_obsolete_cids( + original_mst: &Mst, + new_mst: &Mst, + original_root_cid: CommitCid, +) -> Result, jacquard_repo::error::RepoError> { + let (old_nodes, new_nodes, old_leaves, new_leaves) = tokio::try_join!( + original_mst.collect_node_cids(), + new_mst.collect_node_cids(), + original_mst.leaves(), + new_mst.leaves(), + )?; + let old_nodes_set: BTreeSet = old_nodes.into_iter().collect(); + let new_nodes_set: BTreeSet = new_nodes.into_iter().collect(); + let old_leaf_set: BTreeSet = old_leaves.iter().map(|(_, cid)| *cid).collect(); + let new_leaf_set: BTreeSet = new_leaves.iter().map(|(_, cid)| *cid).collect(); + let removed_nodes = old_nodes_set.difference(&new_nodes_set).copied(); + let removed_leaves = old_leaf_set.difference(&new_leaf_set).copied(); + let obsolete: BTreeSet = std::iter::once(original_root_cid.into_cid()) + .chain(removed_nodes) + .chain(removed_leaves) + .collect(); + Ok(obsolete.into_iter().collect()) +} + pub async fn finalize_repo_write( state: &AppState, ctx: RepoWriteContext, @@ -226,18 +257,44 @@ pub async fn finalize_repo_write( params: FinalizeParams<'_>, ) -> Result { let new_mst_root = mst.persist().await.map_err(|e| { - error!("MST persist failed: {:?}", e); + error!("MST persist failed: {}", e); ApiError::InternalError(None) })?; - let written_cids: Vec = ctx - .tracking_store - .get_all_relevant_cids() - .into_iter() - .collect::>() - .into_iter() - .collect(); - let written_cids_str: Vec = written_cids.iter().map(ToString::to_string).collect(); + let block_bytes = ctx.tracking_store.take_written_blocks(); + + let storage_for_diff = Arc::new(ctx.tracking_store.clone()); + let original_settled = Mst::load(storage_for_diff.clone(), ctx.prev_data_cid, None); + let new_settled = Mst::load(storage_for_diff, new_mst_root, None); + let (obsolete_cids, new_tree_cids) = tokio::try_join!( + async { + compute_obsolete_cids( + &original_settled, + &new_settled, + CommitCid::from(ctx.current_root_cid), + ) + .await + .map_err(|e| { + error!("MST diff failed during finalize_repo_write: {}", e); + ApiError::InternalError(Some("MST diff failed".into())) + }) + }, + async { + let (nodes, leaves) = + tokio::try_join!(new_settled.collect_node_cids(), new_settled.leaves(),).map_err( + |e| { + error!("new tree walk failed: {}", e); + ApiError::InternalError(None) + }, + )?; + Ok::, ApiError>( + nodes + .into_iter() + .chain(leaves.iter().map(|(_, cid)| *cid)) + .collect(), + ) + }, + )?; let result = commit_and_log( state, @@ -245,12 +302,14 @@ pub async fn finalize_repo_write( did: params.did, user_id: params.user_id, current_root_cid: Some(ctx.current_root_cid), + prev_commit_bytes: Some(ctx.prev_commit_bytes), prev_data_cid: Some(ctx.prev_data_cid), new_mst_root, ops: params.ops, - blocks_cids: &written_cids_str, + block_bytes, + new_tree_cids, blobs: params.blob_cids, - obsolete_cids: vec![ctx.current_root_cid], + obsolete_cids, backlinks_to_add: params.backlinks_to_add, backlinks_to_remove: params.backlinks_to_remove, }, @@ -297,7 +356,7 @@ pub fn create_signed_commit( let sig_bytes = signed.sig().clone(); let signed_bytes = signed .to_cbor() - .map_err(|e| CommitError::SerializationFailed(format!("{:?}", e)))?; + .map_err(|e| CommitError::SerializationFailed(e.to_string()))?; Ok((signed_bytes, sig_bytes)) } @@ -305,18 +364,18 @@ pub enum RecordOp { Create { collection: Nsid, rkey: Rkey, - cid: Cid, + cid: RecordCid, }, Update { collection: Nsid, rkey: Rkey, - cid: Cid, - prev: Option, + cid: RecordCid, + prev: RecordCid, }, Delete { collection: Nsid, rkey: Rkey, - prev: Option, + prev: RecordCid, }, } @@ -329,10 +388,12 @@ pub struct CommitParams<'a> { pub did: &'a Did, pub user_id: Uuid, pub current_root_cid: Option, + pub prev_commit_bytes: Option, pub prev_data_cid: Option, pub new_mst_root: Cid, pub ops: Vec, - pub blocks_cids: &'a [String], + pub block_bytes: std::collections::HashMap, + pub new_tree_cids: Vec, pub blobs: &'a [String], pub obsolete_cids: Vec, pub backlinks_to_add: Vec, @@ -344,8 +405,8 @@ pub async fn commit_and_log( params: CommitParams<'_>, ) -> Result { use tranquil_db_traits::{ - ApplyCommitError, ApplyCommitInput, CommitEventData, RecordDelete, RecordUpsert, - RepoEventType, + ApplyCommitError, ApplyCommitInput, CommitEventData, EventBlockInline, RecordDelete, + RecordUpsert, RepoEventType, }; let backlinks_to_add = params.backlinks_to_add; @@ -354,14 +415,21 @@ pub async fn commit_and_log( did, user_id, current_root_cid, + prev_commit_bytes, prev_data_cid, new_mst_root, ops, - blocks_cids, + mut block_bytes, + new_tree_cids, blobs, obsolete_cids, .. } = params; + debug_assert_eq!( + current_root_cid.is_some(), + prev_commit_bytes.is_some(), + "current_root_cid and prev_commit_bytes must be both Some (non-genesis) or both None (genesis)" + ); let key_row = state .repos .user @@ -377,18 +445,27 @@ pub async fn commit_and_log( let rev_str = rev.to_string(); let (new_commit_bytes, _sig) = create_signed_commit(did, new_mst_root, &rev_str, current_root_cid, &signing_key)?; - let new_root_cid = state + let new_root_cid = + compute_cid(&new_commit_bytes).map_err(|e| CommitError::BlockStoreFailed(e.to_string()))?; + + let commit_bytes_owned = Bytes::from(new_commit_bytes.clone()); + state .block_store .put(&new_commit_bytes) .await - .map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))?; + .map_err(|e| CommitError::BlockStoreFailed(format!("failed to write commit block: {e}")))?; - let mut all_block_cids: Vec> = blocks_cids + block_bytes.insert(new_root_cid, commit_bytes_owned); + + if let (Some(prev_root), Some(prev_bytes)) = (current_root_cid, prev_commit_bytes) { + block_bytes.entry(prev_root).or_insert(prev_bytes); + } + + let all_block_cids: Vec> = new_tree_cids .iter() - .filter_map(|s| Cid::from_str(s).ok()) + .chain(std::iter::once(&new_root_cid)) .map(|c| c.to_bytes()) .collect(); - all_block_cids.push(new_root_cid.to_bytes()); let obsolete_bytes: Vec> = obsolete_cids.iter().map(|c| c.to_bytes()).collect(); @@ -410,7 +487,7 @@ pub async fn commit_and_log( upserts.push(RecordUpsert { collection: collection.clone(), rkey: rkey.clone(), - cid: crate::types::CidLink::from(cid), + cid: crate::types::CidLink::from(cid.as_cid()), }); } RecordOp::Delete { @@ -443,32 +520,30 @@ pub async fn commit_and_log( rkey, cid, prev, - } => { - let mut obj = json!({ - "action": "update", - "path": format!("{}/{}", collection, rkey), - "cid": cid.to_string() - }); - if let Some(prev_cid) = prev { - obj["prev"] = json!(prev_cid.to_string()); - } - obj - } + } => json!({ + "action": "update", + "path": format!("{}/{}", collection, rkey), + "cid": cid.to_string(), + "prev": prev.to_string(), + }), RecordOp::Delete { collection, rkey, prev, - } => { - let mut obj = json!({ - "action": "delete", - "path": format!("{}/{}", collection, rkey), - "cid": null - }); - if let Some(prev_cid) = prev { - obj["prev"] = json!(prev_cid.to_string()); - } - obj - } + } => json!({ + "action": "delete", + "path": format!("{}/{}", collection, rkey), + "cid": null, + "prev": prev.to_string(), + }), + }) + .collect(); + + let inline_blocks: Vec = block_bytes + .iter() + .map(|(cid, data)| EventBlockInline { + cid_bytes: cid.to_bytes(), + data: data.to_vec(), }) .collect(); @@ -479,7 +554,7 @@ pub async fn commit_and_log( prev_cid: current_root_cid.map(crate::types::CidLink::from), ops: Some(json!(ops_json)), blobs: Some(blobs.to_vec()), - blocks_cids: Some(blocks_cids.to_vec()), + blocks: Some(inline_blocks), prev_data_cid: prev_data_cid.map(crate::types::CidLink::from), rev: Some(rev_str.clone()), }; @@ -510,6 +585,31 @@ pub async fn commit_and_log( ApplyCommitError::Database(msg) => CommitError::DatabaseError(msg), })?; + let apply_result = (|| { + let bs = state.block_store.clone(); + let decrements = obsolete_cids.clone(); + async move { bs.decrement_refs(&decrements).await } + }) + .retry( + ExponentialBuilder::default() + .with_min_delay(std::time::Duration::from_millis(50)) + .with_max_delay(std::time::Duration::from_secs(2)) + .with_max_times(5), + ) + .await; + + if let Err(e) = apply_result { + let leaked: Vec = obsolete_cids.iter().map(Cid::to_string).collect(); + tracing::error!( + error = %e, + user_id = %user_id, + new_root = %new_root_cid, + leaked_cids = ?leaked, + "blockstore decrement_refs failed after metastore commit succeeded \ + and exhausted retries; blocks may leak refcounts" + ); + } + Ok(CommitResult { commit_cid: new_root_cid, rev: rev_str, @@ -530,98 +630,57 @@ pub async fn create_record_internal( .map_err(|e| CommitError::DatabaseError(e.to_string()))? .ok_or(CommitError::UserNotFound)?; - let _write_lock = state.repo_write_locks.lock(user_id).await; + let to_commit_err = |e: ApiError| CommitError::DatabaseError(format!("{:?}", e)); - let root_cid_link = state - .repos - .repo - .get_repo_root_cid_by_user_id(user_id) + let (ctx, mst) = begin_repo_write(state, user_id, None) .await - .map_err(|e| CommitError::DatabaseError(e.to_string()))? - .ok_or(CommitError::RepoNotFound)?; - let current_root_cid = Cid::from_str(root_cid_link.as_str()) - .map_err(|e| CommitError::InvalidCid(e.to_string()))?; - let tracking_store = TrackingBlockStore::new(state.block_store.clone()); - let commit_bytes = tracking_store - .get(¤t_root_cid) - .await - .map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))? - .ok_or(CommitError::BlockStoreFailed( - "Commit block not found".into(), - ))?; - let commit = jacquard_repo::commit::Commit::from_cbor(&commit_bytes) - .map_err(|e| CommitError::CommitParseFailed(format!("{:?}", e)))?; - let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); + .map_err(to_commit_err)?; + let record_ipld = crate::util::json_to_ipld(record); let mut record_bytes = Vec::new(); serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld) - .map_err(|e| CommitError::RecordSerializationFailed(format!("{:?}", e)))?; - let record_cid = tracking_store + .map_err(|e| CommitError::RecordSerializationFailed(e.to_string()))?; + let record_cid = ctx + .tracking_store .put(&record_bytes) .await - .map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))?; + .map_err(|e| CommitError::BlockStoreFailed(e.to_string()))?; + let key = format!("{}/{}", collection, rkey); let new_mst = mst .add(&key, record_cid) .await - .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; - let new_mst_root = new_mst - .persist() - .await - .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; + .map_err(|e| CommitError::MstOperationFailed(e.to_string()))?; + let op = RecordOp::Create { collection: collection.clone(), rkey: rkey.clone(), - cid: record_cid, + cid: RecordCid::from(record_cid), }; - let mut new_mst_blocks = std::collections::BTreeMap::new(); - let mut old_mst_blocks = std::collections::BTreeMap::new(); - new_mst - .blocks_for_path(&key, &mut new_mst_blocks) - .await - .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; - mst.blocks_for_path(&key, &mut old_mst_blocks) - .await - .map_err(|e| CommitError::MstOperationFailed(format!("{:?}", e)))?; - let obsolete_cids: Vec = std::iter::once(current_root_cid) - .chain( - old_mst_blocks - .keys() - .filter(|cid| !new_mst_blocks.contains_key(*cid)) - .copied(), - ) - .collect(); - let mut relevant_blocks = new_mst_blocks; - relevant_blocks.extend(old_mst_blocks); - relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes)); - let written_cids: Vec = tracking_store - .get_all_relevant_cids() - .into_iter() - .chain(relevant_blocks.keys().copied()) - .collect::>() - .into_iter() - .collect(); - let written_cids_str: Vec = written_cids.iter().map(|c| c.to_string()).collect(); + let modified_keys = [key]; let blob_cids = extract_blob_cids(record); let record_uri = AtUri::from_parts(did.as_str(), collection.as_str(), rkey.as_str()); let backlinks = extract_backlinks(&record_uri, record); - let result = commit_and_log( + + let result = finalize_repo_write( state, - CommitParams { + ctx, + new_mst, + FinalizeParams { did, user_id, - current_root_cid: Some(current_root_cid), - prev_data_cid: Some(commit.data), - new_mst_root, + controller_did: None, + delegation_detail: None, ops: vec![op], - blocks_cids: &written_cids_str, - blobs: &blob_cids, - obsolete_cids, + modified_keys: &modified_keys, + blob_cids: &blob_cids, backlinks_to_add: backlinks, backlinks_to_remove: vec![], }, ) - .await?; + .await + .map_err(to_commit_err)?; + let uri = format!("at://{}/{}/{}", did, collection, rkey); Ok((uri, result.commit_cid)) } @@ -659,10 +718,20 @@ pub async fn sequence_sync_event( let cid_link: crate::types::CidLink = commit_cid .parse() .map_err(|_| CommitError::InvalidCid(commit_cid.to_string()))?; + let commit_cid_parsed = + Cid::from_str(commit_cid).map_err(|e| CommitError::InvalidCid(e.to_string()))?; + let commit_bytes = state + .block_store + .get(&commit_cid_parsed) + .await + .map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))? + .ok_or(CommitError::BlockStoreFailed( + "Commit block not found for sync event".into(), + ))?; state .repos .repo - .insert_sync_event(did, &cid_link, rev) + .insert_sync_event(did, &cid_link, rev, &commit_bytes) .await .map_err(|e| CommitError::DatabaseError(format!("sync event: {}", e))) } @@ -676,10 +745,33 @@ pub async fn sequence_genesis_commit( ) -> Result { let commit_cid_link = crate::types::CidLink::from(commit_cid); let mst_root_cid_link = crate::types::CidLink::from(mst_root_cid); + let commit_bytes = state + .block_store + .get(commit_cid) + .await + .map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))? + .ok_or(CommitError::BlockStoreFailed( + "Genesis commit block not found".into(), + ))?; + let mst_root_bytes = state + .block_store + .get(mst_root_cid) + .await + .map_err(|e| CommitError::BlockStoreFailed(format!("{:?}", e)))? + .ok_or(CommitError::BlockStoreFailed( + "Genesis MST root block not found".into(), + ))?; state .repos .repo - .insert_genesis_commit_event(did, &commit_cid_link, &mst_root_cid_link, rev) + .insert_genesis_commit_event( + did, + &commit_cid_link, + &mst_root_cid_link, + rev, + &commit_bytes, + &mst_root_bytes, + ) .await .map_err(|e| CommitError::DatabaseError(format!("genesis commit event: {}", e))) } diff --git a/crates/tranquil-pds/src/scheduled.rs b/crates/tranquil-pds/src/scheduled.rs index 209d069..f4d6aa5 100644 --- a/crates/tranquil-pds/src/scheduled.rs +++ b/crates/tranquil-pds/src/scheduled.rs @@ -9,117 +9,15 @@ use std::time::Duration; use tokio::time::interval; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; -use tranquil_db_traits::{ - BlobRepository, BrokenGenesisCommit, RepoRepository, SequenceNumber, SsoRepository, - UserRepository, -}; +use tranquil_db_traits::{BlobRepository, RepoRepository, SsoRepository, UserRepository}; +use tranquil_store::blockstore::CidBytes; +use tranquil_store::bloom::BloomFilter; use tranquil_types::{AtUri, CidLink, Did}; use crate::repo::AnyBlockStore; use crate::storage::BlobStorage; use crate::sync::car::encode_car_header; -#[derive(Debug)] -enum GenesisBackfillError { - MissingCommitCid, - InvalidCid, - BlockFetchFailed, - BlockNotFound, - CommitParseFailed, - UpdateFailed, -} - -impl std::fmt::Display for GenesisBackfillError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::MissingCommitCid => f.write_str("missing commit_cid"), - Self::InvalidCid => f.write_str("invalid CID"), - Self::BlockFetchFailed => f.write_str("failed to fetch block"), - Self::BlockNotFound => f.write_str("block not found"), - Self::CommitParseFailed => f.write_str("failed to parse commit"), - Self::UpdateFailed => f.write_str("failed to update"), - } - } -} - -async fn process_genesis_commit( - repo_repo: &dyn RepoRepository, - block_store: &AnyBlockStore, - row: BrokenGenesisCommit, -) -> Result<(Did, SequenceNumber), (SequenceNumber, GenesisBackfillError)> { - let commit_cid_str = row - .commit_cid - .ok_or((row.seq, GenesisBackfillError::MissingCommitCid))?; - let commit_cid = - Cid::from_str(&commit_cid_str).map_err(|_| (row.seq, GenesisBackfillError::InvalidCid))?; - let block = block_store - .get(&commit_cid) - .await - .map_err(|_| (row.seq, GenesisBackfillError::BlockFetchFailed))? - .ok_or((row.seq, GenesisBackfillError::BlockNotFound))?; - let commit = Commit::from_cbor(&block) - .map_err(|_| (row.seq, GenesisBackfillError::CommitParseFailed))?; - let blocks_cids = vec![commit.data.to_string(), commit_cid.to_string()]; - repo_repo - .update_seq_blocks_cids(row.seq, &blocks_cids) - .await - .map_err(|_| (row.seq, GenesisBackfillError::UpdateFailed))?; - Ok((row.did, row.seq)) -} - -pub async fn backfill_genesis_commit_blocks( - repo_repo: Arc, - block_store: AnyBlockStore, -) { - let broken_genesis_commits = match repo_repo.get_broken_genesis_commits().await { - Ok(rows) => rows, - Err(e) => { - error!( - "Failed to query repo_seq for genesis commit backfill: {:?}", - e - ); - return; - } - }; - - if broken_genesis_commits.is_empty() { - debug!("No genesis commits need blocks_cids backfill"); - return; - } - - info!( - count = broken_genesis_commits.len(), - "Backfilling blocks_cids for genesis commits" - ); - - let results = futures::future::join_all(broken_genesis_commits.into_iter().map(|row| { - let repo_repo = repo_repo.clone(); - let block_store = block_store.clone(); - async move { process_genesis_commit(repo_repo.as_ref(), &block_store, row).await } - })) - .await; - - let (success, failed) = results.iter().fold((0, 0), |(s, f), r| match r { - Ok((did, seq)) => { - info!(seq = seq.as_i64(), did = %did, "Fixed genesis commit blocks_cids"); - (s + 1, f) - } - Err((seq, reason)) => { - warn!( - seq = seq.as_i64(), - reason = %reason, - "Failed to process genesis commit" - ); - (s, f + 1) - } - }); - - info!( - success, - failed, "Completed genesis commit blocks_cids backfill" - ); -} - async fn process_repo_rev( repo_repo: &dyn RepoRepository, block_store: &AnyBlockStore, @@ -422,6 +320,7 @@ pub async fn backfill_record_blobs(repo_repo: Arc, block_sto info!(success, failed, "Completed record_blobs backfill"); } +#[allow(clippy::too_many_arguments)] pub async fn start_scheduled_tasks( user_repo: Arc, blob_repo: Arc, @@ -429,23 +328,91 @@ pub async fn start_scheduled_tasks( sso_repo: Arc, repo_repo: Arc, block_store: AnyBlockStore, + eventlog_segments_dir: Option, shutdown: CancellationToken, ) { let cfg = tranquil_config::get(); let check_interval = Duration::from_secs(cfg.scheduled.delete_check_interval_secs); - let gc_interval = Duration::from_secs(cfg.scheduled.block_gc_interval_secs); + let compaction_enabled = cfg.scheduled.compaction_interval_secs > 0; + let reachability_enabled = cfg.scheduled.reachability_walk_interval_secs > 0; + let archival_enabled_secs = cfg.scheduled.archival_interval_secs > 0; + let event_retention_enabled = cfg.scheduled.event_retention_interval_secs > 0; + let compaction_interval = Duration::from_secs(cfg.scheduled.compaction_interval_secs.max(60)); + let reachability_interval = + Duration::from_secs(cfg.scheduled.reachability_walk_interval_secs.max(60)); + let archival_interval = Duration::from_secs(cfg.scheduled.archival_interval_secs.max(60)); + let event_retention_interval = + Duration::from_secs(cfg.scheduled.event_retention_interval_secs.max(60)); + let event_retention_max_age = Duration::from_secs(cfg.scheduled.event_retention_max_age_secs); + + let archiver: Option> = + match (&eventlog_segments_dir, &cfg.scheduled.archival_dest_dir) { + (Some(segments_dir), Some(dest_dir)) if archival_enabled_secs => { + let sidecar_path = segments_dir + .parent() + .unwrap_or(segments_dir) + .join("archival.state"); + match tranquil_store::archival::LocalArchivalDestination::new( + std::path::PathBuf::from(dest_dir), + ) { + Ok(dest) => { + info!( + dest_dir = dest_dir, + interval_secs = archival_interval.as_secs(), + "continuous archival enabled" + ); + Some(Arc::new(tranquil_store::archival::ContinuousArchiver::new( + segments_dir.clone(), + sidecar_path, + Box::new(dest), + ))) + } + Err(e) => { + error!( + dest_dir = dest_dir, + error = %e, + "failed to initialize archival destination, archival disabled" + ); + None + } + } + } + _ => None, + }; info!( check_interval_secs = check_interval.as_secs(), - gc_interval_secs = gc_interval.as_secs(), + compaction_enabled, + compaction_interval_secs = cfg.scheduled.compaction_interval_secs, + reachability_enabled, + reachability_interval_secs = cfg.scheduled.reachability_walk_interval_secs, + archival_enabled = archiver.is_some(), + event_retention_enabled, + event_retention_interval_secs = cfg.scheduled.event_retention_interval_secs, + event_retention_max_age_secs = cfg.scheduled.event_retention_max_age_secs, "Starting scheduled tasks service" ); let mut ticker = interval(check_interval); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - let mut gc_ticker = interval(gc_interval); - gc_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut compaction_ticker = interval(compaction_interval); + compaction_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + let mut reachability_ticker = interval(reachability_interval); + reachability_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + let mut archival_ticker = interval(archival_interval); + archival_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + let mut event_retention_ticker = match event_retention_enabled { + true => { + let mut t = interval(event_retention_interval); + t.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + Some(t) + } + false => None, + }; loop { tokio::select! { @@ -492,63 +459,139 @@ pub async fn start_scheduled_tasks( } } } - _ = gc_ticker.tick() => { - if let Some(pg) = block_store.as_postgres() - && let Err(e) = run_block_gc(repo_repo.as_ref(), pg).await - { - error!("Block GC error: {e}"); + _ = compaction_ticker.tick(), if compaction_enabled => { + if let Some(store) = block_store.as_tranquil_store() { + let store = store.clone(); + let threshold = cfg.scheduled.compaction_liveness_threshold; + let grace_ms = cfg.scheduled.compaction_grace_period_ms; + if let Err(e) = tokio::task::spawn_blocking(move || { + run_compaction_pass(&store, threshold, grace_ms) + }).await.unwrap_or_else(|e| Err(anyhow::anyhow!("compaction task panicked: {e}"))) { + error!("Compaction error: {e}"); + } + } + } + _ = reachability_ticker.tick(), if reachability_enabled => { + if let Some(store) = block_store.as_tranquil_store() { + let store = store.clone(); + let repo_repo = repo_repo.clone(); + match tokio::task::spawn_blocking(move || { + run_reachability_walk(&store, repo_repo.as_ref()) + }).await { + Ok(Ok(result)) => { + info!( + repos_walked = result.repos_walked, + blocks_visited = result.blocks_visited, + live_refcounted = result.live_refcounted, + leaked_blocks = result.leaked_blocks, + repaired_blocks = result.repaired_blocks, + bloom_heap_mb = result.bloom_heap_bytes / (1024 * 1024), + "reachability walk complete" + ); + } + Ok(Err(e)) => error!("Reachability walk error: {e}"), + Err(e) => error!("Reachability walk panicked: {e}"), + } + } + } + _ = archival_ticker.tick(), if archival_enabled_secs => { + if let Some(ref archiver) = archiver { + let archiver = Arc::clone(archiver); + match tokio::task::spawn_blocking(move || { + archiver.run_pass() + }).await { + Ok(Ok(result)) if result.segments_archived > 0 => { + info!( + segments_archived = result.segments_archived, + bytes_archived = result.bytes_archived, + "archival pass complete" + ); + } + Ok(Ok(_)) => {} + Ok(Err(e)) => error!("Archival pass error: {e}"), + Err(e) => error!("Archival task panicked: {e}"), + } + } + } + _ = async { + match event_retention_ticker.as_mut() { + Some(t) => { t.tick().await; } + None => std::future::pending::<()>().await, + } + }, if event_retention_enabled => { + let cutoff = chrono::Utc::now() + - chrono::Duration::from_std(event_retention_max_age) + .expect("event_retention_max_age fits chrono::Duration: validated at config load"); + match repo_repo.prune_events_older_than(cutoff).await { + Ok(count) if count.is_zero() => { + debug!("event retention: nothing past cutoff"); + } + Ok(count) => { + info!(deleted = count.count(), unit = count.unit(), "event retention prune complete"); + } + Err(e) => error!(error = %e, "event retention error"), } } } } } -const BLOCK_GC_BATCH_SIZE: i64 = 1000; - -async fn run_block_gc( - repo_repo: &dyn RepoRepository, - block_store: &crate::repo::PostgresBlockStore, +fn run_compaction_pass( + store: &tranquil_store::blockstore::TranquilBlockStore, + liveness_threshold: f64, + grace_period_ms: u64, ) -> anyhow::Result<()> { - let mut total_deleted: u64 = 0; + match store.cleanup_gc_meta() { + Ok(0) => {} + Ok(n) => info!(count = n, "cleaned up stale gc_meta entries"), + Err(e) => warn!(error = %e, "gc_meta cleanup failed, continuing"), + } - loop { - let candidates = block_store - .get_oldest_block_cids(BLOCK_GC_BATCH_SIZE) - .await - .context("failed to fetch candidate blocks")?; + let liveness_map = store + .compaction_liveness(grace_period_ms) + .context("failed to compute liveness")?; - match candidates.is_empty() { - true => break, - false => { - let batch_len = candidates.len(); - let unreferenced = repo_repo - .find_unreferenced_blocks(&candidates) - .await - .context("failed to check block references")?; + let candidate = liveness_map + .iter() + .filter(|(_, info)| info.total_blocks > 0 && info.ratio() < liveness_threshold) + .min_by(|(_, a), (_, b)| { + a.ratio() + .partial_cmp(&b.ratio()) + .unwrap_or(std::cmp::Ordering::Equal) + }); - let deleted = match unreferenced.is_empty() { - true => 0, - false => block_store - .delete_blocks(&unreferenced) - .await - .context("failed to delete unreferenced blocks")?, - }; - - total_deleted = total_deleted.saturating_add(deleted); - - match unreferenced.len() == batch_len { - true => continue, - false => break, + match candidate { + None => { + debug!("Compaction: no files below liveness threshold"); + Ok(()) + } + Some((&file_id, info)) => { + info!( + file_id = %file_id, + liveness = format!("{:.1}%", info.ratio() * 100.0), + live_blocks = info.live_blocks, + total_blocks = info.total_blocks, + "compacting data file" + ); + match store.compact_file(file_id, grace_period_ms) { + Ok(result) => { + info!( + file_id = %result.file_id, + reclaimed_bytes = result.reclaimed_bytes, + live_blocks = result.live_blocks, + dead_blocks = result.dead_blocks, + "compaction complete" + ); + Ok(()) } + Err(tranquil_store::blockstore::CompactionError::ActiveFileCannotBeCompacted) => { + debug!(file_id = %file_id, "skipped active file"); + Ok(()) + } + Err(e) => Err(anyhow::anyhow!("compaction failed: {e}")), } } } - - match total_deleted > 0 { - true => info!(total_deleted, "Block GC cycle complete"), - false => debug!("Block GC cycle: no orphaned blocks found"), - } - Ok(()) } async fn process_scheduled_deletions( @@ -694,3 +737,232 @@ pub async fn generate_repo_car_from_user_blocks( generate_repo_car(block_store, &actual_head_cid).await } + +pub struct ReachabilityResult { + pub repos_walked: u64, + pub blocks_visited: u64, + pub live_refcounted: u64, + pub leaked_blocks: u64, + pub repaired_blocks: u64, + pub bloom_heap_bytes: usize, +} + +const REPO_PAGE_SIZE: i64 = 500; +const BLOOM_FALSE_POSITIVE_RATE: f64 = 0.01; + +fn cid_to_bytes(cid: &Cid) -> anyhow::Result { + cid.to_bytes() + .try_into() + .map_err(|_| anyhow::anyhow!("CID byte length mismatch for {cid}")) +} + +fn walk_repo_dag_sync( + store: &tranquil_store::blockstore::TranquilBlockStore, + head_cid: &Cid, + reachable: &mut std::collections::HashSet, +) -> anyhow::Result<()> { + let mut to_visit = vec![cid_to_bytes(head_cid)?]; + + while let Some(cid_bytes) = to_visit.pop() { + if !reachable.insert(cid_bytes) { + continue; + } + + let block = match store.get_block_sync(&cid_bytes)? { + Some(b) => b, + None => { + tracing::warn!( + ?cid_bytes, + "referenced block missing during reachability walk" + ); + continue; + } + }; + + if let Ok(commit) = Commit::from_cbor(&block) { + to_visit.push(cid_to_bytes(&commit.data)?); + if let Some(prev) = &commit.prev { + to_visit.push(cid_to_bytes(prev)?); + } + } else if let Ok(Ipld::Map(ref obj)) = serde_ipld_dagcbor::from_slice::(&block) { + if let Some(Ipld::Link(left_cid)) = obj.get("l") + && let Ok(bytes) = ::try_from(left_cid.to_bytes().as_slice()) + { + to_visit.push(bytes); + } + if let Some(Ipld::List(entries)) = obj.get("e") { + entries + .iter() + .filter_map(|entry| match entry { + Ipld::Map(entry_obj) => Some(entry_obj), + _ => None, + }) + .flat_map(|entry_obj| { + [entry_obj.get("t"), entry_obj.get("v")] + .into_iter() + .flatten() + .filter_map(|v| match v { + Ipld::Link(link_cid) => { + ::try_from(link_cid.to_bytes().as_slice()).ok() + } + _ => None, + }) + }) + .for_each(|bytes| to_visit.push(bytes)); + } + } + } + + Ok(()) +} + +fn paginate_repos( + rt: &tokio::runtime::Handle, + repo_repo: &dyn RepoRepository, + mut each_page: impl FnMut(&[tranquil_db_traits::RepoListItem]) -> anyhow::Result<()>, +) -> anyhow::Result<()> { + let mut cursor_did: Option = None; + + std::iter::from_fn(|| { + let page = rt + .block_on(repo_repo.list_repos_paginated(cursor_did.as_ref(), REPO_PAGE_SIZE)) + .context("failed to list repos"); + match &page { + Ok(p) => { + cursor_did = p.last().map(|r| r.did.clone()); + cursor_did.as_ref().map(|_| page) + } + Err(_) => Some(page), + } + }) + .try_for_each(|page| each_page(&page?)) +} + +pub fn run_reachability_walk( + store: &tranquil_store::blockstore::TranquilBlockStore, + repo_repo: &dyn RepoRepository, +) -> anyhow::Result { + let rt = tokio::runtime::Handle::current(); + + let approx_blocks = store.approximate_block_count(); + + const MAX_PREALLOC: usize = 64_000_000; + let mut visited = std::collections::HashSet::with_capacity( + usize::try_from(approx_blocks) + .unwrap_or(0) + .min(MAX_PREALLOC), + ); + + info!(approx_blocks, "reachability walk starting"); + + let mut repos_walked: u64 = 0; + let mut seen_heads: std::collections::HashMap = std::collections::HashMap::new(); + + paginate_repos(&rt, repo_repo, |page| { + page.iter().try_for_each(|repo| -> anyhow::Result<()> { + let cid = + Cid::from_str(repo.repo_root_cid.as_str()).context("invalid repo_root_cid")?; + seen_heads.insert(repo.did.clone(), repo.repo_root_cid.clone()); + walk_repo_dag_sync(store, &cid, &mut visited)?; + repos_walked = repos_walked.saturating_add(1); + if repos_walked.is_multiple_of(1000) { + info!( + repos_walked, + blocks_so_far = visited.len(), + "reachability walk progress" + ); + } + Ok(()) + }) + })?; + + let blocks_visited = u64::try_from(visited.len()).unwrap_or(u64::MAX); + + let mut reachable = + BloomFilter::with_capacity_and_fpr(blocks_visited.max(1024), BLOOM_FALSE_POSITIVE_RATE); + visited.iter().for_each(|cid| reachable.insert(cid)); + drop(visited); + + let mut stale_repos: u64 = 0; + paginate_repos(&rt, repo_repo, |page| { + let stale: Vec<_> = page + .iter() + .filter(|repo| seen_heads.get(&repo.did) != Some(&repo.repo_root_cid)) + .collect(); + stale.iter().try_for_each(|repo| -> anyhow::Result<()> { + let cid = + Cid::from_str(repo.repo_root_cid.as_str()).context("invalid repo_root_cid")?; + let mut extra = std::collections::HashSet::new(); + walk_repo_dag_sync(store, &cid, &mut extra)?; + extra.iter().for_each(|c| reachable.insert(c)); + seen_heads.insert(repo.did.clone(), repo.repo_root_cid.clone()); + stale_repos = stale_repos.saturating_add(1); + Ok(()) + }) + })?; + + info!( + repos_walked, + blocks_visited, + stale_repos, + bloom_heap_mb = reachable.heap_bytes() / (1024 * 1024), + "DAG traversal complete, quiescing blockstore for leak scan" + ); + + let (_snapshot, quiesce_guard) = store + .quiesce() + .map_err(|e| anyhow::anyhow!("failed to quiesce blockstore: {e}"))?; + + let mut quiesced_stale: u64 = 0; + paginate_repos(&rt, repo_repo, |page| { + page.iter() + .filter(|repo| seen_heads.get(&repo.did) != Some(&repo.repo_root_cid)) + .try_for_each(|repo| -> anyhow::Result<()> { + let cid = + Cid::from_str(repo.repo_root_cid.as_str()).context("invalid repo_root_cid")?; + let mut extra = std::collections::HashSet::new(); + walk_repo_dag_sync(store, &cid, &mut extra)?; + extra.iter().for_each(|c| reachable.insert(c)); + quiesced_stale = quiesced_stale.saturating_add(1); + Ok(()) + }) + })?; + + if quiesced_stale > 0 { + info!( + quiesced_stale, + "caught additional stale repos during quiesced re-walk" + ); + } + + let (leaked, live_refcounted) = store + .find_leaked_refcounts(|cid| reachable.contains(cid)) + .map_err(|e| anyhow::anyhow!("failed to scan index: {e}"))?; + let leaked_blocks = u64::try_from(leaked.len()).unwrap_or(u64::MAX); + let bloom_heap_bytes = reachable.heap_bytes(); + drop(reachable); + + quiesce_guard.resume(); + + let repaired_blocks = match leaked.is_empty() { + true => 0, + false => { + warn!( + leaked_blocks, + "reachability walk found leaked refcounts, repairing" + ); + store + .repair_leaked_refcounts(&leaked) + .map_err(|e| anyhow::anyhow!("failed to repair leaked refcounts: {e}"))? + } + }; + + Ok(ReachabilityResult { + repos_walked, + blocks_visited, + live_refcounted, + leaked_blocks, + repaired_blocks, + bloom_heap_bytes, + }) +} diff --git a/crates/tranquil-pds/src/state.rs b/crates/tranquil-pds/src/state.rs index 6a39e62..bd2bc40 100644 --- a/crates/tranquil-pds/src/state.rs +++ b/crates/tranquil-pds/src/state.rs @@ -49,6 +49,7 @@ pub struct AppState { pub bootstrap_invite_code: Option, pub signal_sender: Option>, pub signal_store_provider: Option>, + pub eventlog_segments_dir: Option, } #[derive(Debug, Clone, Copy)] @@ -214,9 +215,7 @@ impl AppState { match cfg.storage.repo_backend() { tranquil_config::RepoBackend::TranquilStore => { - tracing::info!( - "tranquil-store repo backend active. EXPERIMENTAL! No garbage collection, no backup/restore" - ); + tracing::info!("tranquil-store repo backend active. EXPERIMENTAL!"); Ok(Self::from_store(shutdown).await) } tranquil_config::RepoBackend::Postgres => { @@ -273,10 +272,11 @@ impl AppState { pub async fn from_db(db: PgPool, shutdown: CancellationToken) -> Self { let cfg = tranquil_config::get(); - let (repos, block_store, signal_store_provider): ( + let (repos, block_store, signal_store_provider, eventlog_segments_dir): ( PostgresRepositories, crate::repo::AnyBlockStore, Option>, + Option, ) = match cfg.storage.repo_backend() == tranquil_config::RepoBackend::TranquilStore { true => { let wiring = wire_tranquil_store(&cfg.tranquil_store, shutdown.clone()); @@ -284,6 +284,7 @@ impl AppState { wiring.repos, crate::repo::AnyBlockStore::TranquilStore(wiring.blockstore), Some(wiring.signal_provider), + Some(wiring.segments_dir), ) } false => { @@ -294,11 +295,19 @@ impl AppState { repos, crate::repo::AnyBlockStore::Postgres(PostgresBlockStore::new(db)), Some(provider), + None, ) } }; - Self::build(repos, block_store, signal_store_provider, shutdown).await + Self::build( + repos, + block_store, + signal_store_provider, + eventlog_segments_dir, + shutdown, + ) + .await } pub async fn from_store(shutdown: CancellationToken) -> Self { @@ -309,6 +318,30 @@ impl AppState { wiring.repos, crate::repo::AnyBlockStore::TranquilStore(wiring.blockstore), Some(wiring.signal_provider), + Some(wiring.segments_dir), + shutdown, + ) + .await + } + + pub async fn from_store_at(data_dir: &std::path::Path, shutdown: CancellationToken) -> Self { + let base = &tranquil_config::get().tranquil_store; + let store_cfg = tranquil_config::TranquilStoreConfig { + data_dir: data_dir.to_string_lossy().into_owned(), + memory_budget_mb: base.memory_budget_mb, + handler_threads: base.handler_threads, + eventlog_pending_bytes_budget: base.eventlog_pending_bytes_budget, + eventlog_max_event_payload: base.eventlog_max_event_payload, + max_blockstore_file_size: base.max_blockstore_file_size, + max_eventlog_segment_size: base.max_eventlog_segment_size, + }; + let wiring = wire_tranquil_store(&store_cfg, shutdown.clone()); + + Self::build( + wiring.repos, + crate::repo::AnyBlockStore::TranquilStore(wiring.blockstore), + Some(wiring.signal_provider), + Some(wiring.segments_dir), shutdown, ) .await @@ -318,6 +351,7 @@ impl AppState { repos: PostgresRepositories, block_store: crate::repo::AnyBlockStore, signal_store_provider: Option>, + eventlog_segments_dir: Option, shutdown: CancellationToken, ) -> Self { AuthConfig::init(); @@ -359,6 +393,7 @@ impl AppState { bootstrap_invite_code: None, signal_sender: None, signal_store_provider, + eventlog_segments_dir, } } @@ -442,6 +477,7 @@ struct TranquilStoreWiring { blockstore: tranquil_store::blockstore::TranquilBlockStore, signal_provider: Arc, repos: PostgresRepositories, + segments_dir: PathBuf, } fn wire_tranquil_store( @@ -486,14 +522,18 @@ fn wire_tranquil_store( let blockstore = TranquilBlockStore::open(BlockStoreConfig { data_dir: blockstore_data_dir, index_dir: blockstore_index_dir, - max_file_size: tranquil_store::blockstore::DEFAULT_MAX_FILE_SIZE, + max_file_size: store_cfg.max_blockstore_file_size, group_commit: Default::default(), + shard_count: tranquil_store::blockstore::DEFAULT_SHARD_COUNT, }) .expect("failed to open blockstore"); let event_log = EventLog::open( EventLogConfig { segments_dir, + pending_bytes_budget: store_cfg.eventlog_pending_bytes_budget, + max_event_payload: store_cfg.eventlog_max_event_payload, + max_segment_size: store_cfg.max_eventlog_segment_size, ..EventLogConfig::default() }, RealIO::new(), @@ -503,6 +543,10 @@ fn wire_tranquil_store( let bridge = Arc::new(EventLogBridge::new(Arc::clone(&event_log))); + let was_clean = tranquil_store::consistency::had_clean_shutdown(&data_dir); + tranquil_store::consistency::remove_clean_shutdown_marker(&data_dir) + .expect("failed to remove clean shutdown marker"); + let indexes = metastore.partition(Partition::Indexes).clone(); let event_ops = metastore.event_ops(Arc::clone(&bridge)); let recovered = event_ops @@ -512,6 +556,41 @@ fn wire_tranquil_store( tracing::info!(recovered, "replayed metastore mutations from eventlog"); } + let skip_check = std::env::var("TRANQUIL_SKIP_CONSISTENCY_CHECK").is_ok_and(|v| v == "1"); + if (!was_clean || recovered > 0) && !skip_check { + let report = tranquil_store::consistency::verify_store_consistency( + &blockstore, + &metastore, + &event_log, + ); + report.log_findings(); + + if report.has_repairable_issues() { + let repair = tranquil_store::consistency::repair_known_issues(&blockstore, &report); + if repair.orphan_files_removed > 0 { + tracing::info!( + removed = repair.orphan_files_removed, + "repaired orphan data files" + ); + } + if repair.had_errors() { + tracing::warn!(errors = repair.repair_errors, "some repairs failed"); + } + } + + if report.has_unrecoverable_issues() { + panic!( + "unrecoverable store inconsistencies detected: {} dangling root CIDs, {} dangling record CIDs, \ + {} deserialization failures, cursor_ahead={}. \ + manual intervention required. set TRANQUIL_SKIP_CONSISTENCY_CHECK=1 to bypass.", + report.dangling_root_cids.len(), + report.dangling_record_cids.len(), + report.deserialization_failures, + report.cursor_ahead_of_eventlog, + ); + } + } + let notifier = bridge.notifier(); let signal_db = metastore.database().clone(); let signal_ks = metastore.signal_keyspace(); @@ -525,13 +604,23 @@ fn wire_tranquil_store( tokio::spawn({ let pool = Arc::clone(&pool); + let shutdown_event_log = Arc::clone(&event_log); + let shutdown_data_dir = data_dir.clone(); async move { shutdown.cancelled().await; pool.close().await; + if let Err(e) = shutdown_event_log.shutdown() { + tracing::warn!(error = %e, "eventlog shutdown failed"); + } + if let Err(e) = + tranquil_store::consistency::write_clean_shutdown_marker(&shutdown_data_dir) + { + tracing::warn!(error = %e, "failed to write clean shutdown marker"); + } } }); - let client = MetastoreClient::::new(pool); + let client = MetastoreClient::::new(pool, Arc::clone(&event_log)); tracing::info!(data_dir = %store_cfg.data_dir, "tranquil-store data directory"); @@ -553,9 +642,12 @@ fn wire_tranquil_store( tranquil_signal::fjall_store::FjallSignalStoreProvider::new(signal_db, signal_ks), ); + let eventlog_segments_dir = event_log.segments_dir().to_path_buf(); + TranquilStoreWiring { blockstore, signal_provider, repos, + segments_dir: eventlog_segments_dir, } } diff --git a/crates/tranquil-pds/src/sync/frame.rs b/crates/tranquil-pds/src/sync/frame.rs index 879b6c3..0b359e6 100644 --- a/crates/tranquil-pds/src/sync/frame.rs +++ b/crates/tranquil-pds/src/sync/frame.rs @@ -142,7 +142,6 @@ pub struct CommitFrameBuilder { seq: i64, did: Did, commit_cid: Cid, - prev_cid: Option, ops_json: serde_json::Value, blob_cids: Vec, time: chrono::DateTime, @@ -150,12 +149,10 @@ pub struct CommitFrameBuilder { } impl CommitFrameBuilder { - #[allow(clippy::too_many_arguments)] pub fn new( seq: i64, did: Did, commit_cid_str: &str, - prev_cid_str: Option<&str>, ops_json: serde_json::Value, blob_strs: Vec, time: chrono::DateTime, @@ -163,9 +160,6 @@ impl CommitFrameBuilder { ) -> Result { let commit_cid = Cid::from_str(commit_cid_str) .map_err(|_| CommitFrameError::InvalidCommitCid(commit_cid_str.to_string()))?; - let prev_cid = prev_cid_str.map(Cid::from_str).transpose().map_err(|_| { - CommitFrameError::InvalidCommitCid(prev_cid_str.unwrap_or("").to_string()) - })?; let blob_cids: Vec = blob_strs .iter() .filter_map(|s| Cid::from_str(s).ok()) @@ -174,7 +168,6 @@ impl CommitFrameBuilder { seq, did, commit_cid, - prev_cid, ops_json, blob_cids, time, @@ -197,7 +190,7 @@ impl CommitFrameBuilder { }) .collect(); let rev = self.rev.unwrap_or_else(placeholder_rev); - let since = self.prev_cid.as_ref().map(|_| rev.clone()); + let since = None; CommitFrame { seq: self.seq, rebase: false, @@ -235,7 +228,6 @@ impl TryFrom for CommitFrame { event.seq.as_i64(), event.did.clone(), commit_cid.as_str(), - event.prev_cid.as_ref().map(|c| c.as_str()), event.ops.unwrap_or_default(), event.blobs.unwrap_or_default(), event.created_at, diff --git a/crates/tranquil-pds/src/sync/import.rs b/crates/tranquil-pds/src/sync/import.rs index 77a3f49..070f3bc 100644 --- a/crates/tranquil-pds/src/sync/import.rs +++ b/crates/tranquil-pds/src/sync/import.rs @@ -8,7 +8,7 @@ use std::io::Cursor; use std::sync::Arc; use thiserror::Error; use tracing::debug; -use tranquil_db::{ImportBlock, ImportRecord, ImportRepoError, RepoRepository}; +use tranquil_db_traits::{ImportBlock, ImportRecord, ImportRepoError, RepoRepository}; use tranquil_types::CidLink; use uuid::Uuid; diff --git a/crates/tranquil-pds/src/sync/util.rs b/crates/tranquil-pds/src/sync/util.rs index ae32764..822e835 100644 --- a/crates/tranquil-pds/src/sync/util.rs +++ b/crates/tranquil-pds/src/sync/util.rs @@ -15,7 +15,7 @@ use std::collections::{BTreeMap, HashMap}; use std::io::Cursor; use std::str::FromStr; use tokio::io::AsyncWriteExt; -use tranquil_db_traits::{AccountStatus, RepoEventType, RepoRepository}; +use tranquil_db_traits::{AccountStatus, EventBlocks, RepoEventType, RepoRepository}; use tranquil_types::Did; #[derive(Debug)] @@ -25,7 +25,8 @@ pub enum SyncFrameError { IoFlush(std::io::Error), CborSerialize(String), MissingCommitCid, - CommitBlockNotFound, + MissingInlineCommitBlock, + MissingLegacyBlocks(Vec), RevExtraction, InvalidEvent(String), BlockStore(tranquil_db_traits::DbError), @@ -40,7 +41,17 @@ impl std::fmt::Display for SyncFrameError { Self::IoFlush(e) => write!(f, "CAR buffer flush failed: {}", e), Self::CborSerialize(e) => write!(f, "CBOR serialization failed: {}", e), Self::MissingCommitCid => write!(f, "missing commit_cid"), - Self::CommitBlockNotFound => write!(f, "commit block not found"), + Self::MissingInlineCommitBlock => { + write!(f, "event missing inline commit block bytes") + } + Self::MissingLegacyBlocks(cids) => { + write!( + f, + "legacy event references blocks not present in live blockstore (gc race): {} missing cid(s), first: {}", + cids.len(), + cids.first().map(|c| c.to_string()).unwrap_or_default() + ) + } Self::RevExtraction => write!(f, "could not extract rev from commit"), Self::InvalidEvent(msg) => write!(f, "invalid event: {}", msg), Self::BlockStore(e) => write!(f, "block store error: {}", e), @@ -178,7 +189,7 @@ fn extract_rev_from_commit_bytes(commit_bytes: &[u8]) -> Option { async fn write_car_blocks( commit_cid: Cid, - commit_bytes: Option, + commit_bytes: Bytes, other_blocks: BTreeMap, ) -> Result, SyncFrameError> { let mut buffer = Cursor::new(Vec::new()); @@ -190,12 +201,10 @@ async fn write_car_blocks( .await .map_err(SyncFrameError::CarWrite)?; } - if let Some(data) = commit_bytes { - writer - .write(commit_cid, data.as_ref()) - .await - .map_err(SyncFrameError::CarWrite)?; - } + writer + .write(commit_cid, commit_bytes.as_ref()) + .await + .map_err(SyncFrameError::CarWrite)?; writer.finish().await.map_err(SyncFrameError::CarFinalize)?; buffer.flush().await.map_err(SyncFrameError::IoFlush)?; Ok(buffer.into_inner()) @@ -265,26 +274,81 @@ fn format_account_event(event: &SequencedEvent) -> Result, SyncFrameErro Ok(bytes) } -async fn format_sync_event( +async fn event_blocks_to_map( + blocks: Option<&EventBlocks>, + prefetched: &HashMap, state: &AppState, +) -> Result, SyncFrameError> { + match blocks { + None => Ok(HashMap::new()), + Some(EventBlocks::Inline(inline)) => inline + .iter() + .map(|b| { + Cid::read_bytes(b.cid_bytes.as_slice()) + .map_err(SyncFrameError::CidParse) + .map(|cid| (cid, Bytes::copy_from_slice(&b.data))) + }) + .collect(), + Some(EventBlocks::LegacyCids(cid_strs)) => { + let cids: Vec = cid_strs + .iter() + .map(|s| Cid::from_str(s).map_err(SyncFrameError::CidParse)) + .collect::>()?; + let mut map: HashMap = HashMap::with_capacity(cids.len()); + let to_fetch: Vec = cids + .iter() + .filter(|cid| match prefetched.get(cid) { + Some(b) => { + map.insert(**cid, b.clone()); + false + } + None => true, + }) + .copied() + .collect(); + if !to_fetch.is_empty() { + let fetched = state.block_store.get_many(&to_fetch).await?; + let (found, missing): (Vec<_>, Vec<_>) = to_fetch + .into_iter() + .zip(fetched) + .partition(|(_, opt)| opt.is_some()); + found + .into_iter() + .filter_map(|(cid, opt)| opt.map(|b| (cid, b))) + .for_each(|(cid, b)| { + map.insert(cid, b); + }); + if !missing.is_empty() { + let missing_cids: Vec = missing.into_iter().map(|(cid, _)| cid).collect(); + return Err(SyncFrameError::MissingLegacyBlocks(missing_cids)); + } + } + Ok(map) + } + } +} + +async fn format_sync_event( event: &SequencedEvent, + prefetched: &HashMap, + state: &AppState, ) -> Result, SyncFrameError> { let commit_cid_str = event .commit_cid .as_ref() .ok_or(SyncFrameError::MissingCommitCid)?; let commit_cid = Cid::from_str(commit_cid_str)?; - let commit_bytes = state - .block_store + let blocks_map = event_blocks_to_map(event.blocks.as_ref(), prefetched, state).await?; + let commit_bytes = blocks_map .get(&commit_cid) - .await? - .ok_or(SyncFrameError::CommitBlockNotFound)?; + .cloned() + .ok_or(SyncFrameError::MissingInlineCommitBlock)?; let rev = if let Some(ref stored_rev) = event.rev { stored_rev.clone() } else { extract_rev_from_commit_bytes(&commit_bytes).ok_or(SyncFrameError::RevExtraction)? }; - let car_bytes = write_car_blocks(commit_cid, Some(commit_bytes), BTreeMap::new()).await?; + let car_bytes = write_car_blocks(commit_cid, commit_bytes, BTreeMap::new()).await?; serialize_event_frame( FrameType::Sync, &SyncFrame { @@ -302,13 +366,17 @@ struct CommitEventContext { frame: CommitFrame, commit_cid: Cid, prev_cid: Option, - block_cids: Vec, + inline_blocks: HashMap, } -fn prepare_commit_event(event: SequencedEvent) -> Result { - let block_cids_str = event.blocks_cids.clone().unwrap_or_default(); +async fn prepare_commit_event( + event: SequencedEvent, + prefetched: &HashMap, + state: &AppState, +) -> Result { let prev_cid_link = event.prev_cid.clone(); let prev_data_cid_link = event.prev_data_cid.clone(); + let inline_blocks = event_blocks_to_map(event.blocks.as_ref(), prefetched, state).await?; let mut frame: CommitFrame = event .try_into() @@ -321,46 +389,43 @@ fn prepare_commit_event(event: SequencedEvent) -> Result = block_cids_str - .iter() - .filter_map(|s| Cid::from_str(s).ok()) - .filter(|c| Some(*c) != prev_cid) - .collect(); - if !block_cids.contains(&commit_cid) { - block_cids.push(commit_cid); - } Ok(CommitEventContext { frame, commit_cid, prev_cid, - block_cids, + inline_blocks, }) } fn partition_blocks( block_cids: impl IntoIterator, commit_cid: Cid, -) -> (Option, BTreeMap) { +) -> Result<(Bytes, BTreeMap), SyncFrameError> { let (commit_data, other_blocks): (Vec<_>, Vec<_>) = block_cids .into_iter() .partition(|(cid, _)| *cid == commit_cid); - let commit_bytes = commit_data.into_iter().next().map(|(_, data)| data); + let commit_bytes = commit_data + .into_iter() + .next() + .map(|(_, data)| data) + .ok_or(SyncFrameError::MissingInlineCommitBlock)?; let other = other_blocks.into_iter().collect(); - (commit_bytes, other) + Ok((commit_bytes, other)) } async fn finalize_commit_frame( mut frame: CommitFrame, commit_cid: Cid, - commit_bytes: Option, + commit_bytes: Bytes, other_blocks: BTreeMap, ) -> Result, SyncFrameError> { - if let Some(ref cb) = commit_bytes - && let Some(rev) = extract_rev_from_commit_bytes(cb) - { + if let Some(rev) = extract_rev_from_commit_bytes(&commit_bytes) { frame.rev = rev; } frame.blocks = write_car_blocks(commit_cid, commit_bytes, other_blocks).await?; @@ -371,134 +436,63 @@ async fn finalize_commit_frame( pub async fn format_event_for_sending( state: &AppState, event: SequencedEvent, +) -> Result, SyncFrameError> { + format_event_inner(event, &HashMap::new(), state).await +} + +async fn format_event_inner( + event: SequencedEvent, + prefetched: &HashMap, + state: &AppState, ) -> Result, SyncFrameError> { match event.event_type { RepoEventType::Identity => return format_identity_event(&event), RepoEventType::Account => return format_account_event(&event), - RepoEventType::Sync => return format_sync_event(state, &event).await, + RepoEventType::Sync => return format_sync_event(&event, prefetched, state).await, RepoEventType::Commit => {} } - let ctx = prepare_commit_event(event)?; + let ctx = prepare_commit_event(event, prefetched, state).await?; let mut frame = ctx.frame; if let Some(ref pc) = ctx.prev_cid - && let Ok(Some(prev_bytes)) = state.block_store.get(pc).await - && let Some(rev) = extract_rev_from_commit_bytes(&prev_bytes) + && let Some(prev_bytes) = ctx.inline_blocks.get(pc) + && let Some(rev) = extract_rev_from_commit_bytes(prev_bytes) { frame.since = Some(rev); } - if ctx.block_cids.is_empty() { - frame.blocks = Vec::new(); - let capacity = frame.blocks.len() + 512; - return serialize_event_frame(FrameType::Commit, &frame, capacity); - } - let fetched = state.block_store.get_many(&ctx.block_cids).await?; - let resolved = ctx - .block_cids - .iter() - .zip(fetched.iter()) - .filter_map(|(cid, data_opt)| data_opt.as_ref().map(|data| (*cid, data.clone()))); - let (commit_bytes, other_blocks) = partition_blocks(resolved, ctx.commit_cid); + let (commit_bytes, other_blocks) = partition_blocks(ctx.inline_blocks, ctx.commit_cid)?; finalize_commit_frame(frame, ctx.commit_cid, commit_bytes, other_blocks).await } +pub async fn format_event_with_prefetched_blocks( + state: &AppState, + event: SequencedEvent, + prefetched: &HashMap, +) -> Result, SyncFrameError> { + format_event_inner(event, prefetched, state).await +} + pub async fn prefetch_blocks_for_events( state: &AppState, events: &[SequencedEvent], ) -> Result, SyncFrameError> { - let mut all_cids: Vec = events + let legacy_cids: Vec = events .iter() - .flat_map(|event| { - let commit_cid = event - .commit_cid - .as_ref() - .and_then(|s| Cid::from_str(s).ok()); - let prev_cid = event.prev_cid.as_ref().and_then(|s| Cid::from_str(s).ok()); - let block_cids = event - .blocks_cids - .as_ref() - .map(|cids| cids.iter().filter_map(|s| Cid::from_str(s).ok()).collect()) - .unwrap_or_else(Vec::new); - commit_cid.into_iter().chain(prev_cid).chain(block_cids) + .filter_map(|e| match e.blocks.as_ref() { + Some(EventBlocks::LegacyCids(strs)) => Some(strs.iter()), + _ => None, }) - .collect(); - all_cids.sort(); - all_cids.dedup(); - if all_cids.is_empty() { + .flatten() + .map(|s| Cid::from_str(s).map_err(SyncFrameError::CidParse)) + .collect::>()?; + if legacy_cids.is_empty() { return Ok(HashMap::new()); } - let fetched = state.block_store.get_many(&all_cids).await?; - let blocks_map: HashMap = all_cids + let fetched = state.block_store.get_many(&legacy_cids).await?; + Ok(legacy_cids .into_iter() .zip(fetched) - .filter_map(|(cid, data_opt)| data_opt.map(|data| (cid, data))) - .collect(); - Ok(blocks_map) -} - -fn format_sync_event_with_prefetched( - event: &SequencedEvent, - prefetched: &HashMap, -) -> Result, SyncFrameError> { - let commit_cid_str = event - .commit_cid - .as_ref() - .ok_or(SyncFrameError::MissingCommitCid)?; - let commit_cid = Cid::from_str(commit_cid_str)?; - let commit_bytes = prefetched - .get(&commit_cid) - .ok_or(SyncFrameError::CommitBlockNotFound)?; - let rev = if let Some(ref stored_rev) = event.rev { - stored_rev.clone() - } else { - extract_rev_from_commit_bytes(commit_bytes).ok_or(SyncFrameError::RevExtraction)? - }; - let car_bytes = futures::executor::block_on(write_car_blocks( - commit_cid, - Some(commit_bytes.clone()), - BTreeMap::new(), - ))?; - serialize_event_frame( - FrameType::Sync, - &SyncFrame { - did: event.did.clone(), - rev, - blocks: car_bytes, - seq: event.seq.as_i64(), - time: format_atproto_time(event.created_at), - }, - 512, - ) -} - -pub async fn format_event_with_prefetched_blocks( - event: SequencedEvent, - prefetched: &HashMap, -) -> Result, SyncFrameError> { - match event.event_type { - RepoEventType::Identity => return format_identity_event(&event), - RepoEventType::Account => return format_account_event(&event), - RepoEventType::Sync => return format_sync_event_with_prefetched(&event, prefetched), - RepoEventType::Commit => {} - } - let ctx = prepare_commit_event(event)?; - let mut frame = ctx.frame; - if let Some(ref pc) = ctx.prev_cid - && let Some(prev_bytes) = prefetched.get(pc) - && let Some(rev) = extract_rev_from_commit_bytes(prev_bytes) - { - frame.since = Some(rev); - } - if ctx.block_cids.is_empty() { - frame.blocks = Vec::new(); - let capacity = frame.blocks.len() + 512; - return serialize_event_frame(FrameType::Commit, &frame, capacity); - } - let resolved = ctx - .block_cids - .into_iter() - .filter_map(|cid| prefetched.get(&cid).map(|data| (cid, data.clone()))); - let (commit_bytes, other_blocks) = partition_blocks(resolved, ctx.commit_cid); - finalize_commit_frame(frame, ctx.commit_cid, commit_bytes, other_blocks).await + .filter_map(|(cid, opt)| opt.map(|b| (cid, b))) + .collect()) } pub fn format_info_frame( diff --git a/crates/tranquil-pds/tests/common/mod.rs b/crates/tranquil-pds/tests/common/mod.rs index 590b3af..598319b 100644 --- a/crates/tranquil-pds/tests/common/mod.rs +++ b/crates/tranquil-pds/tests/common/mod.rs @@ -29,6 +29,7 @@ static TEST_DB_POOL: OnceLock = OnceLock::new(); static TEST_TEMP_DIR: OnceLock = OnceLock::new(); static CLUSTER: OnceLock> = OnceLock::new(); static TEST_REPOS: OnceLock> = OnceLock::new(); +static TEST_BLOCK_STORE: OnceLock = OnceLock::new(); #[allow(dead_code)] pub fn is_store_backend() -> bool { @@ -41,6 +42,8 @@ pub fn is_store_backend() -> bool { pub struct ServerConfig { pub pool: Option, pub cache: Option<(Arc, Arc)>, + pub store_path: Option, + pub shared_state: Option, } #[allow(dead_code)] @@ -574,12 +577,17 @@ async fn spawn_server(config: ServerConfig) -> ServerInstance { .with_oauth_authorize_limit(10000) .with_oauth_token_limit(10000); let cache_refs = config.cache.as_ref().map(|(c, r)| (c.clone(), r.clone())); - let mut state = match config.pool { - Some(pool) => AppState::from_db(pool, CancellationToken::new()).await, - None => AppState::from_store(CancellationToken::new()).await, + let mut state = match config.shared_state { + Some(s) => s, + None => match (config.pool, config.store_path) { + (Some(pool), _) => AppState::from_db(pool, CancellationToken::new()).await, + (None, Some(path)) => AppState::from_store_at(&path, CancellationToken::new()).await, + (None, None) => AppState::from_store(CancellationToken::new()).await, + }, }; state = state.with_rate_limiters(rate_limiters); TEST_REPOS.set(state.repos.clone()).ok(); + TEST_BLOCK_STORE.set(state.block_store.clone()).ok(); if let Some((cache, distributed_rate_limiter)) = config.cache { state = state.with_cache(cache, distributed_rate_limiter); } @@ -637,6 +645,8 @@ async fn setup_store_backend() -> String { let instance = spawn_server(ServerConfig { pool: None, cache: None, + store_path: None, + shared_state: None, }) .await; APP_PORT.set(instance.port).ok(); @@ -664,6 +674,8 @@ async fn spawn_app(database_url: String) -> String { let instance = spawn_server(ServerConfig { pool: Some(pool), cache: None, + store_path: None, + shared_state: None, }) .await; APP_PORT.set(instance.port).ok(); @@ -671,27 +683,9 @@ async fn spawn_app(database_url: String) -> String { } #[allow(dead_code)] -pub async fn spawn_cluster(database_url: String, node_count: usize) -> Vec { +pub async fn spawn_cluster(pool: Option, node_count: usize) -> Vec { use tranquil_ripple::{RippleConfig, RippleEngine}; - let pool = PgPoolOptions::new() - .max_connections(10) - .acquire_timeout(std::time::Duration::from_secs(30)) - .connect(&database_url) - .await - .expect("Failed to connect to Postgres for cluster"); - sqlx::migrate!("./migrations") - .run(&pool) - .await - .expect("Failed to run migrations for cluster"); - let test_pool = PgPoolOptions::new() - .max_connections(2) - .acquire_timeout(std::time::Duration::from_secs(30)) - .connect(&database_url) - .await - .expect("Failed to create test pool for cluster"); - TEST_DB_POOL.set(test_pool).ok(); - let shutdown = CancellationToken::new(); let mut ripple_nodes: Vec<(Arc, Arc)> = @@ -713,11 +707,28 @@ pub async fn spawn_cluster(database_url: String, node_count: usize) -> Vec { + let path = std::env::temp_dir().join(format!( + "tranquil-pds-cluster-store-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&path).expect("failed to create cluster store dir"); + Some(AppState::from_store_at(&path, CancellationToken::new()).await) + } + false => None, + }; let mut instances: Vec = Vec::with_capacity(node_count); for (cache, rate_limiter) in ripple_nodes { let server_config = ServerConfig { - pool: Some(pool.clone()), + pool: pool.clone(), cache: Some((cache, rate_limiter)), + store_path: None, + shared_state: base_state.clone(), }; let instance = spawn_server(server_config).await; instances.push(instance); @@ -757,12 +768,14 @@ pub async fn cluster() -> &'static [ServerInstance] { unsafe { std::env::remove_var("DISABLE_RATE_LIMITING"); } - let database_url = if has_external_infra() { + let pool = if is_store_backend() { + setup_cluster_store_backend().await + } else if has_external_infra() { setup_cluster_external_infra().await } else { setup_cluster_testcontainers().await }; - let nodes = spawn_cluster(database_url, 3).await; + let nodes = spawn_cluster(pool, 3).await; tx.send(nodes).unwrap(); std::future::pending::<()>().await; }); @@ -771,7 +784,36 @@ pub async fn cluster() -> &'static [ServerInstance] { }) } -async fn setup_cluster_external_infra() -> String { +async fn setup_cluster_store_backend() -> Option { + let temp_dir = std::env::temp_dir().join(format!( + "tranquil-pds-cluster-store-{}", + uuid::Uuid::new_v4() + )); + let blob_path = temp_dir.join("blobs"); + let backup_path = temp_dir.join("backups"); + let store_path = temp_dir.join("store"); + std::fs::create_dir_all(&blob_path).expect("failed to create blob temp directory"); + std::fs::create_dir_all(&backup_path).expect("failed to create backup temp directory"); + std::fs::create_dir_all(&store_path).expect("failed to create store temp directory"); + TEST_TEMP_DIR.set(temp_dir).ok(); + let plc_url = setup_mock_plc_directory().await; + unsafe { + std::env::set_var("BLOB_STORAGE_BACKEND", "filesystem"); + std::env::set_var("BLOB_STORAGE_PATH", blob_path.to_str().unwrap()); + std::env::set_var("BACKUP_STORAGE_BACKEND", "filesystem"); + std::env::set_var("BACKUP_STORAGE_PATH", backup_path.to_str().unwrap()); + std::env::set_var("MAX_IMPORT_SIZE", "100000000"); + std::env::set_var("SKIP_IMPORT_VERIFICATION", "true"); + std::env::set_var("PLC_DIRECTORY_URL", &plc_url); + std::env::set_var("REPO_BACKEND", "tranquil-store"); + std::env::set_var("TRANQUIL_STORE_DATA_DIR", store_path.to_str().unwrap()); + std::env::set_var("DATABASE_URL", "postgres://unused/unused"); + } + register_mock_appview().await; + None +} + +async fn setup_cluster_external_infra() -> Option { let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set when using external infra"); let plc_url = setup_mock_plc_directory().await; @@ -780,11 +822,28 @@ async fn setup_cluster_external_infra() -> String { std::env::set_var("PLC_DIRECTORY_URL", &plc_url); } register_mock_appview().await; - database_url + let pool = PgPoolOptions::new() + .max_connections(10) + .acquire_timeout(std::time::Duration::from_secs(30)) + .connect(&database_url) + .await + .expect("Failed to connect to Postgres for cluster"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to run migrations for cluster"); + let test_pool = PgPoolOptions::new() + .max_connections(2) + .acquire_timeout(std::time::Duration::from_secs(30)) + .connect(&database_url) + .await + .expect("Failed to create test pool for cluster"); + TEST_DB_POOL.set(test_pool).ok(); + Some(pool) } #[cfg(not(feature = "external-infra"))] -async fn setup_cluster_testcontainers() -> String { +async fn setup_cluster_testcontainers() -> Option { let temp_dir = std::env::temp_dir().join(format!("tranquil-pds-cluster-{}", uuid::Uuid::new_v4())); let blob_path = temp_dir.join("blobs"); @@ -817,11 +876,28 @@ async fn setup_cluster_testcontainers() -> String { .expect("Failed to get port") ); DB_CONTAINER.set(container).ok(); - connection_string + let pool = PgPoolOptions::new() + .max_connections(10) + .acquire_timeout(std::time::Duration::from_secs(30)) + .connect(&connection_string) + .await + .expect("Failed to connect to Postgres for cluster"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("Failed to run migrations for cluster"); + let test_pool = PgPoolOptions::new() + .max_connections(2) + .acquire_timeout(std::time::Duration::from_secs(30)) + .connect(&connection_string) + .await + .expect("Failed to create test pool for cluster"); + TEST_DB_POOL.set(test_pool).ok(); + Some(pool) } #[cfg(feature = "external-infra")] -async fn setup_cluster_testcontainers() -> String { +async fn setup_cluster_testcontainers() -> Option { panic!( "Testcontainers disabled with external-infra feature. Set DATABASE_URL and BLOB_STORAGE_PATH (or S3_ENDPOINT)." ); @@ -861,6 +937,14 @@ pub async fn get_test_repos() -> &'static Arc TEST_REPOS.get().expect("TEST_REPOS not initialized") } +#[allow(dead_code)] +pub async fn get_test_block_store() -> &'static tranquil_pds::repo::AnyBlockStore { + base_url().await; + TEST_BLOCK_STORE + .get() + .expect("TEST_BLOCK_STORE not initialized") +} + fn extract_verification_code(body_text: &str) -> String { let lines: Vec<&str> = body_text.lines().collect(); lines diff --git a/crates/tranquil-pds/tests/firehose/mod.rs b/crates/tranquil-pds/tests/firehose/mod.rs index e832ea5..57bd2c4 100644 --- a/crates/tranquil-pds/tests/firehose/mod.rs +++ b/crates/tranquil-pds/tests/firehose/mod.rs @@ -32,6 +32,7 @@ pub struct ParsedCommitFrame { pub prev_data: Option, } +#[allow(dead_code)] #[derive(Debug, Clone)] pub struct ParsedRepoOp { pub action: RepoAction, @@ -223,6 +224,7 @@ impl FirehoseConsumer { self.frames.lock().unwrap().drain(..).collect() } + #[allow(dead_code)] pub fn all_commits(&self) -> Vec { self.frames .lock() diff --git a/crates/tranquil-pds/tests/firehose_inline_blocks.rs b/crates/tranquil-pds/tests/firehose_inline_blocks.rs new file mode 100644 index 0000000..ca56b0c --- /dev/null +++ b/crates/tranquil-pds/tests/firehose_inline_blocks.rs @@ -0,0 +1,423 @@ +mod common; +mod firehose; +mod helpers; + +use cid::Cid; +use common::*; +use firehose::FirehoseConsumer; +use helpers::build_car_with_signature; +use iroh_car::CarReader; +use k256::ecdsa::SigningKey; +use multihash::Multihash; +use reqwest::StatusCode; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::io::Cursor; +use std::time::Duration; +use tranquil_db_traits::{EventBlocks, RepoEventType, SequenceNumber}; +use tranquil_types::{CidLink, Did}; + +fn synthetic_cid(payload: &[u8]) -> Cid { + let digest = Sha256::digest(payload); + let mh = Multihash::wrap(0x12, digest.as_slice()).expect("multihash wrap"); + Cid::new_v1(0x71, mh) +} + +fn fresh_synthetic_did(label: &str) -> Did { + Did::new(format!( + "did:plc:test{}{}", + label, + uuid::Uuid::new_v4().simple() + )) + .expect("valid did") +} + +async fn create_post(client: &reqwest::Client, token: &str, did: &str, text: &str) { + let payload = json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "record": { + "$type": "app.bsky.feed.post", + "text": text, + "createdAt": chrono::Utc::now().to_rfc3339(), + } + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.createRecord", + base_url().await + )) + .bearer_auth(token) + .json(&payload) + .send() + .await + .expect("createRecord request failed"); + assert_eq!(res.status(), StatusCode::OK, "createRecord failed"); +} + +#[tokio::test] +async fn commit_events_carry_inline_blocks() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + + create_post(&client, &token, &did, "commit A: orphans incoming").await; + create_post(&client, &token, &did, "commit B: bye bye MST nodes from A").await; + + let repos = get_test_repos().await; + let typed_did = tranquil_types::Did::new(did.clone()).unwrap(); + + let events = repos + .repo + .get_events_since_seq(SequenceNumber::ZERO, None) + .await + .expect("get_events_since_seq failed"); + + let our_commits: Vec<_> = events + .iter() + .filter(|e| e.did == typed_did && e.event_type == RepoEventType::Commit) + .collect(); + + assert!( + our_commits.len() >= 2, + "expected at least 2 commit events for our DID, got {}", + our_commits.len() + ); + + our_commits.iter().for_each(|event| { + let blocks = event.blocks.as_ref().unwrap_or_else(|| { + panic!( + "commit event seq={} has no blocks field", + event.seq.as_i64() + ) + }); + let inline = match blocks { + EventBlocks::Inline(v) => v, + EventBlocks::LegacyCids(_) => panic!( + "commit event seq={} resolved as LegacyCids, expected Inline; \ + new commits must inline block bytes into the eventlog", + event.seq.as_i64() + ), + }; + assert!( + !inline.is_empty(), + "commit event seq={} has empty Inline blocks vec", + event.seq.as_i64() + ); + let commit_cid = event + .commit_cid + .as_ref() + .and_then(|c| c.to_cid()) + .unwrap_or_else(|| { + panic!("commit event seq={} missing commit_cid", event.seq.as_i64()) + }); + let commit_cid_bytes = commit_cid.to_bytes(); + assert!( + inline.iter().any(|b| b.cid_bytes == commit_cid_bytes), + "commit event seq={} inline blocks do not contain the commit block", + event.seq.as_i64() + ); + inline.iter().for_each(|b| { + let parsed = Cid::read_bytes(b.cid_bytes.as_slice()).unwrap_or_else(|e| { + panic!( + "commit event seq={} inline cid_bytes failed to parse as Cid: {e}", + event.seq.as_i64() + ) + }); + assert_eq!( + parsed.to_bytes(), + b.cid_bytes, + "commit event seq={} cid round-trip mismatch (cid={parsed})", + event.seq.as_i64() + ); + }); + }); +} + +#[tokio::test] +async fn sync_event_carries_inline_commit_block() { + let repos = get_test_repos().await; + let did = fresh_synthetic_did("sync"); + let commit_bytes = b"synthetic sync commit block payload".to_vec(); + let commit_cid = synthetic_cid(&commit_bytes); + let cid_link: CidLink = (&commit_cid).into(); + let rev = "3kabcdefghij2"; + + let seq = repos + .repo + .insert_sync_event(&did, &cid_link, Some(rev), &commit_bytes) + .await + .expect("insert_sync_event"); + + let event = repos + .repo + .get_event_by_seq(seq) + .await + .expect("get_event_by_seq") + .expect("event present"); + + assert_eq!(event.event_type, RepoEventType::Sync); + let blocks = event + .blocks + .as_ref() + .expect("sync event must carry inline blocks"); + let inline = match blocks { + EventBlocks::Inline(v) => v, + EventBlocks::LegacyCids(_) => { + panic!("sync event resolved as LegacyCids; new sync events must inline block bytes") + } + }; + assert_eq!( + inline.len(), + 1, + "sync event must carry exactly the commit block, got {}", + inline.len() + ); + let stored = &inline[0]; + assert_eq!( + stored.cid_bytes, + commit_cid.to_bytes(), + "sync event inline cid_bytes mismatch" + ); + assert_eq!(stored.data, commit_bytes, "sync event inline data mismatch"); +} + +#[tokio::test] +async fn genesis_commit_event_carries_inline_blocks() { + let repos = get_test_repos().await; + let did = fresh_synthetic_did("gen"); + let commit_bytes = b"synthetic genesis commit block payload".to_vec(); + let mst_root_bytes = b"synthetic genesis mst root block payload".to_vec(); + let commit_cid = synthetic_cid(&commit_bytes); + let mst_root_cid = synthetic_cid(&mst_root_bytes); + let commit_link: CidLink = (&commit_cid).into(); + let mst_link: CidLink = (&mst_root_cid).into(); + let rev = "3kabcdefghij3"; + + let seq = repos + .repo + .insert_genesis_commit_event( + &did, + &commit_link, + &mst_link, + rev, + &commit_bytes, + &mst_root_bytes, + ) + .await + .expect("insert_genesis_commit_event"); + + let event = repos + .repo + .get_event_by_seq(seq) + .await + .expect("get_event_by_seq") + .expect("event present"); + + assert_eq!(event.event_type, RepoEventType::Commit); + let blocks = event + .blocks + .as_ref() + .expect("genesis commit event must carry inline blocks"); + let inline = match blocks { + EventBlocks::Inline(v) => v, + EventBlocks::LegacyCids(_) => { + panic!("genesis event resolved as LegacyCids; new genesis events must inline blocks") + } + }; + assert_eq!( + inline.len(), + 2, + "genesis event must carry commit + mst root blocks, got {}", + inline.len() + ); + + let commit_cid_bytes = commit_cid.to_bytes(); + let mst_cid_bytes = mst_root_cid.to_bytes(); + + let commit_block = inline + .iter() + .find(|b| b.cid_bytes == commit_cid_bytes) + .expect("genesis inline blocks missing commit block"); + assert_eq!(commit_block.data, commit_bytes); + + let mst_block = inline + .iter() + .find(|b| b.cid_bytes == mst_cid_bytes) + .expect("genesis inline blocks missing mst root block"); + assert_eq!(mst_block.data, mst_root_bytes); +} + +#[tokio::test] +async fn backfill_succeeds_from_eventlog_alone() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + + create_post(&client, &token, &did, "first").await; + create_post(&client, &token, &did, "second").await; + + let consumer = FirehoseConsumer::connect_with_cursor(app_port(), 0).await; + let commits = consumer + .wait_for_commits(&did, 2, Duration::from_secs(20)) + .await; + + assert!( + commits.len() >= 2, + "expected at least 2 backfilled commits for {}, got {}", + did, + commits.len() + ); + + for commit in &commits { + assert!( + !commit.blocks.is_empty(), + "backfilled commit seq={} has empty CAR blocks", + commit.seq + ); + let mut reader = CarReader::new(Cursor::new(&commit.blocks)) + .await + .unwrap_or_else(|e| panic!("CAR header parse failed for seq={}: {e}", commit.seq)); + assert!( + !reader.header().roots().is_empty(), + "CAR for seq={} has no roots", + commit.seq + ); + assert_eq!( + reader.header().roots()[0], + commit.commit, + "CAR root mismatch for seq={}", + commit.seq + ); + let mut found_commit_block = false; + while let Ok(Some((cid, _))) = reader.next_block().await { + if cid == commit.commit { + found_commit_block = true; + } + } + assert!( + found_commit_block, + "backfilled commit seq={} CAR missing the commit block", + commit.seq + ); + } +} + +#[tokio::test] +async fn import_event_carries_inline_commit_block() { + let client = client(); + let (token, did) = create_account_and_login(&client).await; + + let signing_key = SigningKey::random(&mut rand::thread_rng()); + let (car_bytes, _car_root_cid) = build_car_with_signature(&did, &signing_key); + + let import_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.importRepo", + base_url().await + )) + .bearer_auth(&token) + .header("Content-Type", "application/vnd.ipld.car") + .body(car_bytes) + .send() + .await + .expect("import request failed"); + assert_eq!( + import_res.status(), + StatusCode::OK, + "import should succeed: body={:?}", + import_res.text().await.unwrap_or_default() + ); + + let repos = get_test_repos().await; + let typed_did = tranquil_types::Did::new(did.clone()).unwrap(); + let events = repos + .repo + .get_events_since_seq(SequenceNumber::ZERO, None) + .await + .expect("get_events_since_seq failed"); + + let our_commits: Vec<_> = events + .iter() + .filter(|e| e.did == typed_did && e.event_type == RepoEventType::Commit) + .collect(); + assert!( + !our_commits.is_empty(), + "expected at least one commit event for {} after import", + did + ); + + let import_event = our_commits + .last() + .expect("at least one commit event after import"); + let blocks = import_event.blocks.as_ref().unwrap_or_else(|| { + panic!( + "import commit event seq={} missing blocks field", + import_event.seq.as_i64() + ) + }); + let inline = match blocks { + EventBlocks::Inline(v) => v, + EventBlocks::LegacyCids(_) => panic!( + "import event seq={} resolved as LegacyCids; new commits must inline blocks", + import_event.seq.as_i64() + ), + }; + assert!( + !inline.is_empty(), + "import event seq={} has empty Inline blocks vec — this is the bug from \ + sequence_import_event using `blocks: Some(vec![])`", + import_event.seq.as_i64() + ); + let commit_cid = import_event + .commit_cid + .as_ref() + .and_then(|c| c.to_cid()) + .unwrap_or_else(|| { + panic!( + "import event seq={} missing commit_cid", + import_event.seq.as_i64() + ) + }); + let commit_cid_bytes = commit_cid.to_bytes(); + assert!( + inline.iter().any(|b| b.cid_bytes == commit_cid_bytes), + "import event seq={} inline blocks do not contain the freshly-created commit block", + import_event.seq.as_i64() + ); + + let consumer = FirehoseConsumer::connect_with_cursor(app_port(), 0).await; + let commits = consumer + .wait_for_commits(&did, 1, Duration::from_secs(20)) + .await; + assert!( + !commits.is_empty(), + "expected at least one backfilled commit after import for {}", + did + ); + for commit in &commits { + assert!( + !commit.blocks.is_empty(), + "backfilled import commit seq={} has empty CAR blocks", + commit.seq + ); + let mut reader = CarReader::new(Cursor::new(&commit.blocks)) + .await + .unwrap_or_else(|e| panic!("CAR header parse failed for seq={}: {e}", commit.seq)); + assert_eq!( + reader.header().roots()[0], + commit.commit, + "CAR root mismatch for import commit seq={}", + commit.seq + ); + let mut found_commit_block = false; + while let Ok(Some((cid, _))) = reader.next_block().await { + if cid == commit.commit { + found_commit_block = true; + } + } + assert!( + found_commit_block, + "backfilled import commit seq={} CAR missing the commit block", + commit.seq + ); + } +} diff --git a/crates/tranquil-pds/tests/firehose_validation.rs b/crates/tranquil-pds/tests/firehose_validation.rs index d1a880c..fa396d9 100644 --- a/crates/tranquil-pds/tests/firehose_validation.rs +++ b/crates/tranquil-pds/tests/firehose_validation.rs @@ -245,11 +245,11 @@ async fn test_firehose_frame_structure() { assert!(timeout.is_ok(), "Timed out waiting for event for our DID"); let (header, frame) = frame_opt.expect("No matching frame found"); - println!("\n=== Frame Structure Validation ===\n"); + println!("\n-- frame structure validation --\n"); println!("Header:"); - println!(" op: {} (expected: 1)", header.op); - println!(" t: {} (expected: #commit)", header.t); + println!(" op: {}, expected 1", header.op); + println!(" t: {}, expected #commit", header.t); assert_eq!(header.op, 1, "Header op should be 1"); assert_eq!(header.t, "#commit", "Header t should be #commit"); @@ -260,7 +260,7 @@ async fn test_firehose_frame_structure() { println!(" repo: {}", frame.repo); println!(" commit: {}", frame.commit); println!( - " rev: {} (valid TID: {})", + " rev: {}, valid TID: {}", frame.rev, is_valid_tid(&frame.rev) ); @@ -269,12 +269,12 @@ async fn test_firehose_frame_structure() { println!(" ops count: {}", frame.ops.len()); println!(" blobs count: {}", frame.blobs.len()); println!( - " time: {} (valid format: {})", + " time: {}, valid format: {}", frame.time, is_valid_time_format(&frame.time) ); println!( - " prevData: {:?} (IMPORTANT - should have value for updates)", + " prevData: {:?}, should have value for updates", frame.prev_data ); @@ -297,7 +297,7 @@ async fn test_firehose_frame_structure() { println!(" path: {}", op.path); println!(" cid: {:?}", op.cid); println!( - " prev: {:?} (should be Some for updates/deletes)", + " prev: {:?}, should be Some for updates/deletes", op.prev ); @@ -351,7 +351,7 @@ async fn test_firehose_frame_structure() { } } - println!("\n=== Validation Complete ===\n"); + println!("\n-- validation complete --\n"); ws_stream.send(tungstenite::Message::Close(None)).await.ok(); } @@ -435,7 +435,7 @@ async fn test_firehose_update_has_prev_field() { assert!(timeout.is_ok(), "Timed out waiting for update commit"); let frame = frame_opt.expect("No matching frame found"); - println!("\n=== Update Operation Validation ===\n"); + println!("\n-- update operation validation --\n"); println!("First profile CID: {}", first_cid); println!("Frame prevData: {:?}", frame.prev_data); @@ -455,7 +455,7 @@ async fn test_firehose_update_has_prev_field() { } } - println!("\n=== Validation Complete ===\n"); + println!("\n-- validation complete --\n"); ws_stream.send(tungstenite::Message::Close(None)).await.ok(); } @@ -512,13 +512,13 @@ async fn test_firehose_commit_has_prev_data() { assert!(timeout.is_ok(), "Timed out waiting for first commit"); let first_frame = first_frame_opt.expect("No first frame found"); - println!("\n=== First Commit ==="); + println!("\n-- first commit --"); println!( - " prevData: {:?} (first commit may be None)", + " prevData: {:?}, first commit may be None", first_frame.prev_data ); println!( - " since: {:?} (first commit should be None)", + " since: {:?}, first commit should be None", first_frame.since ); @@ -562,13 +562,13 @@ async fn test_firehose_commit_has_prev_data() { assert!(timeout.is_ok(), "Timed out waiting for second commit"); let second_frame = second_frame_opt.expect("No second frame found"); - println!("\n=== Second Commit ==="); + println!("\n-- second commit --"); println!( - " prevData: {:?} (should have value - MST root CID)", + " prevData: {:?}, should have value as MST root CID", second_frame.prev_data ); println!( - " since: {:?} (should have value - previous rev)", + " since: {:?}, should have value as previous rev", second_frame.since ); @@ -577,7 +577,7 @@ async fn test_firehose_commit_has_prev_data() { "Second commit should have 'since' field pointing to first commit rev" ); - println!("\n=== Validation Complete ===\n"); + println!("\n-- validation complete --\n"); ws_stream.send(tungstenite::Message::Close(None)).await.ok(); } @@ -634,7 +634,7 @@ async fn test_compare_raw_cbor_encoding() { assert!(timeout.is_ok(), "Timed out waiting for event for our DID"); let raw_bytes = raw_bytes_opt.expect("No matching frame found"); - println!("\n=== Raw CBOR Analysis ===\n"); + println!("\n-- raw CBOR analysis --\n"); println!("Total frame size: {} bytes", raw_bytes.len()); fn bytes_to_hex(bytes: &[u8]) -> String { @@ -657,7 +657,7 @@ async fn test_compare_raw_cbor_encoding() { println!("\nPayload section: {} bytes", raw_bytes.len() - header_end); - println!("\n=== Analysis Complete ===\n"); + println!("\n-- analysis complete --\n"); ws_stream.send(tungstenite::Message::Close(None)).await.ok(); } diff --git a/crates/tranquil-pds/tests/gc_after_delete.rs b/crates/tranquil-pds/tests/gc_after_delete.rs new file mode 100644 index 0000000..8169821 --- /dev/null +++ b/crates/tranquil-pds/tests/gc_after_delete.rs @@ -0,0 +1,410 @@ +mod common; +mod helpers; +use chrono::Utc; +use common::*; +use helpers::*; +use reqwest::StatusCode; +use serde_json::{Value, json}; +use tranquil_types::Did; + +#[tokio::test] +async fn test_delete_record_marks_blocks_obsolete() { + let client = client(); + let base = base_url().await; + let repos = get_test_repos().await; + let (did, jwt) = setup_new_user("gc-after-delete").await; + + let user_id = repos + .user + .get_id_by_did(&Did::new(did.clone()).unwrap()) + .await + .expect("DB error") + .expect("User not found"); + + let count_baseline = repos + .repo + .count_user_blocks(user_id) + .await + .expect("count_user_blocks failed"); + + let collection = "app.bsky.feed.post"; + let rkey = format!("gc_test_{}", Utc::now().timestamp_millis()); + let create_payload = json!({ + "repo": did, + "collection": collection, + "rkey": rkey, + "record": { + "$type": collection, + "text": "this record is destined for deletion", + "createdAt": Utc::now().to_rfc3339() + } + }); + + let create_res = client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) + .bearer_auth(&jwt) + .json(&create_payload) + .send() + .await + .expect("Failed to send createRecord"); + assert_eq!( + create_res.status(), + StatusCode::OK, + "createRecord did not return 200" + ); + let create_body: Value = create_res + .json() + .await + .expect("createRecord response was not JSON"); + let record_uri = create_body["uri"] + .as_str() + .expect("createRecord response missing uri") + .to_string(); + let record_cid = create_body["cid"] + .as_str() + .expect("createRecord response missing cid") + .to_string(); + + let count_after_create = repos + .repo + .count_user_blocks(user_id) + .await + .expect("count_user_blocks failed"); + assert!( + count_after_create > count_baseline, + "user_blocks count did not grow after createRecord (baseline={}, after_create={})", + count_baseline, + count_after_create + ); + + let delete_payload = json!({ + "repo": did, + "collection": collection, + "rkey": rkey, + }); + let delete_res = client + .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base)) + .bearer_auth(&jwt) + .json(&delete_payload) + .send() + .await + .expect("Failed to send deleteRecord"); + assert_eq!( + delete_res.status(), + StatusCode::OK, + "deleteRecord did not return 200: {:?}", + delete_res.text().await + ); + + let count_after_delete = repos + .repo + .count_user_blocks(user_id) + .await + .expect("count_user_blocks failed"); + + assert!( + count_after_delete < count_after_create, + "user_blocks count did not shrink after deleteRecord \ + (baseline={}, after_create={}, after_delete={}). \ + The delete path produced no obsolete CIDs beyond the prior commit root, \ + which is the regression this test guards against.", + count_baseline, + count_after_create, + count_after_delete + ); + + let get_res = client + .get(format!("{}/xrpc/com.atproto.repo.getRecord", base)) + .query(&[ + ("repo", did.as_str()), + ("collection", collection), + ("rkey", rkey.as_str()), + ]) + .send() + .await + .expect("Failed to send getRecord"); + assert!( + !get_res.status().is_success(), + "deleted record is still resolvable via getRecord (status={}); uri={} cid={}", + get_res.status(), + record_uri, + record_cid + ); +} + +#[tokio::test] +async fn test_update_record_marks_old_record_block_obsolete() { + let client = client(); + let base = base_url().await; + let repos = get_test_repos().await; + let (did, jwt) = setup_new_user("gc-after-update").await; + + let user_id = repos + .user + .get_id_by_did(&Did::new(did.clone()).unwrap()) + .await + .expect("DB error") + .expect("User not found"); + + let collection = "app.bsky.feed.post"; + let rkey = format!("gc_update_{}", Utc::now().timestamp_millis()); + + let put_v1 = json!({ + "repo": did, + "collection": collection, + "rkey": rkey, + "record": { + "$type": collection, + "text": "first version", + "createdAt": Utc::now().to_rfc3339() + } + }); + let res = client + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base)) + .bearer_auth(&jwt) + .json(&put_v1) + .send() + .await + .expect("Failed to send putRecord v1"); + assert_eq!(res.status(), StatusCode::OK, "first putRecord failed"); + + let count_after_create = repos + .repo + .count_user_blocks(user_id) + .await + .expect("count_user_blocks failed"); + + let put_v2 = json!({ + "repo": did, + "collection": collection, + "rkey": rkey, + "record": { + "$type": collection, + "text": "second version with new content", + "createdAt": Utc::now().to_rfc3339() + } + }); + let res = client + .post(format!("{}/xrpc/com.atproto.repo.putRecord", base)) + .bearer_auth(&jwt) + .json(&put_v2) + .send() + .await + .expect("Failed to send putRecord v2"); + assert_eq!(res.status(), StatusCode::OK, "second putRecord failed"); + + let count_after_update = repos + .repo + .count_user_blocks(user_id) + .await + .expect("count_user_blocks failed"); + + assert!( + count_after_update <= count_after_create + 1, + "user_blocks count grew by more than 1 after putRecord update \ + (after_create={}, after_update={}). The previous version's record block \ + should have been marked obsolete; instead it appears to be leaking.", + count_after_create, + count_after_update + ); +} + +#[tokio::test] +async fn test_delete_in_populated_repo_marks_merged_subtree_blocks_obsolete() { + let client = client(); + let base = base_url().await; + let repos = get_test_repos().await; + let (did, jwt) = setup_new_user("gc-merge").await; + + let user_id = repos + .user + .get_id_by_did(&Did::new(did.clone()).unwrap()) + .await + .expect("DB error") + .expect("User not found"); + + let collection = "app.bsky.feed.post"; + let record_count = 64usize; + let now_ms = Utc::now().timestamp_millis(); + + let rkeys: Vec = (0..record_count) + .map(|i| format!("gc_merge_{}_{:04}", now_ms, i)) + .collect(); + + let create_results = + futures::future::try_join_all(rkeys.iter().enumerate().map(|(i, rkey)| { + let client = client.clone(); + let jwt = jwt.clone(); + let did = did.clone(); + let base = base.to_string(); + let payload = json!({ + "repo": did, + "collection": collection, + "rkey": rkey, + "record": { + "$type": collection, + "text": format!("seed record {}", i), + "createdAt": Utc::now().to_rfc3339() + } + }); + async move { + let res = client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) + .bearer_auth(&jwt) + .json(&payload) + .send() + .await + .expect("Failed to send createRecord"); + if res.status() != StatusCode::OK { + return Err(format!("seed createRecord failed: {}", res.status())); + } + Ok::<(), String>(()) + } + })) + .await; + create_results.expect("seeding records failed"); + + let count_after_seed = repos + .repo + .count_user_blocks(user_id) + .await + .expect("count_user_blocks failed"); + + let target_rkey = &rkeys[record_count / 2]; + let delete_payload = json!({ + "repo": did, + "collection": collection, + "rkey": target_rkey, + }); + let delete_res = client + .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base)) + .bearer_auth(&jwt) + .json(&delete_payload) + .send() + .await + .expect("Failed to send deleteRecord"); + assert_eq!( + delete_res.status(), + StatusCode::OK, + "deleteRecord did not return 200: {:?}", + delete_res.text().await + ); + + let count_after_delete = repos + .repo + .count_user_blocks(user_id) + .await + .expect("count_user_blocks failed"); + assert!( + count_after_delete < count_after_seed, + "user_blocks did not shrink after deleting from a populated repo \ + (after_seed={}, after_delete={}). The path-walk-based obsolete \ + calculation does not capture sibling subtree blocks orphaned by \ + delete-merge; only an MST-diff-based calculation does.", + count_after_seed, + count_after_delete + ); + + let get_res = client + .get(format!("{}/xrpc/com.atproto.repo.getRecord", base)) + .query(&[ + ("repo", did.as_str()), + ("collection", collection), + ("rkey", target_rkey.as_str()), + ]) + .send() + .await + .expect("Failed to send getRecord"); + assert!( + !get_res.status().is_success(), + "deleted record is still resolvable via getRecord (status={})", + get_res.status(), + ); +} + +#[tokio::test] +async fn test_delete_decrements_tranquil_store_refcounts() { + if !is_store_backend() { + eprintln!( + "skipping test_delete_decrements_tranquil_store_refcounts: \ + only meaningful with the tranquil-store backend" + ); + return; + } + + let client = client(); + let base = base_url().await; + let block_store = get_test_block_store().await; + let store = block_store + .as_tranquil_store() + .expect("tranquil-store backend selected but block_store is not TranquilStore"); + let (did, jwt) = setup_new_user("gc-store-decrement").await; + + let collection = "app.bsky.feed.post"; + let rkey = format!("gc_store_{}", Utc::now().timestamp_millis()); + + let create_res = client + .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) + .bearer_auth(&jwt) + .json(&json!({ + "repo": did, + "collection": collection, + "rkey": rkey, + "record": { + "$type": collection, + "text": "destined for refcount decrement", + "createdAt": Utc::now().to_rfc3339() + } + })) + .send() + .await + .expect("Failed to send createRecord"); + assert_eq!(create_res.status(), StatusCode::OK, "createRecord failed"); + let create_body: Value = create_res.json().await.expect("createRecord not JSON"); + let record_cid_str = create_body["cid"] + .as_str() + .expect("createRecord response missing cid") + .to_string(); + let record_cid = cid::Cid::try_from(record_cid_str.as_str()).expect("invalid record cid"); + + let refcount_after_create = store + .refcount_of(&record_cid) + .expect("refcount_of failed") + .expect("record cid not in blockstore index after create"); + assert!( + refcount_after_create > 0, + "record cid had refcount 0 immediately after create (cid={})", + record_cid_str + ); + + let delete_res = client + .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base)) + .bearer_auth(&jwt) + .json(&json!({ + "repo": did, + "collection": collection, + "rkey": rkey, + })) + .send() + .await + .expect("Failed to send deleteRecord"); + assert_eq!( + delete_res.status(), + StatusCode::OK, + "deleteRecord did not return 200: {:?}", + delete_res.text().await + ); + + let refcount_after_delete = store + .refcount_of(&record_cid) + .expect("refcount_of failed") + .expect("record cid slot vanished entirely after delete"); + assert_eq!( + refcount_after_delete, 0, + "record cid still has nonzero refcount after deleteRecord \ + (cid={}, before_delete={}, after_delete={}). The hash_index \ + decrement that drives on-disk reclamation is the regression \ + this test guards against.", + record_cid_str, refcount_after_create, refcount_after_delete + ); +} diff --git a/crates/tranquil-pds/tests/store_parity.rs b/crates/tranquil-pds/tests/store_parity.rs index e443537..93a1365 100644 --- a/crates/tranquil-pds/tests/store_parity.rs +++ b/crates/tranquil-pds/tests/store_parity.rs @@ -36,6 +36,7 @@ async fn create_store_repos() -> Arc { index_dir: bs_index, max_file_size: tranquil_store::blockstore::DEFAULT_MAX_FILE_SIZE, group_commit: Default::default(), + shard_count: 1, }) .expect("blockstore open"); @@ -66,7 +67,7 @@ async fn create_store_repos() -> Arc { Some(2), )); - let client = MetastoreClient::::new(pool); + let client = MetastoreClient::::new(pool, Arc::clone(&event_log)); Arc::new(PostgresRepositories { pool: None, @@ -137,18 +138,39 @@ fn test_at_uri(did: &Did, collection: &Nsid, rkey: &Rkey) -> AtUri { .unwrap() } -async fn seed_repo( - repos: &PostgresRepositories, - did: &Did, - handle: &Handle, - root_cid: &CidLink, - user_id: Uuid, -) { +async fn seed_user(repos: &PostgresRepositories, did: &Did, handle: &Handle) -> Uuid { + let commit_cid = helpers::make_cid(did.as_str().as_bytes()).to_string(); + let input = tranquil_db_traits::CreatePasswordAccountInput { + handle: handle.clone(), + email: None, + did: did.clone(), + password_hash: "parity-test-hash".to_string(), + preferred_comms_channel: CommsChannel::Email, + discord_username: None, + telegram_username: None, + signal_username: None, + deactivated_at: None, + encrypted_key_bytes: vec![0u8; 32], + encryption_version: 0, + reserved_key_id: None, + commit_cid, + repo_rev: "rev0".to_string(), + genesis_block_cids: vec![], + invite_code: None, + birthdate_pref: None, + }; repos - .repo - .create_repo(user_id, did, handle, root_cid, "rev0") + .user + .create_password_account(&input) .await - .unwrap(); + .unwrap() + .user_id +} + +async fn seed_repos(f: &ParityFixture, did: &Did, handle: &Handle) -> (Uuid, Uuid) { + let pg_uid = seed_user(&f.pg, did, handle).await; + let store_uid = seed_user(&f.store, did, handle).await; + (pg_uid, store_uid) } async fn seed_records( @@ -211,14 +233,11 @@ async fn parity_health_check() { #[tokio::test] async fn parity_rkey_sort_order() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("rkey"); let handle = test_handle("rkey"); - let root_cid = test_cid(0); let collection = test_nsid("post"); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let records: Vec<(Rkey, CidLink)> = (0u8..10) .map(|i| { @@ -228,18 +247,18 @@ async fn parity_rkey_sort_order() { }) .collect(); - seed_records(&f.pg, uid, &collection, &records).await; - seed_records(&f.store, uid, &collection, &records).await; + seed_records(&f.pg, pg_uid, &collection, &records).await; + seed_records(&f.store, store_uid, &collection, &records).await; let pg_fwd = f.pg.repo - .list_records(uid, &collection, None, 100, false, None, None) + .list_records(pg_uid, &collection, None, 100, false, None, None) .await .unwrap(); let store_fwd = f .store .repo - .list_records(uid, &collection, None, 100, false, None, None) + .list_records(store_uid, &collection, None, 100, false, None, None) .await .unwrap(); @@ -249,13 +268,13 @@ async fn parity_rkey_sort_order() { let pg_rev = f.pg.repo - .list_records(uid, &collection, None, 100, true, None, None) + .list_records(pg_uid, &collection, None, 100, true, None, None) .await .unwrap(); let store_rev = f .store .repo - .list_records(uid, &collection, None, 100, true, None, None) + .list_records(store_uid, &collection, None, 100, true, None, None) .await .unwrap(); @@ -271,14 +290,11 @@ async fn parity_rkey_sort_order() { #[tokio::test] async fn parity_cursor_pagination() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("cursor"); let handle = test_handle("cursor"); - let root_cid = test_cid(0); let collection = test_nsid("post"); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let records: Vec<(Rkey, CidLink)> = (0u8..20) .map(|i| { @@ -288,8 +304,8 @@ async fn parity_cursor_pagination() { }) .collect(); - seed_records(&f.pg, uid, &collection, &records).await; - seed_records(&f.store, uid, &collection, &records).await; + seed_records(&f.pg, pg_uid, &collection, &records).await; + seed_records(&f.store, store_uid, &collection, &records).await; let mut pg_all = Vec::new(); let mut store_all = Vec::new(); @@ -302,7 +318,7 @@ async fn parity_cursor_pagination() { let pg_page = f.pg.repo .list_records( - uid, + pg_uid, &collection, pg_cursor.as_ref(), limit, @@ -316,7 +332,7 @@ async fn parity_cursor_pagination() { .store .repo .list_records( - uid, + store_uid, &collection, store_cursor.as_ref(), limit, @@ -359,14 +375,11 @@ async fn parity_cursor_pagination() { #[tokio::test] async fn parity_cursor_pagination_reverse() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("currev"); let handle = test_handle("currev"); - let root_cid = test_cid(0); let collection = test_nsid("post"); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let records: Vec<(Rkey, CidLink)> = (0u8..15) .map(|i| { @@ -376,8 +389,8 @@ async fn parity_cursor_pagination_reverse() { }) .collect(); - seed_records(&f.pg, uid, &collection, &records).await; - seed_records(&f.store, uid, &collection, &records).await; + seed_records(&f.pg, pg_uid, &collection, &records).await; + seed_records(&f.store, store_uid, &collection, &records).await; let mut pg_all = Vec::new(); let mut store_all = Vec::new(); @@ -389,7 +402,7 @@ async fn parity_cursor_pagination_reverse() { let pg_page = f.pg.repo .list_records( - uid, + pg_uid, &collection, pg_cursor.as_ref(), limit, @@ -403,7 +416,7 @@ async fn parity_cursor_pagination_reverse() { .store .repo .list_records( - uid, + store_uid, &collection, store_cursor.as_ref(), limit, @@ -436,14 +449,11 @@ async fn parity_cursor_pagination_reverse() { #[tokio::test] async fn parity_rkey_range_query() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("range"); let handle = test_handle("range"); - let root_cid = test_cid(0); let collection = test_nsid("post"); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let records: Vec<(Rkey, CidLink)> = (0u8..10) .map(|i| { @@ -453,21 +463,37 @@ async fn parity_rkey_range_query() { }) .collect(); - seed_records(&f.pg, uid, &collection, &records).await; - seed_records(&f.store, uid, &collection, &records).await; + seed_records(&f.pg, pg_uid, &collection, &records).await; + seed_records(&f.store, store_uid, &collection, &records).await; let start = test_rkey("3l03aaaaaaaaa"); let end = test_rkey("3l07aaaaaaaaa"); let pg_range = f.pg.repo - .list_records(uid, &collection, None, 100, false, Some(&start), Some(&end)) + .list_records( + pg_uid, + &collection, + None, + 100, + false, + Some(&start), + Some(&end), + ) .await .unwrap(); let store_range = f .store .repo - .list_records(uid, &collection, None, 100, false, Some(&start), Some(&end)) + .list_records( + store_uid, + &collection, + None, + 100, + false, + Some(&start), + Some(&end), + ) .await .unwrap(); @@ -479,13 +505,10 @@ async fn parity_rkey_range_query() { #[tokio::test] async fn parity_collection_listing() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("colls"); let handle = test_handle("colls"); - let root_cid = test_cid(0); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let post_ns = test_nsid("post"); let like_ns = Nsid::new("app.bsky.feed.like").unwrap(); @@ -497,19 +520,19 @@ async fn parity_collection_listing() { let repost_records = vec![(test_rkey("3laaaaaaaaa03"), test_cid(3))]; let follow_records = vec![(test_rkey("3laaaaaaaaa04"), test_cid(4))]; - seed_records(&f.pg, uid, &post_ns, &post_records).await; - seed_records(&f.pg, uid, &like_ns, &like_records).await; - seed_records(&f.pg, uid, &repost_ns, &repost_records).await; - seed_records(&f.pg, uid, &follow_ns, &follow_records).await; + seed_records(&f.pg, pg_uid, &post_ns, &post_records).await; + seed_records(&f.pg, pg_uid, &like_ns, &like_records).await; + seed_records(&f.pg, pg_uid, &repost_ns, &repost_records).await; + seed_records(&f.pg, pg_uid, &follow_ns, &follow_records).await; - seed_records(&f.store, uid, &post_ns, &post_records).await; - seed_records(&f.store, uid, &like_ns, &like_records).await; - seed_records(&f.store, uid, &repost_ns, &repost_records).await; - seed_records(&f.store, uid, &follow_ns, &follow_records).await; + seed_records(&f.store, store_uid, &post_ns, &post_records).await; + seed_records(&f.store, store_uid, &like_ns, &like_records).await; + seed_records(&f.store, store_uid, &repost_ns, &repost_records).await; + seed_records(&f.store, store_uid, &follow_ns, &follow_records).await; let mut pg_colls: Vec = f.pg.repo - .list_collections(uid) + .list_collections(pg_uid) .await .unwrap() .into_iter() @@ -520,7 +543,7 @@ async fn parity_collection_listing() { let mut store_colls: Vec = f .store .repo - .list_collections(uid) + .list_collections(store_uid) .await .unwrap() .into_iter() @@ -531,8 +554,8 @@ async fn parity_collection_listing() { assert_eq!(pg_colls, store_colls, "collection listing mismatch"); assert_eq!(pg_colls.len(), 4); - let pg_count = f.pg.repo.count_records(uid).await.unwrap(); - let store_count = f.store.repo.count_records(uid).await.unwrap(); + let pg_count = f.pg.repo.count_records(pg_uid).await.unwrap(); + let store_count = f.store.repo.count_records(store_uid).await.unwrap(); assert_eq!(pg_count, store_count, "record count mismatch"); assert_eq!(pg_count, 4); } @@ -540,53 +563,64 @@ async fn parity_collection_listing() { #[tokio::test] async fn parity_record_get_and_delete() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("getdel"); let handle = test_handle("getdel"); - let root_cid = test_cid(0); let collection = test_nsid("post"); let rkey = test_rkey("3laaaaaaaaa01"); let cid = test_cid(1); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; - seed_records(&f.pg, uid, &collection, &[(rkey.clone(), cid.clone())]).await; - seed_records(&f.store, uid, &collection, &[(rkey.clone(), cid.clone())]).await; + seed_records(&f.pg, pg_uid, &collection, &[(rkey.clone(), cid.clone())]).await; + seed_records( + &f.store, + store_uid, + &collection, + &[(rkey.clone(), cid.clone())], + ) + .await; let pg_cid = f.pg.repo - .get_record_cid(uid, &collection, &rkey) + .get_record_cid(pg_uid, &collection, &rkey) .await .unwrap(); let store_cid = f .store .repo - .get_record_cid(uid, &collection, &rkey) + .get_record_cid(store_uid, &collection, &rkey) .await .unwrap(); assert_eq!(pg_cid, store_cid, "get_record_cid mismatch"); assert!(pg_cid.is_some()); f.pg.repo - .delete_records(uid, &[collection.clone()], &[rkey.clone()]) + .delete_records( + pg_uid, + std::slice::from_ref(&collection), + std::slice::from_ref(&rkey), + ) .await .unwrap(); f.store .repo - .delete_records(uid, &[collection.clone()], &[rkey.clone()]) + .delete_records( + store_uid, + std::slice::from_ref(&collection), + std::slice::from_ref(&rkey), + ) .await .unwrap(); let pg_gone = f.pg.repo - .get_record_cid(uid, &collection, &rkey) + .get_record_cid(pg_uid, &collection, &rkey) .await .unwrap(); let store_gone = f .store .repo - .get_record_cid(uid, &collection, &rkey) + .get_record_cid(store_uid, &collection, &rkey) .await .unwrap(); assert_eq!(pg_gone, None); @@ -596,15 +630,12 @@ async fn parity_record_get_and_delete() { #[tokio::test] async fn parity_backlink_queries() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("blink"); let handle = test_handle("blink"); - let root_cid = test_cid(0); let like_ns = Nsid::new("app.bsky.feed.like").unwrap(); let target_did = test_did("target"); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let rkey1 = test_rkey("3laaaaaaaaa01"); let rkey2 = test_rkey("3laaaaaaaaa02"); @@ -628,10 +659,13 @@ async fn parity_backlink_queries() { }, ]; - f.pg.backlink.add_backlinks(uid, &backlinks).await.unwrap(); + f.pg.backlink + .add_backlinks(pg_uid, &backlinks) + .await + .unwrap(); f.store .backlink - .add_backlinks(uid, &backlinks) + .add_backlinks(store_uid, &backlinks) .await .unwrap(); @@ -643,13 +677,13 @@ async fn parity_backlink_queries() { let pg_conflicts = f.pg.backlink - .get_backlink_conflicts(uid, &like_ns, &[conflict_backlink.clone()]) + .get_backlink_conflicts(pg_uid, &like_ns, std::slice::from_ref(&conflict_backlink)) .await .unwrap(); let store_conflicts = f .store .backlink - .get_backlink_conflicts(uid, &like_ns, &[conflict_backlink]) + .get_backlink_conflicts(store_uid, &like_ns, &[conflict_backlink]) .await .unwrap(); @@ -674,13 +708,13 @@ async fn parity_backlink_queries() { let pg_after = f.pg.backlink - .get_backlink_conflicts(uid, &like_ns, &[post_removal.clone()]) + .get_backlink_conflicts(pg_uid, &like_ns, std::slice::from_ref(&post_removal)) .await .unwrap(); let store_after = f .store .backlink - .get_backlink_conflicts(uid, &like_ns, &[post_removal]) + .get_backlink_conflicts(store_uid, &like_ns, &[post_removal]) .await .unwrap(); @@ -694,14 +728,11 @@ async fn parity_backlink_queries() { #[tokio::test] async fn parity_backlink_remove_by_repo() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("blrep"); let handle = test_handle("blrep"); - let root_cid = test_cid(0); let like_ns = Nsid::new("app.bsky.feed.like").unwrap(); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let rkey = test_rkey("3laaaaaaaaa01"); let uri = test_at_uri(&did, &like_ns, &rkey); @@ -711,17 +742,23 @@ async fn parity_backlink_remove_by_repo() { link_to: "at://did:plc:sometarget/app.bsky.feed.post/abc".to_owned(), }]; - f.pg.backlink.add_backlinks(uid, &backlinks).await.unwrap(); + f.pg.backlink + .add_backlinks(pg_uid, &backlinks) + .await + .unwrap(); f.store .backlink - .add_backlinks(uid, &backlinks) + .add_backlinks(store_uid, &backlinks) .await .unwrap(); - f.pg.backlink.remove_backlinks_by_repo(uid).await.unwrap(); + f.pg.backlink + .remove_backlinks_by_repo(pg_uid) + .await + .unwrap(); f.store .backlink - .remove_backlinks_by_repo(uid) + .remove_backlinks_by_repo(store_uid) .await .unwrap(); @@ -732,29 +769,26 @@ async fn parity_backlink_remove_by_repo() { }; let pg_after = f.pg.backlink - .get_backlink_conflicts(uid, &like_ns, &[probe.clone()]) + .get_backlink_conflicts(pg_uid, &like_ns, std::slice::from_ref(&probe)) .await .unwrap(); let store_after = f .store .backlink - .get_backlink_conflicts(uid, &like_ns, &[probe]) + .get_backlink_conflicts(store_uid, &like_ns, &[probe]) .await .unwrap(); assert_eq!(pg_after.len(), 0); assert_eq!(store_after.len(), 0); } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] async fn parity_blob_metadata() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("blob"); let handle = test_handle("blob"); - let root_cid = test_cid(0); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let blob_cid1 = test_cid(101); let blob_cid2 = test_cid(102); @@ -776,12 +810,12 @@ async fn parity_blob_metadata() { tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { pg.blob - .insert_blob(&cid, &mime, size, uid, &key) + .insert_blob(&cid, &mime, size, pg_uid, &key) .await .unwrap(); store .blob - .insert_blob(&cid, &mime, size, uid, &key) + .insert_blob(&cid, &mime, size, store_uid, &key) .await .unwrap(); }); @@ -809,31 +843,32 @@ async fn parity_blob_metadata() { let store_key = f.store.blob.get_blob_storage_key(&blob_cid2).await.unwrap(); assert_eq!(pg_key, store_key); - let pg_count = f.pg.blob.count_blobs_by_user(uid).await.unwrap(); - let store_count = f.store.blob.count_blobs_by_user(uid).await.unwrap(); + let pg_count = f.pg.blob.count_blobs_by_user(pg_uid).await.unwrap(); + let store_count = f.store.blob.count_blobs_by_user(store_uid).await.unwrap(); assert_eq!(pg_count, store_count); assert_eq!(pg_count, 3); - let pg_list = f.pg.blob.list_blobs_by_user(uid, None, 100).await.unwrap(); + let pg_list = + f.pg.blob + .list_blobs_by_user(pg_uid, None, 100) + .await + .unwrap(); let store_list = f .store .blob - .list_blobs_by_user(uid, None, 100) + .list_blobs_by_user(store_uid, None, 100) .await .unwrap(); assert_eq!(pg_list.len(), store_list.len()); } -#[tokio::test] +#[tokio::test(flavor = "multi_thread")] async fn parity_blob_pagination() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("blobpg"); let handle = test_handle("blobpg"); - let root_cid = test_cid(0); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; (0u8..8).for_each(|i| { let cid = test_cid(200 + i); @@ -847,7 +882,7 @@ async fn parity_blob_pagination() { &cid, "application/octet-stream", 512 * (i as i64 + 1), - uid, + pg_uid, &key, ) .await @@ -858,7 +893,7 @@ async fn parity_blob_pagination() { &cid, "application/octet-stream", 512 * (i as i64 + 1), - uid, + store_uid, &key, ) .await @@ -876,13 +911,13 @@ async fn parity_blob_pagination() { loop { let pg_page = f.pg.blob - .list_blobs_by_user(uid, pg_cursor.as_deref(), limit) + .list_blobs_by_user(pg_uid, pg_cursor.as_deref(), limit) .await .unwrap(); let store_page = f .store .blob - .list_blobs_by_user(uid, store_cursor.as_deref(), limit) + .list_blobs_by_user(store_uid, store_cursor.as_deref(), limit) .await .unwrap(); @@ -910,38 +945,35 @@ async fn parity_blob_pagination() { #[tokio::test] async fn parity_blob_duplicate_insert() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("blobdup"); let handle = test_handle("blobdup"); - let root_cid = test_cid(0); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let cid = test_cid(150); let pg_first = f.pg.blob - .insert_blob(&cid, "image/png", 1024, uid, "blobs/dup.png") + .insert_blob(&cid, "image/png", 1024, pg_uid, "blobs/dup.png") .await .unwrap(); let store_first = f .store .blob - .insert_blob(&cid, "image/png", 1024, uid, "blobs/dup.png") + .insert_blob(&cid, "image/png", 1024, store_uid, "blobs/dup.png") .await .unwrap(); assert_eq!(pg_first, store_first); let pg_dup = f.pg.blob - .insert_blob(&cid, "image/png", 1024, uid, "blobs/dup.png") + .insert_blob(&cid, "image/png", 1024, pg_uid, "blobs/dup.png") .await .unwrap(); let store_dup = f .store .blob - .insert_blob(&cid, "image/png", 1024, uid, "blobs/dup.png") + .insert_blob(&cid, "image/png", 1024, store_uid, "blobs/dup.png") .await .unwrap(); assert_eq!(pg_dup, store_dup); @@ -950,13 +982,10 @@ async fn parity_blob_duplicate_insert() { #[tokio::test] async fn parity_get_all_records() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("allrec"); let handle = test_handle("allrec"); - let root_cid = test_cid(0); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let post_ns = test_nsid("post"); let like_ns = Nsid::new("app.bsky.feed.like").unwrap(); @@ -967,13 +996,13 @@ async fn parity_get_all_records() { ]; let likes = vec![(test_rkey("3laaaaaaaaa03"), test_cid(3))]; - seed_records(&f.pg, uid, &post_ns, &posts).await; - seed_records(&f.pg, uid, &like_ns, &likes).await; - seed_records(&f.store, uid, &post_ns, &posts).await; - seed_records(&f.store, uid, &like_ns, &likes).await; + seed_records(&f.pg, pg_uid, &post_ns, &posts).await; + seed_records(&f.pg, pg_uid, &like_ns, &likes).await; + seed_records(&f.store, store_uid, &post_ns, &posts).await; + seed_records(&f.store, store_uid, &like_ns, &likes).await; - let mut pg_all = f.pg.repo.get_all_records(uid).await.unwrap(); - let mut store_all = f.store.repo.get_all_records(uid).await.unwrap(); + let mut pg_all = f.pg.repo.get_all_records(pg_uid).await.unwrap(); + let mut store_all = f.store.repo.get_all_records(store_uid).await.unwrap(); pg_all.sort_by(|a, b| { a.collection @@ -999,12 +1028,14 @@ async fn parity_get_all_records() { #[tokio::test] async fn parity_comms_queue() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); + let did = test_did("comms"); + let handle = test_handle("comms"); + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let pg_id = f.pg.infra .enqueue_comms( - Some(uid), + Some(pg_uid), CommsChannel::Email, CommsType::Welcome, "test@example.com", @@ -1019,7 +1050,7 @@ async fn parity_comms_queue() { .store .infra .enqueue_comms( - Some(uid), + Some(store_uid), CommsChannel::Email, CommsType::Welcome, "test@example.com", @@ -1035,13 +1066,13 @@ async fn parity_comms_queue() { let pg_latest = f.pg.infra - .get_latest_comms_for_user(uid, CommsType::Welcome, 10) + .get_latest_comms_for_user(pg_uid, CommsType::Welcome, 10) .await .unwrap(); let store_latest = f .store .infra - .get_latest_comms_for_user(uid, CommsType::Welcome, 10) + .get_latest_comms_for_user(store_uid, CommsType::Welcome, 10) .await .unwrap(); @@ -1050,13 +1081,13 @@ async fn parity_comms_queue() { let pg_count = f.pg.infra - .count_comms_by_type(uid, CommsType::Welcome) + .count_comms_by_type(pg_uid, CommsType::Welcome) .await .unwrap(); let store_count = f .store .infra - .count_comms_by_type(uid, CommsType::Welcome) + .count_comms_by_type(store_uid, CommsType::Welcome) .await .unwrap(); assert_eq!(pg_count, store_count); @@ -1066,13 +1097,20 @@ async fn parity_comms_queue() { #[tokio::test] async fn parity_invite_codes() { let f = ParityFixture::new().await; + let did = test_did("invite"); + let handle = test_handle("invite"); + let _ = seed_repos(&f, &did, &handle).await; let code = format!("parity-invite-{}", Uuid::new_v4()); - let pg_created = f.pg.infra.create_invite_code(&code, 5, None).await.unwrap(); + let pg_created = + f.pg.infra + .create_invite_code(&code, 5, Some(&did)) + .await + .unwrap(); let store_created = f .store .infra - .create_invite_code(&code, 5, None) + .create_invite_code(&code, 5, Some(&did)) .await .unwrap(); assert_eq!(pg_created, store_created); @@ -1095,13 +1133,10 @@ async fn parity_invite_codes() { #[tokio::test] async fn parity_account_preferences() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("prefs"); let handle = test_handle("prefs"); - let root_cid = test_cid(0); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let pref_value = serde_json::json!({ "$type": "app.bsky.actor.defs#adultContentPref", @@ -1110,7 +1145,7 @@ async fn parity_account_preferences() { f.pg.infra .upsert_account_preference( - uid, + pg_uid, "app.bsky.actor.defs#adultContentPref/0", pref_value.clone(), ) @@ -1118,12 +1153,21 @@ async fn parity_account_preferences() { .unwrap(); f.store .infra - .upsert_account_preference(uid, "app.bsky.actor.defs#adultContentPref/0", pref_value) + .upsert_account_preference( + store_uid, + "app.bsky.actor.defs#adultContentPref/0", + pref_value, + ) .await .unwrap(); - let mut pg_prefs = f.pg.infra.get_account_preferences(uid).await.unwrap(); - let mut store_prefs = f.store.infra.get_account_preferences(uid).await.unwrap(); + let mut pg_prefs = f.pg.infra.get_account_preferences(pg_uid).await.unwrap(); + let mut store_prefs = f + .store + .infra + .get_account_preferences(store_uid) + .await + .unwrap(); pg_prefs.sort_by(|a, b| a.0.cmp(&b.0)); store_prefs.sort_by(|a, b| a.0.cmp(&b.0)); @@ -1138,31 +1182,40 @@ async fn parity_account_preferences() { #[tokio::test] async fn parity_record_upsert_overwrites() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("upsert"); let handle = test_handle("upsert"); - let root_cid = test_cid(0); let collection = test_nsid("post"); let rkey = test_rkey("3laaaaaaaaa01"); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let cid_v1 = test_cid(1); - seed_records(&f.pg, uid, &collection, &[(rkey.clone(), cid_v1.clone())]).await; + seed_records( + &f.pg, + pg_uid, + &collection, + &[(rkey.clone(), cid_v1.clone())], + ) + .await; seed_records( &f.store, - uid, + store_uid, &collection, &[(rkey.clone(), cid_v1.clone())], ) .await; let cid_v2 = test_cid(2); - seed_records(&f.pg, uid, &collection, &[(rkey.clone(), cid_v2.clone())]).await; + seed_records( + &f.pg, + pg_uid, + &collection, + &[(rkey.clone(), cid_v2.clone())], + ) + .await; seed_records( &f.store, - uid, + store_uid, &collection, &[(rkey.clone(), cid_v2.clone())], ) @@ -1170,20 +1223,20 @@ async fn parity_record_upsert_overwrites() { let pg_cid = f.pg.repo - .get_record_cid(uid, &collection, &rkey) + .get_record_cid(pg_uid, &collection, &rkey) .await .unwrap(); let store_cid = f .store .repo - .get_record_cid(uid, &collection, &rkey) + .get_record_cid(store_uid, &collection, &rkey) .await .unwrap(); assert_eq!(pg_cid, store_cid); assert_eq!(pg_cid.unwrap().as_str(), cid_v2.as_str()); - let pg_count = f.pg.repo.count_records(uid).await.unwrap(); - let store_count = f.store.repo.count_records(uid).await.unwrap(); + let pg_count = f.pg.repo.count_records(pg_uid).await.unwrap(); + let store_count = f.store.repo.count_records(store_uid).await.unwrap(); assert_eq!(pg_count, 1); assert_eq!(store_count, 1); } @@ -1191,44 +1244,45 @@ async fn parity_record_upsert_overwrites() { #[tokio::test] async fn parity_empty_queries() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("empty"); let handle = test_handle("empty"); - let root_cid = test_cid(0); let collection = test_nsid("post"); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let pg_records = f.pg.repo - .list_records(uid, &collection, None, 100, false, None, None) + .list_records(pg_uid, &collection, None, 100, false, None, None) .await .unwrap(); let store_records = f .store .repo - .list_records(uid, &collection, None, 100, false, None, None) + .list_records(store_uid, &collection, None, 100, false, None, None) .await .unwrap(); assert_eq!(pg_records.len(), 0); assert_eq!(store_records.len(), 0); - let pg_colls = f.pg.repo.list_collections(uid).await.unwrap(); - let store_colls = f.store.repo.list_collections(uid).await.unwrap(); + let pg_colls = f.pg.repo.list_collections(pg_uid).await.unwrap(); + let store_colls = f.store.repo.list_collections(store_uid).await.unwrap(); assert_eq!(pg_colls.len(), 0); assert_eq!(store_colls.len(), 0); - let pg_count = f.pg.repo.count_records(uid).await.unwrap(); - let store_count = f.store.repo.count_records(uid).await.unwrap(); + let pg_count = f.pg.repo.count_records(pg_uid).await.unwrap(); + let store_count = f.store.repo.count_records(store_uid).await.unwrap(); assert_eq!(pg_count, 0); assert_eq!(store_count, 0); - let pg_blobs = f.pg.blob.list_blobs_by_user(uid, None, 100).await.unwrap(); + let pg_blobs = + f.pg.blob + .list_blobs_by_user(pg_uid, None, 100) + .await + .unwrap(); let store_blobs = f .store .blob - .list_blobs_by_user(uid, None, 100) + .list_blobs_by_user(store_uid, None, 100) .await .unwrap(); assert_eq!(pg_blobs.len(), 0); @@ -1249,6 +1303,8 @@ async fn parity_empty_queries() { async fn parity_deletion_requests() { let f = ParityFixture::new().await; let did = test_did("delreq"); + let handle = test_handle("delreq"); + let _ = seed_repos(&f, &did, &handle).await; let token = format!("del-token-{}", Uuid::new_v4()); let expires = chrono::Utc::now() + chrono::Duration::hours(24); @@ -1345,13 +1401,10 @@ async fn parity_signing_key_reservation() { #[tokio::test] async fn parity_repo_root_operations() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("root"); let handle = test_handle("root"); - let root_cid = test_cid(0); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let pg_root = f.pg.repo.get_repo_root_by_did(&did).await.unwrap(); let store_root = f.store.repo.get_repo_root_by_did(&did).await.unwrap(); @@ -1359,12 +1412,12 @@ async fn parity_repo_root_operations() { let new_root = test_cid(99); f.pg.repo - .update_repo_root(uid, &new_root, "rev1") + .update_repo_root(pg_uid, &new_root, "rev1") .await .unwrap(); f.store .repo - .update_repo_root(uid, &new_root, "rev1") + .update_repo_root(store_uid, &new_root, "rev1") .await .unwrap(); @@ -1373,8 +1426,8 @@ async fn parity_repo_root_operations() { assert_eq!(pg_updated, store_updated); assert_eq!(pg_updated.unwrap().as_str(), new_root.as_str()); - let pg_info = f.pg.repo.get_repo(uid).await.unwrap().unwrap(); - let store_info = f.store.repo.get_repo(uid).await.unwrap().unwrap(); + let pg_info = f.pg.repo.get_repo(pg_uid).await.unwrap().unwrap(); + let store_info = f.store.repo.get_repo(store_uid).await.unwrap().unwrap(); assert_eq!(pg_info.repo_rev, store_info.repo_rev); assert_eq!( pg_info.repo_root_cid.as_str(), @@ -1385,32 +1438,29 @@ async fn parity_repo_root_operations() { #[tokio::test] async fn parity_delete_all_records() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("delall"); let handle = test_handle("delall"); - let root_cid = test_cid(0); let collection = test_nsid("post"); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let records: Vec<(Rkey, CidLink)> = (0u8..5) .map(|i| (test_rkey(&format!("3l{:02}aaaaaaaaa", i)), test_cid(i + 1))) .collect(); - seed_records(&f.pg, uid, &collection, &records).await; - seed_records(&f.store, uid, &collection, &records).await; + seed_records(&f.pg, pg_uid, &collection, &records).await; + seed_records(&f.store, store_uid, &collection, &records).await; - f.pg.repo.delete_all_records(uid).await.unwrap(); - f.store.repo.delete_all_records(uid).await.unwrap(); + f.pg.repo.delete_all_records(pg_uid).await.unwrap(); + f.store.repo.delete_all_records(store_uid).await.unwrap(); - let pg_count = f.pg.repo.count_records(uid).await.unwrap(); - let store_count = f.store.repo.count_records(uid).await.unwrap(); + let pg_count = f.pg.repo.count_records(pg_uid).await.unwrap(); + let store_count = f.store.repo.count_records(store_uid).await.unwrap(); assert_eq!(pg_count, 0); assert_eq!(store_count, 0); - let pg_colls = f.pg.repo.list_collections(uid).await.unwrap(); - let store_colls = f.store.repo.list_collections(uid).await.unwrap(); + let pg_colls = f.pg.repo.list_collections(pg_uid).await.unwrap(); + let store_colls = f.store.repo.list_collections(store_uid).await.unwrap(); assert_eq!(pg_colls.len(), 0); assert_eq!(store_colls.len(), 0); } @@ -1418,32 +1468,33 @@ async fn parity_delete_all_records() { #[tokio::test] async fn parity_plc_tokens() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("plctok"); let handle = test_handle("plctok"); - let root_cid = test_cid(0); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let token = format!("plc-{}", Uuid::new_v4()); let expires = chrono::Utc::now() + chrono::Duration::hours(1); f.pg.infra - .insert_plc_token(uid, &token, expires) + .insert_plc_token(pg_uid, &token, expires) .await .unwrap(); f.store .infra - .insert_plc_token(uid, &token, expires) + .insert_plc_token(store_uid, &token, expires) .await .unwrap(); - let pg_expiry = f.pg.infra.get_plc_token_expiry(uid, &token).await.unwrap(); + let pg_expiry = + f.pg.infra + .get_plc_token_expiry(pg_uid, &token) + .await + .unwrap(); let store_expiry = f .store .infra - .get_plc_token_expiry(uid, &token) + .get_plc_token_expiry(store_uid, &token) .await .unwrap(); assert!(pg_expiry.is_some()); @@ -1458,14 +1509,22 @@ async fn parity_plc_tokens() { assert_eq!(pg_count, store_count); assert_eq!(pg_count, 1); - f.pg.infra.delete_plc_token(uid, &token).await.unwrap(); - f.store.infra.delete_plc_token(uid, &token).await.unwrap(); + f.pg.infra.delete_plc_token(pg_uid, &token).await.unwrap(); + f.store + .infra + .delete_plc_token(store_uid, &token) + .await + .unwrap(); - let pg_gone = f.pg.infra.get_plc_token_expiry(uid, &token).await.unwrap(); + let pg_gone = + f.pg.infra + .get_plc_token_expiry(pg_uid, &token) + .await + .unwrap(); let store_gone = f .store .infra - .get_plc_token_expiry(uid, &token) + .get_plc_token_expiry(store_uid, &token) .await .unwrap(); assert!(pg_gone.is_none()); @@ -1475,22 +1534,19 @@ async fn parity_plc_tokens() { #[tokio::test] async fn parity_blob_delete_and_takedown() { let f = ParityFixture::new().await; - let uid = Uuid::new_v4(); let did = test_did("blobdel"); let handle = test_handle("blobdel"); - let root_cid = test_cid(0); - seed_repo(&f.pg, &did, &handle, &root_cid, uid).await; - seed_repo(&f.store, &did, &handle, &root_cid, uid).await; + let (pg_uid, store_uid) = seed_repos(&f, &did, &handle).await; let cid = test_cid(180); f.pg.blob - .insert_blob(&cid, "image/png", 1024, uid, "blobs/td.png") + .insert_blob(&cid, "image/png", 1024, pg_uid, "blobs/td.png") .await .unwrap(); f.store .blob - .insert_blob(&cid, "image/png", 1024, uid, "blobs/td.png") + .insert_blob(&cid, "image/png", 1024, store_uid, "blobs/td.png") .await .unwrap(); @@ -1522,3 +1578,88 @@ async fn parity_blob_delete_and_takedown() { assert!(pg_meta.is_none()); assert!(store_meta.is_none()); } + +#[tokio::test] +async fn parity_prune_events_older_than() { + let f = ParityFixture::new().await; + let did = test_did("prune"); + + let event = tranquil_db_traits::CommitEventData { + did: did.clone(), + event_type: tranquil_db_traits::RepoEventType::Commit, + commit_cid: Some(test_cid(1)), + prev_cid: None, + ops: None, + blobs: None, + blocks: None, + prev_data_cid: None, + rev: Some("rev0".to_string()), + }; + + let pg_seq = f.pg.repo.insert_commit_event(&event).await.unwrap(); + let store_seq = f.store.repo.insert_commit_event(&event).await.unwrap(); + assert!(pg_seq.as_i64() > 0); + assert!(store_seq.as_i64() > 0); + + let past_cutoff = chrono::Utc::now() - chrono::Duration::hours(24); + let pg_pruned_past = + f.pg.repo + .prune_events_older_than(past_cutoff) + .await + .unwrap(); + let store_pruned_past = f + .store + .repo + .prune_events_older_than(past_cutoff) + .await + .unwrap(); + assert!( + pg_pruned_past.is_zero(), + "past cutoff should not prune fresh events on pg, got {pg_pruned_past:?}" + ); + assert!( + store_pruned_past.is_zero(), + "past cutoff should not prune fresh events on store, got {store_pruned_past:?}" + ); + assert!( + matches!(pg_pruned_past, tranquil_db_traits::PruneCount::Rows(_)), + "pg backend must report row counts" + ); + assert!( + matches!( + store_pruned_past, + tranquil_db_traits::PruneCount::Segments(_) + ), + "store backend must report segment counts" + ); + + let future_cutoff = chrono::Utc::now() + chrono::Duration::hours(24); + let pg_pruned_future = + f.pg.repo + .prune_events_older_than(future_cutoff) + .await + .unwrap(); + assert!( + pg_pruned_future.count() > 0, + "future cutoff should prune at least one row on pg, got {pg_pruned_future:?}" + ); + + let pg_after = f.pg.repo.get_event_by_seq(pg_seq).await.unwrap(); + assert!( + pg_after.is_none(), + "pruned pg event must no longer be readable" + ); + + let store_max_before = f.store.repo.get_max_seq().await.unwrap(); + let _ = f + .store + .repo + .prune_events_older_than(future_cutoff) + .await + .unwrap(); + let store_max_after = f.store.repo.get_max_seq().await.unwrap(); + assert_eq!( + store_max_after, store_max_before, + "store retention must not regress max_seq" + ); +} diff --git a/crates/tranquil-pds/tests/whole_story.rs b/crates/tranquil-pds/tests/whole_story.rs index 1d2279e..58964d2 100644 --- a/crates/tranquil-pds/tests/whole_story.rs +++ b/crates/tranquil-pds/tests/whole_story.rs @@ -1171,7 +1171,7 @@ async fn test_backup_restore_workflow() { } #[tokio::test] -async fn test_scale_100_posts_with_pagination() { +async fn test_scale_1000_posts_with_pagination() { let client = client(); let base = base_url().await; let (did, jwt) = setup_new_user("scale-posts").await; diff --git a/crates/tranquil-repo/src/lib.rs b/crates/tranquil-repo/src/lib.rs index c256c4b..bf77548 100644 --- a/crates/tranquil-repo/src/lib.rs +++ b/crates/tranquil-repo/src/lib.rs @@ -6,7 +6,7 @@ use jacquard_repo::storage::BlockStore; use multihash::Multihash; use sha2::{Digest, Sha256}; use sqlx::PgPool; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; #[derive(Clone)] @@ -24,32 +24,6 @@ impl PostgresBlockStore { } } -impl PostgresBlockStore { - pub async fn get_oldest_block_cids(&self, limit: i64) -> Result>, RepoError> { - let rows = sqlx::query!( - "SELECT cid FROM blocks ORDER BY created_at ASC LIMIT $1", - limit, - ) - .fetch_all(&self.pool) - .await - .map_err(RepoError::storage)?; - Ok(rows.into_iter().map(|r| r.cid).collect()) - } - - pub async fn delete_blocks(&self, cids: &[Vec]) -> Result { - match cids.is_empty() { - true => Ok(0), - false => { - let result = sqlx::query!("DELETE FROM blocks WHERE cid = ANY($1)", cids,) - .execute(&self.pool) - .await - .map_err(RepoError::storage)?; - Ok(result.rows_affected()) - } - } - } -} - impl BlockStore for PostgresBlockStore { async fn get(&self, cid: &Cid) -> Result, RepoError> { let cid_bytes = cid.to_bytes(); @@ -152,7 +126,7 @@ impl BlockStore for PostgresBlockStore { #[derive(Clone)] pub struct TrackingBlockStore { inner: S, - written_cids: Arc>>, + written_blocks: Arc>>, read_cids: Arc>>, } @@ -160,18 +134,26 @@ impl TrackingBlockStore { pub fn new(store: S) -> Self { Self { inner: store, - written_cids: Arc::new(Mutex::new(Vec::new())), + written_blocks: Arc::new(Mutex::new(HashMap::new())), read_cids: Arc::new(Mutex::new(HashSet::new())), } } pub fn get_written_cids(&self) -> Vec { - match self.written_cids.lock() { - Ok(guard) => guard.clone(), - Err(poisoned) => poisoned.into_inner().clone(), + match self.written_blocks.lock() { + Ok(guard) => guard.keys().copied().collect(), + Err(poisoned) => poisoned.into_inner().keys().copied().collect(), } } + pub fn take_written_blocks(&self) -> HashMap { + let mut guard = match self.written_blocks.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + std::mem::take(&mut *guard) + } + pub fn get_read_cids(&self) -> Vec { match self.read_cids.lock() { Ok(guard) => guard.iter().cloned().collect(), @@ -206,9 +188,14 @@ impl BlockStore for TrackingBlockStore { async fn put(&self, data: &[u8]) -> Result { let cid = self.inner.put(data).await?; - match self.written_cids.lock() { - Ok(mut guard) => guard.push(cid), - Err(poisoned) => poisoned.into_inner().push(cid), + let bytes = Bytes::copy_from_slice(data); + match self.written_blocks.lock() { + Ok(mut guard) => { + guard.insert(cid, bytes); + } + Err(poisoned) => { + poisoned.into_inner().insert(cid, bytes); + } } Ok(cid) } @@ -222,11 +209,10 @@ impl BlockStore for TrackingBlockStore { blocks: impl IntoIterator + Send, ) -> Result<(), RepoError> { let blocks: Vec<_> = blocks.into_iter().collect(); - let cids: Vec = blocks.iter().map(|(cid, _)| *cid).collect(); - self.inner.put_many(blocks).await?; - match self.written_cids.lock() { - Ok(mut guard) => guard.extend(cids), - Err(poisoned) => poisoned.into_inner().extend(cids), + self.inner.put_many(blocks.clone()).await?; + match self.written_blocks.lock() { + Ok(mut guard) => guard.extend(blocks), + Err(poisoned) => poisoned.into_inner().extend(blocks), } Ok(()) } diff --git a/crates/tranquil-server/src/main.rs b/crates/tranquil-server/src/main.rs index ae6811c..cb1cff3 100644 --- a/crates/tranquil-server/src/main.rs +++ b/crates/tranquil-server/src/main.rs @@ -10,8 +10,7 @@ use tranquil_pds::comms::{CommsService, DiscordSender, EmailSender, SignalSender use tranquil_pds::crawlers::{Crawlers, start_crawlers_service}; use tranquil_pds::scheduled::{ - backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks, - start_scheduled_tasks, + backfill_record_blobs, backfill_repo_rev, backfill_user_blocks, start_scheduled_tasks, }; use tranquil_pds::state::AppState; @@ -131,10 +130,6 @@ async fn run() -> Result<(), Box> { let backfill_block_store = state.block_store.clone(); tokio::spawn(async move { tokio::join!( - backfill_genesis_commit_blocks( - backfill_repo_repo.clone(), - backfill_block_store.clone() - ), backfill_repo_rev(backfill_repo_repo.clone(), backfill_block_store.clone()), backfill_user_blocks(backfill_repo_repo.clone(), backfill_block_store.clone()), backfill_record_blobs(backfill_repo_repo, backfill_block_store), @@ -255,6 +250,7 @@ async fn run() -> Result<(), Box> { state.repos.sso.clone(), state.repos.repo.clone(), state.block_store.clone(), + state.eventlog_segments_dir.clone(), shutdown.clone(), )); diff --git a/crates/tranquil-signal/Cargo.toml b/crates/tranquil-signal/Cargo.toml index d36d661..1a12ad9 100644 --- a/crates/tranquil-signal/Cargo.toml +++ b/crates/tranquil-signal/Cargo.toml @@ -24,4 +24,6 @@ uuid = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +tranquil-signal = { path = ".", features = ["fjall-store"] } rand = "0.9" +tempfile = "3" diff --git a/crates/tranquil-signal/src/lib.rs b/crates/tranquil-signal/src/lib.rs index 7388b98..794b856 100644 --- a/crates/tranquil-signal/src/lib.rs +++ b/crates/tranquil-signal/src/lib.rs @@ -6,6 +6,8 @@ pub mod fjall_store; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_fjall; pub use client::{ DeviceName, InvalidDeviceName, InvalidSignalUsername, LinkGeneration, LinkResult, MessageBody, diff --git a/crates/tranquil-signal/src/tests.rs b/crates/tranquil-signal/src/tests.rs index 9df74f5..c94cfad 100644 --- a/crates/tranquil-signal/src/tests.rs +++ b/crates/tranquil-signal/src/tests.rs @@ -14,15 +14,16 @@ use uuid::Uuid; use crate::store::{IdentityType, PgProtocolStore, PgSignalStore}; -async fn test_store() -> PgSignalStore { +async fn test_store() -> Option { let url = std::env::var("DATABASE_URL") .unwrap_or_else(|_| "postgres://postgres:postgres@127.0.0.1:5432/postgres".into()); let pool = PgPoolOptions::new() .max_connections(5) + .acquire_timeout(std::time::Duration::from_secs(2)) .connect(&url) .await - .unwrap(); + .ok()?; sqlx::query("DELETE FROM signal_kv") .execute(&pool) @@ -61,7 +62,7 @@ async fn test_store() -> PgSignalStore { .await .ok(); - PgSignalStore::new(pool) + Some(PgSignalStore::new(pool)) } fn protocol_store(store: &PgSignalStore, identity: IdentityType) -> PgProtocolStore { @@ -70,7 +71,9 @@ fn protocol_store(store: &PgSignalStore, identity: IdentityType) -> PgProtocolSt #[tokio::test] async fn state_store_registration_empty() { - let store = test_store().await; + let Some(store) = test_store().await else { + return; + }; assert!(store.load_registration_data().await.unwrap().is_none()); assert!(!store.is_registered().await); @@ -78,7 +81,9 @@ async fn state_store_registration_empty() { #[tokio::test] async fn state_store_kv_roundtrip() { - let store = test_store().await; + let Some(store) = test_store().await else { + return; + }; let value = b"test-data".to_vec(); sqlx::query("INSERT INTO signal_kv (key, value) VALUES ('test_key', $1)") @@ -96,7 +101,9 @@ async fn state_store_kv_roundtrip() { #[tokio::test] async fn state_store_identity_keypairs() { - let store = test_store().await; + let Some(store) = test_store().await else { + return; + }; let aci_pair = IdentityKeyPair::generate(&mut rand::rng()); let pni_pair = IdentityKeyPair::generate(&mut rand::rng()); @@ -116,13 +123,17 @@ async fn state_store_identity_keypairs() { #[tokio::test] async fn state_store_sender_certificate_roundtrip() { - let store = test_store().await; + let Some(store) = test_store().await else { + return; + }; assert!(store.sender_certificate().await.unwrap().is_none()); } #[tokio::test] async fn state_store_clear_registration() { - let mut store = test_store().await; + let Some(mut store) = test_store().await else { + return; + }; sqlx::query("INSERT INTO signal_kv (key, value) VALUES ('registration', $1)") .bind(b"dummy-data".as_slice()) @@ -151,7 +162,9 @@ async fn state_store_clear_registration() { #[tokio::test] async fn session_store_crud() { - let store = test_store().await; + let Some(store) = test_store().await else { + return; + }; let mut ps = protocol_store(&store, IdentityType::Aci); let addr = ProtocolAddress::new("test-uuid".into(), DeviceId::new(1).unwrap()); @@ -170,7 +183,9 @@ async fn session_store_crud() { #[tokio::test] async fn session_store_sub_devices() { - let store = test_store().await; + let Some(store) = test_store().await else { + return; + }; let mut ps = protocol_store(&store, IdentityType::Aci); let uuid = Uuid::new_v4(); @@ -196,7 +211,9 @@ async fn session_store_sub_devices() { #[tokio::test] async fn pre_key_store_crud() { - let store = test_store().await; + let Some(store) = test_store().await else { + return; + }; let mut ps = protocol_store(&store, IdentityType::Aci); let keypair = KeyPair::generate(&mut rand::rng()); @@ -213,7 +230,9 @@ async fn pre_key_store_crud() { #[tokio::test] async fn pre_key_store_next_ids() { - let store = test_store().await; + let Some(store) = test_store().await else { + return; + }; let mut ps = protocol_store(&store, IdentityType::Aci); assert_eq!(ps.next_pre_key_id().await.unwrap(), 1); @@ -229,7 +248,9 @@ async fn pre_key_store_next_ids() { #[tokio::test] async fn signed_pre_key_store_crud() { - let store = test_store().await; + let Some(store) = test_store().await else { + return; + }; let mut ps = protocol_store(&store, IdentityType::Aci); let keypair = KeyPair::generate(&mut rand::rng()); @@ -251,7 +272,9 @@ async fn signed_pre_key_store_crud() { #[tokio::test] async fn kyber_pre_key_one_time_mark_used_deletes() { - let store = test_store().await; + let Some(store) = test_store().await else { + return; + }; let mut ps = protocol_store(&store, IdentityType::Aci); let keypair = KeyPair::generate(&mut rand::rng()); @@ -276,7 +299,9 @@ async fn kyber_pre_key_one_time_mark_used_deletes() { #[tokio::test] async fn kyber_pre_key_last_resort_survives_mark_used() { - let store = test_store().await; + let Some(store) = test_store().await else { + return; + }; let mut ps = protocol_store(&store, IdentityType::Aci); let keypair = KeyPair::generate(&mut rand::rng()); @@ -303,7 +328,9 @@ async fn kyber_pre_key_last_resort_survives_mark_used() { #[tokio::test] async fn kyber_pre_key_last_resort_rejects_replayed_base_key() { - let store = test_store().await; + let Some(store) = test_store().await else { + return; + }; let mut ps = protocol_store(&store, IdentityType::Aci); let keypair = KeyPair::generate(&mut rand::rng()); @@ -332,7 +359,9 @@ async fn kyber_pre_key_last_resort_rejects_replayed_base_key() { #[tokio::test] async fn kyber_pre_key_last_resort_list() { - let store = test_store().await; + let Some(store) = test_store().await else { + return; + }; let mut ps = protocol_store(&store, IdentityType::Aci); let keypair = KeyPair::generate(&mut rand::rng()); @@ -361,7 +390,9 @@ async fn kyber_pre_key_last_resort_list() { #[tokio::test] async fn identity_store_crud() { - let store = test_store().await; + let Some(store) = test_store().await else { + return; + }; let mut ps = protocol_store(&store, IdentityType::Aci); let addr = ProtocolAddress::new("test-addr".into(), DeviceId::new(1).unwrap()); @@ -383,7 +414,9 @@ async fn identity_store_crud() { #[tokio::test] async fn identity_store_aci_pni_isolation() { - let store = test_store().await; + let Some(store) = test_store().await else { + return; + }; let mut aci_store = protocol_store(&store, IdentityType::Aci); let pni_store = protocol_store(&store, IdentityType::Pni); @@ -401,7 +434,9 @@ async fn identity_store_aci_pni_isolation() { #[tokio::test] async fn sender_key_store_load_missing() { - let store = test_store().await; + let Some(store) = test_store().await else { + return; + }; let mut ps = protocol_store(&store, IdentityType::Aci); let sender = ProtocolAddress::new("sender-uuid".into(), DeviceId::new(1).unwrap()); @@ -417,7 +452,9 @@ async fn sender_key_store_load_missing() { #[tokio::test] async fn profile_key_store_roundtrip() { - let mut store = test_store().await; + let Some(mut store) = test_store().await else { + return; + }; let uuid = Uuid::new_v4(); let service_id: ServiceId = presage::libsignal_service::protocol::Aci::from(uuid).into(); @@ -433,7 +470,9 @@ async fn profile_key_store_roundtrip() { #[tokio::test] async fn client_from_pool_returns_none_without_registration() { - let store = test_store().await; + let Some(store) = test_store().await else { + return; + }; let pool = store.db.clone(); let client = @@ -443,7 +482,9 @@ async fn client_from_pool_returns_none_without_registration() { #[tokio::test] async fn store_clear_removes_kv() { - let mut store = test_store().await; + let Some(mut store) = test_store().await else { + return; + }; store .set_aci_identity_key_pair(IdentityKeyPair::generate(&mut rand::rng())) diff --git a/crates/tranquil-signal/src/tests_fjall.rs b/crates/tranquil-signal/src/tests_fjall.rs new file mode 100644 index 0000000..a2bb1cb --- /dev/null +++ b/crates/tranquil-signal/src/tests_fjall.rs @@ -0,0 +1,373 @@ +use presage::libsignal_service::{ + pre_keys::{KyberPreKeyStoreExt, PreKeysStore}, + prelude::{ProfileKey, SessionStoreExt}, + protocol::{ + DeviceId, Direction, GenericSignedPreKey, IdentityKeyPair, IdentityKeyStore, KeyPair, + KyberPreKeyId, KyberPreKeyRecord, KyberPreKeyStore, PreKeyId, PreKeyRecord, PreKeyStore, + ProtocolAddress, SenderKeyStore, ServiceId, SessionRecord, SessionStore, SignedPreKeyId, + SignedPreKeyRecord, SignedPreKeyStore, Timestamp, + }, +}; +use presage::store::{ContentsStore, StateStore, Store}; +use uuid::Uuid; + +use crate::fjall_store::FjallSignalStore; + +fn test_store() -> (FjallSignalStore, tempfile::TempDir) { + let dir = tempfile::TempDir::new().unwrap(); + let db = fjall::Database::builder(dir.path()).open().unwrap(); + let ks = db + .keyspace("signal", fjall::KeyspaceCreateOptions::default) + .unwrap(); + (FjallSignalStore::new(db, ks), dir) +} + +#[tokio::test] +async fn state_store_registration_empty() { + let (store, _dir) = test_store(); + assert!(store.load_registration_data().await.unwrap().is_none()); + assert!(!store.is_registered().await); +} + +#[tokio::test] +async fn state_store_identity_keypairs() { + let (store, _dir) = test_store(); + let aci_pair = IdentityKeyPair::generate(&mut rand::rng()); + let pni_pair = IdentityKeyPair::generate(&mut rand::rng()); + + store.set_aci_identity_key_pair(aci_pair).await.unwrap(); + store.set_pni_identity_key_pair(pni_pair).await.unwrap(); + + let aci_store = store.aci_protocol_store(); + let pni_store = store.pni_protocol_store(); + + let loaded_aci = aci_store.get_identity_key_pair().await.unwrap(); + let loaded_pni = pni_store.get_identity_key_pair().await.unwrap(); + + assert_eq!(loaded_aci.serialize(), aci_pair.serialize()); + assert_eq!(loaded_pni.serialize(), pni_pair.serialize()); +} + +#[tokio::test] +async fn state_store_sender_certificate_roundtrip() { + let (store, _dir) = test_store(); + assert!(store.sender_certificate().await.unwrap().is_none()); +} + +#[tokio::test] +async fn state_store_clear_registration() { + let (mut store, _dir) = test_store(); + + store + .set_aci_identity_key_pair(IdentityKeyPair::generate(&mut rand::rng())) + .await + .unwrap(); + + let mut ps = store.aci_protocol_store(); + let keypair = KeyPair::generate(&mut rand::rng()); + let record = PreKeyRecord::new(PreKeyId::from(1u32), &keypair); + ps.save_pre_key(PreKeyId::from(1u32), &record) + .await + .unwrap(); + + store.clear_registration().await.unwrap(); + + assert!(store.load_registration_data().await.unwrap().is_none()); + assert!(ps.get_pre_key(PreKeyId::from(1u32)).await.is_err()); +} + +#[tokio::test] +async fn session_store_crud() { + let (store, _dir) = test_store(); + let mut ps = store.aci_protocol_store(); + + let addr = ProtocolAddress::new("test-uuid".into(), DeviceId::new(1).unwrap()); + assert!(ps.load_session(&addr).await.unwrap().is_none()); + + let record = SessionRecord::new_fresh(); + ps.store_session(&addr, &record).await.unwrap(); + + let loaded = ps.load_session(&addr).await.unwrap(); + assert!(loaded.is_some()); + + ps.store_session(&addr, &record).await.unwrap(); + let loaded2 = ps.load_session(&addr).await.unwrap(); + assert!(loaded2.is_some()); +} + +#[tokio::test] +async fn session_store_sub_devices() { + let (store, _dir) = test_store(); + let mut ps = store.aci_protocol_store(); + + let uuid = Uuid::new_v4(); + let service_id: ServiceId = presage::libsignal_service::protocol::Aci::from(uuid).into(); + let addr1 = ProtocolAddress::new(uuid.to_string(), DeviceId::new(1).unwrap()); + let addr2 = ProtocolAddress::new(uuid.to_string(), DeviceId::new(2).unwrap()); + let addr3 = ProtocolAddress::new(uuid.to_string(), DeviceId::new(3).unwrap()); + + let record = SessionRecord::new_fresh(); + ps.store_session(&addr1, &record).await.unwrap(); + ps.store_session(&addr2, &record).await.unwrap(); + ps.store_session(&addr3, &record).await.unwrap(); + + let sub_devices = ps.get_sub_device_sessions(&service_id).await.unwrap(); + assert_eq!(sub_devices.len(), 2); + + let deleted = ps.delete_all_sessions(&service_id).await.unwrap(); + assert_eq!(deleted, 3); + + let sub_devices = ps.get_sub_device_sessions(&service_id).await.unwrap(); + assert!(sub_devices.is_empty()); +} + +#[tokio::test] +async fn pre_key_store_crud() { + let (store, _dir) = test_store(); + let mut ps = store.aci_protocol_store(); + + let keypair = KeyPair::generate(&mut rand::rng()); + let id = PreKeyId::from(42u32); + let record = PreKeyRecord::new(id, &keypair); + + ps.save_pre_key(id, &record).await.unwrap(); + let loaded = ps.get_pre_key(id).await.unwrap(); + assert_eq!(loaded.serialize().unwrap(), record.serialize().unwrap()); + + ps.remove_pre_key(id).await.unwrap(); + assert!(ps.get_pre_key(id).await.is_err()); +} + +#[tokio::test] +async fn pre_key_store_next_ids() { + let (store, _dir) = test_store(); + let mut ps = store.aci_protocol_store(); + + assert_eq!(ps.next_pre_key_id().await.unwrap(), 1); + + let keypair = KeyPair::generate(&mut rand::rng()); + let record = PreKeyRecord::new(PreKeyId::from(5u32), &keypair); + ps.save_pre_key(PreKeyId::from(5u32), &record) + .await + .unwrap(); + + assert_eq!(ps.next_pre_key_id().await.unwrap(), 6); +} + +#[tokio::test] +async fn signed_pre_key_store_crud() { + let (store, _dir) = test_store(); + let mut ps = store.aci_protocol_store(); + + let keypair = KeyPair::generate(&mut rand::rng()); + let id = SignedPreKeyId::from(1u32); + let signature = keypair + .private_key + .calculate_signature(&keypair.public_key.serialize(), &mut rand::rng()) + .unwrap(); + let record = + SignedPreKeyRecord::new(id, Timestamp::from_epoch_millis(1000), &keypair, &signature); + + ps.save_signed_pre_key(id, &record).await.unwrap(); + let loaded = ps.get_signed_pre_key(id).await.unwrap(); + assert_eq!(loaded.serialize().unwrap(), record.serialize().unwrap()); + + assert_eq!(ps.signed_pre_keys_count().await.unwrap(), 1); + assert_eq!(ps.next_signed_pre_key_id().await.unwrap(), 2); +} + +#[tokio::test] +async fn kyber_pre_key_one_time_mark_used_deletes() { + let (store, _dir) = test_store(); + let mut ps = store.aci_protocol_store(); + + let keypair = KeyPair::generate(&mut rand::rng()); + let id = KyberPreKeyId::from(1u32); + let record = KyberPreKeyRecord::generate( + presage::libsignal_service::protocol::kem::KeyType::Kyber1024, + id, + &keypair.private_key, + ) + .unwrap(); + + ps.save_kyber_pre_key(id, &record).await.unwrap(); + assert!(ps.get_kyber_pre_key(id).await.is_ok()); + + let ec_prekey_id = SignedPreKeyId::from(1u32); + ps.mark_kyber_pre_key_used(id, ec_prekey_id, &keypair.public_key) + .await + .unwrap(); + + assert!(ps.get_kyber_pre_key(id).await.is_err()); +} + +#[tokio::test] +async fn kyber_pre_key_last_resort_survives_mark_used() { + let (store, _dir) = test_store(); + let mut ps = store.aci_protocol_store(); + + let keypair = KeyPair::generate(&mut rand::rng()); + let id = KyberPreKeyId::from(1u32); + let record = KyberPreKeyRecord::generate( + presage::libsignal_service::protocol::kem::KeyType::Kyber1024, + id, + &keypair.private_key, + ) + .unwrap(); + + ps.store_last_resort_kyber_pre_key(id, &record) + .await + .unwrap(); + assert!(ps.get_kyber_pre_key(id).await.is_ok()); + + let ec_prekey_id = SignedPreKeyId::from(1u32); + ps.mark_kyber_pre_key_used(id, ec_prekey_id, &keypair.public_key) + .await + .unwrap(); + + assert!(ps.get_kyber_pre_key(id).await.is_ok()); +} + +#[tokio::test] +async fn kyber_pre_key_last_resort_rejects_replayed_base_key() { + let (store, _dir) = test_store(); + let mut ps = store.aci_protocol_store(); + + let keypair = KeyPair::generate(&mut rand::rng()); + let id = KyberPreKeyId::from(1u32); + let record = KyberPreKeyRecord::generate( + presage::libsignal_service::protocol::kem::KeyType::Kyber1024, + id, + &keypair.private_key, + ) + .unwrap(); + + ps.store_last_resort_kyber_pre_key(id, &record) + .await + .unwrap(); + + let ec_prekey_id = SignedPreKeyId::from(1u32); + ps.mark_kyber_pre_key_used(id, ec_prekey_id, &keypair.public_key) + .await + .unwrap(); + + let replay_result = ps + .mark_kyber_pre_key_used(id, ec_prekey_id, &keypair.public_key) + .await; + assert!(replay_result.is_err()); +} + +#[tokio::test] +async fn kyber_pre_key_last_resort_list() { + let (store, _dir) = test_store(); + let mut ps = store.aci_protocol_store(); + + let keypair = KeyPair::generate(&mut rand::rng()); + let id = KyberPreKeyId::from(1u32); + let record = KyberPreKeyRecord::generate( + presage::libsignal_service::protocol::kem::KeyType::Kyber1024, + id, + &keypair.private_key, + ) + .unwrap(); + + assert!( + ps.load_last_resort_kyber_pre_keys() + .await + .unwrap() + .is_empty() + ); + + ps.store_last_resort_kyber_pre_key(id, &record) + .await + .unwrap(); + + let last_resorts = ps.load_last_resort_kyber_pre_keys().await.unwrap(); + assert_eq!(last_resorts.len(), 1); +} + +#[tokio::test] +async fn identity_store_crud() { + let (store, _dir) = test_store(); + let mut ps = store.aci_protocol_store(); + + let addr = ProtocolAddress::new("test-addr".into(), DeviceId::new(1).unwrap()); + let keypair = IdentityKeyPair::generate(&mut rand::rng()); + let identity_key = keypair.identity_key(); + + assert!(ps.get_identity(&addr).await.unwrap().is_none()); + + ps.save_identity(&addr, identity_key).await.unwrap(); + let loaded = ps.get_identity(&addr).await.unwrap().unwrap(); + assert_eq!(loaded.serialize(), identity_key.serialize()); + + assert!( + ps.is_trusted_identity(&addr, identity_key, Direction::Receiving) + .await + .unwrap() + ); +} + +#[tokio::test] +async fn identity_store_aci_pni_isolation() { + let (store, _dir) = test_store(); + let mut aci_store = store.aci_protocol_store(); + let pni_store = store.pni_protocol_store(); + + let addr = ProtocolAddress::new("same-addr".into(), DeviceId::new(1).unwrap()); + let keypair = IdentityKeyPair::generate(&mut rand::rng()); + + aci_store + .save_identity(&addr, keypair.identity_key()) + .await + .unwrap(); + + assert!(aci_store.get_identity(&addr).await.unwrap().is_some()); + assert!(pni_store.get_identity(&addr).await.unwrap().is_none()); +} + +#[tokio::test] +async fn sender_key_store_load_missing() { + let (store, _dir) = test_store(); + let mut ps = store.aci_protocol_store(); + + let sender = ProtocolAddress::new("sender-uuid".into(), DeviceId::new(1).unwrap()); + let dist_id = Uuid::new_v4(); + + assert!( + ps.load_sender_key(&sender, dist_id) + .await + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn profile_key_store_roundtrip() { + let (mut store, _dir) = test_store(); + + let uuid = Uuid::new_v4(); + let service_id: ServiceId = presage::libsignal_service::protocol::Aci::from(uuid).into(); + let key = ProfileKey { bytes: [42u8; 32] }; + + assert!(store.profile_key(&service_id).await.unwrap().is_none()); + + store.upsert_profile_key(&uuid, key).await.unwrap(); + + let loaded = store.profile_key(&service_id).await.unwrap().unwrap(); + assert_eq!(loaded.bytes, key.bytes); +} + +#[tokio::test] +async fn store_clear_removes_all() { + let (mut store, _dir) = test_store(); + + store + .set_aci_identity_key_pair(IdentityKeyPair::generate(&mut rand::rng())) + .await + .unwrap(); + + store.clear().await.unwrap(); + + assert!(store.load_registration_data().await.unwrap().is_none()); +} diff --git a/crates/tranquil-store/Cargo.toml b/crates/tranquil-store/Cargo.toml index 3432306..7033af5 100644 --- a/crates/tranquil-store/Cargo.toml +++ b/crates/tranquil-store/Cargo.toml @@ -28,8 +28,10 @@ jacquard-repo = { workspace = true } cid = { workspace = true } multihash = { workspace = true } sha2 = { workspace = true } +serde_ipld_dagcbor = { workspace = true } siphasher = "1" dashmap = "6" +rayon = "1" smallvec = "1" uuid = { workspace = true } @@ -37,6 +39,7 @@ uuid = { workspace = true } test-harness = [] [dev-dependencies] +tranquil-store = { path = ".", features = ["test-harness"] } proptest = "1" tempfile = "3" futures = { workspace = true } @@ -47,8 +50,8 @@ tranquil-db = { workspace = true } sqlx = { workspace = true } k256 = { workspace = true } rand = { workspace = true } -serde_ipld_dagcbor = { workspace = true } tikv-jemallocator = "0.6" +tracing-subscriber = { workspace = true, features = ["env-filter"] } [[bench]] name = "blockstore" @@ -69,3 +72,11 @@ harness = false [[bench]] name = "profile_reads" harness = false + +[[bench]] +name = "profile_eventlog_reads" +harness = false + +[[bench]] +name = "recovery" +harness = false diff --git a/crates/tranquil-store/benches/blockstore.rs b/crates/tranquil-store/benches/blockstore.rs index 6ae6750..a79cbe8 100644 --- a/crates/tranquil-store/benches/blockstore.rs +++ b/crates/tranquil-store/benches/blockstore.rs @@ -61,11 +61,16 @@ fn compute_stats(durations: &mut [Duration]) -> Option { } fn open_store(dir: &Path) -> TranquilBlockStore { + open_store_sharded(dir, 1) +} + +fn open_store_sharded(dir: &Path, shard_count: u8) -> TranquilBlockStore { TranquilBlockStore::open(BlockStoreConfig { data_dir: dir.join("data"), index_dir: dir.join("index"), max_file_size: DEFAULT_MAX_FILE_SIZE, group_commit: GroupCommitConfig::default(), + shard_count, }) .unwrap() } @@ -81,7 +86,7 @@ fn format_latency(stats: Option<&LatencyStats>) -> String { } async fn bench_write_throughput(block_count: usize, concurrency: usize) { - let dir = tempfile::TempDir::new().unwrap(); + let dir = bench_temp_dir(); let store = open_store(dir.path()); let blocks_per_task = block_count / concurrency; @@ -162,7 +167,7 @@ async fn bench_write_throughput(block_count: usize, concurrency: usize) { } async fn bench_read_throughput(block_count: usize, concurrency: usize) { - let dir = tempfile::TempDir::new().unwrap(); + let dir = bench_temp_dir(); let store = open_store(dir.path()); let cids_per_task = block_count / concurrency; @@ -238,7 +243,7 @@ async fn bench_read_throughput(block_count: usize, concurrency: usize) { } async fn bench_mixed_workload(block_count: usize, concurrency: usize) { - let dir = tempfile::TempDir::new().unwrap(); + let dir = bench_temp_dir(); let store = open_store(dir.path()); let ops_per_task = block_count / concurrency; @@ -351,7 +356,7 @@ async fn bench_group_commit_effectiveness(block_count: usize) { println!("-- group commit effectiveness at {block_count} blocks --"); let baseline_cycle_time = { - let dir = tempfile::TempDir::new().unwrap(); + let dir = bench_temp_dir(); let store = open_store(dir.path()); let start = Instant::now(); @@ -377,7 +382,7 @@ async fn bench_group_commit_effectiveness(block_count: usize) { if block_count < concurrency { return; } - let dir = tempfile::TempDir::new().unwrap(); + let dir = bench_temp_dir(); let store = open_store(dir.path()); let blocks_per_task = block_count / concurrency; let actual_count = blocks_per_task * concurrency; @@ -422,29 +427,10 @@ async fn bench_group_commit_effectiveness(block_count: usize) { .await; } -async fn bench_postgres_write_throughput(block_count: usize, concurrency: usize) { - let database_url = match std::env::var("DATABASE_URL") { - Ok(url) => url, - Err(_) => { - println!("skipped, set DATABASE_URL to enable"); - return; - } - }; +async fn bench_sharded_write_throughput(block_count: usize, concurrency: usize, shard_count: u8) { + let dir = bench_temp_dir(); + let store = open_store_sharded(dir.path(), shard_count); - let max_conns = u32::try_from(concurrency).expect("concurrency exceeds u32") + 5; - let pool = sqlx::postgres::PgPoolOptions::new() - .max_connections(max_conns) - .connect(&database_url) - .await - .unwrap(); - - sqlx::query("CREATE TABLE IF NOT EXISTS blocks (cid bytea PRIMARY KEY, data bytea NOT NULL)") - .execute(&pool) - .await - .unwrap(); - sqlx::query("TRUNCATE blocks").execute(&pool).await.unwrap(); - - let pg_store = tranquil_repo::PostgresBlockStore::new(pool.clone()); let blocks_per_task = block_count / concurrency; let actual_count = blocks_per_task * concurrency; let blocks: Vec> = (0..actual_count).map(make_block).collect(); @@ -454,7 +440,7 @@ async fn bench_postgres_write_throughput(block_count: usize, concurrency: usize) let handles: Vec<_> = (0..concurrency) .map(|task_id| { - let store = pg_store.clone(); + let store = store.clone(); let task_blocks: Vec> = blocks[task_id * blocks_per_task..(task_id + 1) * blocks_per_task].to_vec(); tokio::spawn(async move { @@ -462,38 +448,39 @@ async fn bench_postgres_write_throughput(block_count: usize, concurrency: usize) .then(|block| { let store = store.clone(); async move { - let t = Instant::now(); store.put(&block).await.unwrap(); - t.elapsed() } }) - .collect::>() - .await + .collect::>() + .await; }) }) .collect(); - let mut all_latencies: Vec = futures::future::join_all(handles) - .await - .into_iter() - .flat_map(Result::unwrap) - .collect(); + futures::future::join_all(handles).await; let elapsed = start.elapsed(); - let stats = compute_stats(&mut all_latencies); - let lat = format_latency(stats.as_ref()); println!( - "{:.0} blocks/sec, {:.1} MB/sec, {:.1}ms{lat}", + "{:.0} blocks/sec, {:.1} MB/sec, {:.1}ms", actual_count as f64 / elapsed.as_secs_f64(), total_bytes as f64 / elapsed.as_secs_f64() / (1024.0 * 1024.0), elapsed.as_secs_f64() * 1000.0, ); +} - sqlx::query("TRUNCATE blocks").execute(&pool).await.unwrap(); - pool.close().await; +fn bench_temp_dir() -> tempfile::TempDir { + match std::env::var("BENCH_DIR") { + Ok(dir) => tempfile::TempDir::new_in(dir).unwrap(), + Err(_) => tempfile::TempDir::new().unwrap(), + } } fn main() { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); + let worker_threads = std::env::var("BENCH_WORKER_THREADS") .ok() .and_then(|s| s.trim().parse::().ok()) @@ -558,20 +545,23 @@ fn main() { rt.block_on(bench_group_commit_effectiveness(1000)); - if std::env::var("DATABASE_URL").is_ok() { - block_counts.iter().for_each(|&block_count| { - concurrency_levels.iter().for_each(|&concurrency| { - if block_count < concurrency { - return; - } - println!( - "-- postgres write: {} blocks, {} writers --", - block_count, concurrency - ); - rt.block_on(bench_postgres_write_throughput(block_count, concurrency)); + let shard_counts = parse_env_list("BENCH_SHARDS", vec![1, 2, 4]); + if shard_counts.iter().any(|&s| s > 1) { + println!("\n-- sharded write throughput :p --"); + shard_counts.iter().for_each(|&shards| { + block_counts.iter().for_each(|&block_count| { + concurrency_levels.iter().for_each(|&concurrency| { + if block_count < concurrency { + return; + } + let sc = u8::try_from(shards).unwrap_or(4); + println!( + "-- sharded write: {} shards, {} blocks, {} writers --", + sc, block_count, concurrency + ); + rt.block_on(bench_sharded_write_throughput(block_count, concurrency, sc)); + }); }); }); - } else { - println!("set DATABASE_URL for postgres comparison"); } } diff --git a/crates/tranquil-store/benches/eventlog.rs b/crates/tranquil-store/benches/eventlog.rs index 17d2978..29df4e1 100644 --- a/crates/tranquil-store/benches/eventlog.rs +++ b/crates/tranquil-store/benches/eventlog.rs @@ -42,7 +42,7 @@ fn make_event(index: usize) -> SequencedEvent { prev_data_cid: None, ops: Some(serde_json::json!({ "data": ops_payload })), blobs: None, - blocks_cids: None, + blocks: None, handle: None, active: None, status: None, @@ -621,217 +621,6 @@ fn bench_broadcast_fanout(subscriber_count: usize) { let _ = log.shutdown(); } -async fn bench_pg_write_throughput(event_count: usize, concurrency: usize) { - let database_url = match std::env::var("DATABASE_URL") { - Ok(url) => url, - Err(_) => { - println!("skipped, set DATABASE_URL to enable"); - return; - } - }; - - let max_conns = u32::try_from(concurrency) - .unwrap_or(u32::MAX) - .saturating_add(10); - let pool = sqlx::postgres::PgPoolOptions::new() - .max_connections(max_conns) - .acquire_timeout(Duration::from_secs(30)) - .connect(&database_url) - .await - .unwrap(); - - sqlx::query( - "CREATE TABLE IF NOT EXISTS bench_repo_seq ( - seq BIGSERIAL PRIMARY KEY, - did TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - event_type TEXT NOT NULL, - ops JSONB - )", - ) - .execute(&pool) - .await - .unwrap(); - sqlx::query("TRUNCATE bench_repo_seq") - .execute(&pool) - .await - .unwrap(); - - let events_per_task = event_count / concurrency; - let actual_count = events_per_task * concurrency; - - let start = Instant::now(); - - let handles: Vec<_> = (0..concurrency) - .map(|task_id| { - let pool = pool.clone(); - tokio::spawn(async move { - futures::stream::iter(0..events_per_task) - .then(|i| { - let pool = pool.clone(); - async move { - let global = task_id * events_per_task + i; - let did = format!("did:plc:{global:024x}"); - let ops_size = match global % 4 { - 0 => 64, - 1 => 256, - 2 => 1024, - _ => 4096, - }; - let payload: String = (0..ops_size) - .map(|j| { - ((global.wrapping_mul(31).wrapping_add(j)) % 26 + 97) as u8 - as char - }) - .collect(); - let ops = serde_json::json!({ "data": payload }); - let t = Instant::now(); - sqlx::query( - "INSERT INTO bench_repo_seq (did, event_type, ops) VALUES ($1, $2, $3)", - ) - .bind(&did) - .bind("commit") - .bind(&ops) - .execute(&pool) - .await - .unwrap(); - t.elapsed() - } - }) - .collect::>() - .await - }) - }) - .collect(); - - let mut all_latencies: Vec = futures::future::join_all(handles) - .await - .into_iter() - .flat_map(Result::unwrap) - .collect(); - let elapsed = start.elapsed(); - - let lat = format_latency(compute_stats(&mut all_latencies).as_ref()); - println!( - "{:.0} events/sec, {:.1}ms{lat}", - actual_count as f64 / elapsed.as_secs_f64(), - elapsed.as_secs_f64() * 1000.0, - ); - - sqlx::query("TRUNCATE bench_repo_seq") - .execute(&pool) - .await - .unwrap(); - pool.close().await; -} - -async fn bench_pg_read_throughput(event_count: usize, concurrency: usize) { - let database_url = match std::env::var("DATABASE_URL") { - Ok(url) => url, - Err(_) => { - println!("skipped, set DATABASE_URL to enable"); - return; - } - }; - - let max_conns = u32::try_from(concurrency) - .unwrap_or(u32::MAX) - .saturating_add(5); - let pool = sqlx::postgres::PgPoolOptions::new() - .max_connections(max_conns) - .connect(&database_url) - .await - .unwrap(); - - sqlx::query( - "CREATE TABLE IF NOT EXISTS bench_repo_seq ( - seq BIGSERIAL PRIMARY KEY, - did TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - event_type TEXT NOT NULL, - ops JSONB - )", - ) - .execute(&pool) - .await - .unwrap(); - - let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM bench_repo_seq") - .fetch_one(&pool) - .await - .unwrap(); - if (row.0 as usize) < event_count { - sqlx::query("TRUNCATE bench_repo_seq") - .execute(&pool) - .await - .unwrap(); - println!("populating {event_count} events"); - futures::stream::iter(0..event_count) - .map(|i| { - let pool = pool.clone(); - async move { - let did = format!("did:plc:{i:024x}"); - let ops = serde_json::json!({ "data": "x".repeat(256) }); - sqlx::query( - "INSERT INTO bench_repo_seq (did, event_type, ops) VALUES ($1, $2, $3)", - ) - .bind(&did) - .bind("commit") - .bind(&ops) - .execute(&pool) - .await - .unwrap(); - } - }) - .buffer_unordered(50) - .collect::>() - .await; - } - - let total_events = Arc::new(AtomicU64::new(0)); - - let start = Instant::now(); - - let handles: Vec<_> = (0..concurrency) - .map(|_| { - let pool = pool.clone(); - let total_events = Arc::clone(&total_events); - tokio::spawn(async move { - let mut cursor = 0i64; - let mut count = 0u64; - loop { - let rows: Vec<(i64,)> = sqlx::query_as( - "SELECT seq FROM bench_repo_seq WHERE seq > $1 ORDER BY seq LIMIT $2", - ) - .bind(cursor) - .bind(1000i64) - .fetch_all(&pool) - .await - .unwrap(); - if rows.is_empty() { - break; - } - count += rows.len() as u64; - cursor = rows.last().unwrap().0; - } - total_events.fetch_add(count, Ordering::Relaxed); - }) - }) - .collect(); - - futures::future::join_all(handles).await; - let elapsed = start.elapsed(); - - let total = total_events.load(Ordering::Relaxed); - println!( - "{:.0} total events/sec across {concurrency} readers, {total} events, {:.1}ms", - total as f64 / elapsed.as_secs_f64(), - elapsed.as_secs_f64() * 1000.0, - ); - - pool.close().await; -} - fn main() { println!("-- eventlog benchmarks --"); let cpus = std::thread::available_parallelism() @@ -915,45 +704,5 @@ fn main() { bench_stampede(100_000, 100, 16, 50); bench_stampede(500_000, 100, 16, 50); - let rt = tokio::runtime::Builder::new_multi_thread() - .worker_threads(cpus) - .enable_all() - .build() - .unwrap(); - - if std::env::var("DATABASE_URL").is_ok() { - println!("-- postgres comparison --"); - - event_counts.iter().for_each(|&n| { - producer_counts.iter().for_each(|&p| { - if n >= p { - println!("-- postgres write: {n} events, {p} writers --",); - rt.block_on(bench_pg_write_throughput(n, p)); - } - }); - }); - - event_counts.iter().for_each(|&n| { - [1usize, 4, 16, 32].iter().for_each(|&r| { - println!("-- postgres read: {n} events, {r} readers --",); - rt.block_on(bench_pg_read_throughput(n, r)); - }); - }); - - rt.block_on(async { - let url = std::env::var("DATABASE_URL").unwrap(); - let pool = sqlx::postgres::PgPoolOptions::new() - .max_connections(5) - .connect(&url) - .await - .unwrap(); - sqlx::query("DROP TABLE IF EXISTS bench_repo_seq") - .execute(&pool) - .await - .unwrap(); - pool.close().await; - }); - } else { - println!("set DATABASE_URL for postgres comparison"); - } + let _ = (cpus, producer_counts); } diff --git a/crates/tranquil-store/benches/metastore.rs b/crates/tranquil-store/benches/metastore.rs index e3c5090..b28cc62 100644 --- a/crates/tranquil-store/benches/metastore.rs +++ b/crates/tranquil-store/benches/metastore.rs @@ -180,7 +180,7 @@ fn make_commit_input( prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some(make_rev(rev_n)), }, @@ -241,7 +241,7 @@ async fn seed_records( prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some(make_rev(rev_n)), }, diff --git a/crates/tranquil-store/benches/metastore_scale.rs b/crates/tranquil-store/benches/metastore_scale.rs index 98810aa..d06b2e1 100644 --- a/crates/tranquil-store/benches/metastore_scale.rs +++ b/crates/tranquil-store/benches/metastore_scale.rs @@ -257,7 +257,7 @@ async fn seed_records_for_user(pool: &HandlerPool, user: &UserInfo, record_count prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some(make_rev(1)), }, @@ -348,7 +348,7 @@ async fn bench_single_user_commit(pool: &Arc, user: &UserInfo, ops: prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some(make_rev(rev_n)), }, @@ -424,7 +424,7 @@ async fn bench_multi_user_commit( prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some(make_rev(rev_n)), }, diff --git a/crates/tranquil-store/benches/profile_eventlog_reads.rs b/crates/tranquil-store/benches/profile_eventlog_reads.rs new file mode 100644 index 0000000..d295ab1 --- /dev/null +++ b/crates/tranquil-store/benches/profile_eventlog_reads.rs @@ -0,0 +1,342 @@ +use std::path::Path; +use std::sync::Arc; +use std::time::Instant; + +use chrono::Utc; +use tranquil_db_traits::{RepoEventType, SequencedEvent}; +use tranquil_types::Did; + +use tranquil_store::RealIO; +use tranquil_store::eventlog::{ + EventLog, EventLogConfig, EventSequence, decode_payload, to_sequenced_event, +}; + +fn make_did(index: usize) -> Did { + let suffix: String = format!("{index:024x}"); + Did::new(format!("did:plc:{suffix}")).unwrap() +} + +fn make_event(index: usize) -> SequencedEvent { + let ops_size = match index % 4 { + 0 => 64, + 1 => 256, + 2 => 1024, + _ => 4096, + }; + + let ops_payload: String = (0..ops_size) + .map(|i| ((index.wrapping_mul(31).wrapping_add(i)) % 26 + 97) as u8 as char) + .collect(); + + SequencedEvent { + seq: tranquil_db_traits::SequenceNumber::from_raw( + i64::try_from(index + 1).expect("event index overflow"), + ), + did: make_did(index % 10_000), + created_at: Utc::now(), + event_type: match index % 4 { + 0 => RepoEventType::Commit, + 1 => RepoEventType::Identity, + 2 => RepoEventType::Account, + _ => RepoEventType::Sync, + }, + commit_cid: None, + prev_cid: None, + prev_data_cid: None, + ops: Some(serde_json::json!({ "data": ops_payload })), + blobs: None, + blocks: None, + handle: None, + active: None, + status: None, + rev: None, + } +} + +fn open_eventlog(dir: &Path) -> EventLog { + let segments_dir = dir.join("segments"); + std::fs::create_dir_all(&segments_dir).unwrap(); + EventLog::open( + EventLogConfig { + segments_dir, + ..EventLogConfig::default() + }, + RealIO::new(), + ) + .unwrap() +} + +struct PhaseTimings { + raw_read_ns: Vec, + decode_payload_ns: Vec, + ops_json_ns: Vec, + did_parse_ns: Vec, + full_conversion_ns: Vec, + total_get_events_ns: Vec, +} + +impl PhaseTimings { + fn new(capacity: usize) -> Self { + Self { + raw_read_ns: Vec::with_capacity(capacity), + decode_payload_ns: Vec::with_capacity(capacity), + ops_json_ns: Vec::with_capacity(capacity), + did_parse_ns: Vec::with_capacity(capacity), + full_conversion_ns: Vec::with_capacity(capacity), + total_get_events_ns: Vec::with_capacity(capacity), + } + } +} + +fn percentile(sorted: &[u64], pct: f64) -> u64 { + if sorted.is_empty() { + return 0; + } + let idx = ((sorted.len() - 1) as f64 * pct / 100.0) as usize; + sorted[idx] +} + +fn report_phase(name: &str, values_ns: &mut [u64], event_count: usize) { + values_ns.sort(); + let total: u64 = values_ns.iter().sum(); + let per_event_ns = total as f64 / event_count as f64; + let p50 = percentile(values_ns, 50.0); + let p99 = percentile(values_ns, 99.0); + println!( + "{name}: {:.2}ms total, {per_event_ns:.0}ns/event, p50 {p50}ns, p99 {p99}ns", + total as f64 / 1_000_000.0, + ); +} + +fn profile_read_phases(event_count: usize, readers: usize) { + println!("-- read path profile: {event_count} events, {readers} readers --"); + + let dir = tempfile::TempDir::new().unwrap(); + let log = Arc::new(open_eventlog(dir.path())); + + let events: Vec = (0..event_count).map(make_event).collect(); + events.iter().enumerate().for_each(|(i, event)| { + log.append_event(&make_did(i % 10_000), RepoEventType::Commit, event) + .unwrap(); + }); + log.sync().unwrap(); + + println!("seeded {event_count} events"); + + let batch_size = 4096usize; + let iterations = 3; + + (0..iterations).for_each(|iter| { + println!("-- iteration {}/{iterations} --", iter + 1); + + let handles: Vec<_> = (0..readers) + .map(|_| { + let log = Arc::clone(&log); + std::thread::spawn(move || { + let reader = log.reader(); + let mut timings = PhaseTimings::new(event_count / batch_size + 1); + let mut total_events = 0usize; + + let mut cursor = EventSequence::BEFORE_ALL; + std::iter::from_fn(|| { + let t_total = Instant::now(); + + let t_raw = Instant::now(); + let raw_events = reader.read_events_from(cursor, batch_size).unwrap(); + let raw_read_elapsed = t_raw.elapsed(); + + if raw_events.is_empty() { + return None; + } + + let mut batch_decode_ns = 0u64; + let mut batch_ops_ns = 0u64; + let mut batch_did_ns = 0u64; + let mut batch_conversion_ns = 0u64; + + raw_events.iter().for_each(|raw| { + let t_decode = Instant::now(); + let payload = decode_payload(&raw.payload).unwrap(); + batch_decode_ns += t_decode.elapsed().as_nanos() as u64; + + let t_ops = Instant::now(); + let _ops: Option = payload + .ops + .as_ref() + .map(|bytes| serde_ipld_dagcbor::from_slice(bytes).unwrap()); + batch_ops_ns += t_ops.elapsed().as_nanos() as u64; + + let t_did = Instant::now(); + let _did = Did::new(&payload.did).unwrap(); + batch_did_ns += t_did.elapsed().as_nanos() as u64; + + let t_conversion = Instant::now(); + let payload2 = decode_payload(&raw.payload).unwrap(); + let _event = to_sequenced_event(raw, &payload2).unwrap(); + batch_conversion_ns += t_conversion.elapsed().as_nanos() as u64; + }); + + let batch_events = raw_events.len(); + cursor = EventSequence::new( + u64::try_from(raw_events.last().unwrap().seq.as_i64()).unwrap(), + ); + total_events += batch_events; + + timings.raw_read_ns.push(raw_read_elapsed.as_nanos() as u64); + timings.decode_payload_ns.push(batch_decode_ns); + timings.ops_json_ns.push(batch_ops_ns); + timings.did_parse_ns.push(batch_did_ns); + timings.full_conversion_ns.push(batch_conversion_ns); + timings + .total_get_events_ns + .push(t_total.elapsed().as_nanos() as u64); + + Some(()) + }) + .count(); + + (timings, total_events) + }) + }) + .collect(); + + let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect(); + + let total_events: usize = results.iter().map(|(_, count)| count).sum(); + + let mut agg = PhaseTimings::new(0); + results.iter().for_each(|(t, _)| { + agg.raw_read_ns.extend_from_slice(&t.raw_read_ns); + agg.decode_payload_ns + .extend_from_slice(&t.decode_payload_ns); + agg.ops_json_ns.extend_from_slice(&t.ops_json_ns); + agg.did_parse_ns.extend_from_slice(&t.did_parse_ns); + agg.full_conversion_ns + .extend_from_slice(&t.full_conversion_ns); + agg.total_get_events_ns + .extend_from_slice(&t.total_get_events_ns); + }); + + println!("{total_events} events across {readers} readers"); + report_phase("raw_read", &mut agg.raw_read_ns, total_events); + report_phase("full conversion", &mut agg.full_conversion_ns, total_events); + report_phase( + "postcard decode, isolated", + &mut agg.decode_payload_ns, + total_events, + ); + report_phase( + "DAG-CBOR ops parse, isolated", + &mut agg.ops_json_ns, + total_events, + ); + report_phase("DID parse, isolated", &mut agg.did_parse_ns, total_events); + report_phase( + "end-to-end total", + &mut agg.total_get_events_ns, + total_events, + ); + + let raw_total: u64 = agg.raw_read_ns.iter().sum(); + let conversion_total: u64 = agg.full_conversion_ns.iter().sum(); + let decode_total: u64 = agg.decode_payload_ns.iter().sum(); + let ops_total: u64 = agg.ops_json_ns.iter().sum(); + let did_total: u64 = agg.did_parse_ns.iter().sum(); + + let pipeline_total = raw_total + conversion_total; + let pct = |v: u64| v as f64 / pipeline_total as f64 * 100.0; + let conversion_other = + conversion_total.saturating_sub(decode_total + ops_total + did_total); + println!( + "breakdown: raw_read {:.1}%, postcard {:.1}%, dagcbor_ops {:.1}%, did {:.1}%, rest {:.1}%", + pct(raw_total), + pct(decode_total), + pct(ops_total), + pct(did_total), + pct(conversion_other), + ); + }); + + let _ = log.shutdown(); +} + +fn profile_decode_phases(event_count: usize) { + println!("-- decode phase isolation: {event_count} events --"); + + let dir = tempfile::TempDir::new().unwrap(); + let log = open_eventlog(dir.path()); + + let events: Vec = (0..event_count).map(make_event).collect(); + events.iter().enumerate().for_each(|(i, event)| { + log.append_event(&make_did(i % 10_000), RepoEventType::Commit, event) + .unwrap(); + }); + log.sync().unwrap(); + + let reader = log.reader(); + let raw_events = reader + .read_events_from(EventSequence::BEFORE_ALL, event_count) + .unwrap(); + + println!("{} raw events pre-loaded", raw_events.len()); + + (0..5).for_each(|_| { + let t_decode = Instant::now(); + let payloads: Vec<_> = raw_events + .iter() + .map(|raw| decode_payload(&raw.payload).unwrap()) + .collect(); + let decode_elapsed = t_decode.elapsed(); + + let t_convert = Instant::now(); + let _events: Vec<_> = raw_events + .iter() + .zip(payloads.iter()) + .map(|(raw, payload)| to_sequenced_event(raw, payload).unwrap()) + .collect(); + let convert_elapsed = t_convert.elapsed(); + + let t_ops_only = Instant::now(); + let _: Vec<_> = payloads + .iter() + .map(|p| { + p.ops.as_ref().map(|bytes| { + serde_ipld_dagcbor::from_slice::(bytes).unwrap() + }) + }) + .collect(); + let ops_elapsed = t_ops_only.elapsed(); + + let n = raw_events.len() as f64; + println!( + "postcard {:.0}ns/evt, to_sequenced_event {:.0}ns/evt, dagcbor_ops {:.0}ns/evt", + decode_elapsed.as_nanos() as f64 / n, + convert_elapsed.as_nanos() as f64 / n, + ops_elapsed.as_nanos() as f64 / n, + ); + }); + + let _ = log.shutdown(); +} + +fn main() { + println!("-- eventlog read path profiler --"); + let cpus = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(8); + println!("available parallelism: {cpus}"); + + let event_count = std::env::var("PROFILE_EVENTS") + .ok() + .and_then(|s| s.replace('_', "").parse().ok()) + .unwrap_or(100_000usize); + + let reader_count = std::env::var("PROFILE_READERS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(4usize); + + profile_read_phases(event_count, reader_count); + println!(); + profile_decode_phases(event_count); +} diff --git a/crates/tranquil-store/benches/profile_reads.rs b/crates/tranquil-store/benches/profile_reads.rs index 349d7d6..bf1919a 100644 --- a/crates/tranquil-store/benches/profile_reads.rs +++ b/crates/tranquil-store/benches/profile_reads.rs @@ -128,7 +128,7 @@ async fn seed_records(pool: &Arc, users: &[UserInfo], records_per_u prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev0000000001".to_string()), }, diff --git a/crates/tranquil-store/benches/recovery.rs b/crates/tranquil-store/benches/recovery.rs new file mode 100644 index 0000000..f9fb278 --- /dev/null +++ b/crates/tranquil-store/benches/recovery.rs @@ -0,0 +1,270 @@ +use std::path::Path; +use std::time::Instant; + +use tranquil_store::blockstore::hash_index::BlockIndex; +use tranquil_store::blockstore::{ + CidBytes, DEFAULT_MAX_FILE_SIZE, DataFileId, DataFileWriter, HintFileWriter, hint_file_path, + scan_hints_to_memory, +}; +use tranquil_store::{OpenOptions, RealIO, StorageIO}; + +#[global_allocator] +static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; + +fn test_cid(seed: u32) -> CidBytes { + let le = seed.to_le_bytes(); + std::array::from_fn(|i| match i { + 0 => 0x01, + 1 => 0x71, + 2 => 0x12, + 3 => 0x20, + 4..8 => le[i - 4], + _ => (seed as u8).wrapping_add(i as u8), + }) +} + +fn block_data(seed: u32) -> Vec { + let tag = seed.to_le_bytes(); + std::iter::repeat(tag).flatten().take(256).collect() +} + +struct DirectSeeder<'a> { + io: &'a RealIO, + data_dir: &'a Path, + file_id: DataFileId, + data_writer: DataFileWriter<'a, RealIO>, + hint_writer: HintFileWriter<'a, RealIO>, + blocks_in_file: u64, +} + +impl<'a> DirectSeeder<'a> { + fn new(io: &'a RealIO, data_dir: &'a Path) -> Self { + std::fs::create_dir_all(data_dir).unwrap(); + let file_id = DataFileId::new(0); + + let data_fd = io + .open( + &data_dir.join(format!("{file_id}.tqb")), + OpenOptions::read_write(), + ) + .unwrap(); + let data_writer = DataFileWriter::new(io, data_fd, file_id).unwrap(); + + let hint_fd = io + .open( + &hint_file_path(data_dir, file_id), + OpenOptions::read_write(), + ) + .unwrap(); + let hint_writer = HintFileWriter::new(io, hint_fd); + + Self { + io, + data_dir, + file_id, + data_writer, + hint_writer, + blocks_in_file: 0, + } + } + + fn rotate(&mut self) { + self.data_writer.sync().unwrap(); + self.hint_writer.sync().unwrap(); + + self.file_id = self.file_id.next(); + + let data_fd = self + .io + .open( + &self.data_dir.join(format!("{}.tqb", self.file_id)), + OpenOptions::read_write(), + ) + .unwrap(); + self.data_writer = DataFileWriter::new(self.io, data_fd, self.file_id).unwrap(); + + let hint_fd = self + .io + .open( + &hint_file_path(self.data_dir, self.file_id), + OpenOptions::read_write(), + ) + .unwrap(); + self.hint_writer = HintFileWriter::new(self.io, hint_fd); + self.blocks_in_file = 0; + } + + fn append(&mut self, cid: &CidBytes, data: &[u8]) { + if self.data_writer.position().raw() > DEFAULT_MAX_FILE_SIZE { + self.rotate(); + } + + let loc = self.data_writer.append_block(cid, data).unwrap(); + self.hint_writer + .append_hint(cid, loc.file_id, loc.offset, loc.length) + .unwrap(); + self.blocks_in_file += 1; + + if self.blocks_in_file.is_multiple_of(10_000) { + self.data_writer.sync().unwrap(); + self.hint_writer.sync().unwrap(); + } + } + + fn finish(&mut self) { + self.data_writer.sync().unwrap(); + self.hint_writer.sync().unwrap(); + self.io.sync_dir(self.data_dir).unwrap(); + } +} + +fn seed_blocks_direct(data_dir: &Path, count: u32) { + let io = RealIO::new(); + let mut seeder = DirectSeeder::new(&io, data_dir); + (0..count).for_each(|i| { + let cid = test_cid(i); + let data = block_data(i); + seeder.append(&cid, &data); + }); + seeder.finish(); +} + +fn read_rss_mb() -> f64 { + std::fs::read_to_string("/proc/self/status") + .ok() + .and_then(|s| { + s.lines().find(|l| l.starts_with("VmRSS:")).and_then(|l| { + l.split_whitespace() + .nth(1) + .and_then(|v| v.parse::().ok()) + }) + }) + .map(|kb| kb / 1024.0) + .unwrap_or(0.0) +} + +fn bench_hint_scan_only(data_dir: &Path, block_count: u32) { + let io = RealIO::new(); + + let rss_before = read_rss_mb(); + let start = Instant::now(); + let (hint_index, _cursor) = scan_hints_to_memory(&io, data_dir).unwrap(); + let elapsed = start.elapsed(); + let rss_after = read_rss_mb(); + + let entry_count = hint_index.len(); + let rss_delta = rss_after - rss_before; + let bytes_per_entry = match entry_count { + 0 => 0.0, + n => (rss_delta * 1024.0 * 1024.0) / n as f64, + }; + + println!( + "hint scan to memory ({block_count} blocks): {:.3}s ({:.0} blocks/sec)", + elapsed.as_secs_f64(), + block_count as f64 / elapsed.as_secs_f64(), + ); + println!( + " entries: {entry_count}, RSS: {rss_before:.1}MB -> {rss_after:.1}MB (delta: {rss_delta:.1}MB, {bytes_per_entry:.0} bytes/entry)" + ); + + drop(hint_index); + let rss_after_drop = read_rss_mb(); + println!(" RSS after drop: {rss_after_drop:.1}MB"); +} + +fn bench_hash_table_rebuild_from_hints(data_dir: &Path, index_dir: &Path, block_count: u32) { + let io = RealIO::new(); + let index = BlockIndex::open(index_dir).unwrap(); + + let rss_before = read_rss_mb(); + let start = Instant::now(); + index.rebuild_from_hints(&io, data_dir).unwrap(); + let elapsed = start.elapsed(); + let rss_after = read_rss_mb(); + + println!( + "hash table rebuild from hints ({block_count} blocks): {:.3}s ({:.0} blocks/sec)", + elapsed.as_secs_f64(), + block_count as f64 / elapsed.as_secs_f64(), + ); + println!( + " RSS: {rss_before:.1}MB -> {rss_after:.1}MB (delta: {:.1}MB)", + rss_after - rss_before, + ); +} + +fn bench_hash_table_rebuild_from_data_files(data_dir: &Path, index_dir: &Path, block_count: u32) { + let io = RealIO::new(); + let index = BlockIndex::open(index_dir).unwrap(); + + let start = Instant::now(); + index.rebuild_from_data_files(&io, data_dir).unwrap(); + let elapsed = start.elapsed(); + + println!( + "hash table rebuild from data files ({block_count} blocks): {:.3}s ({:.0} blocks/sec)", + elapsed.as_secs_f64(), + block_count as f64 / elapsed.as_secs_f64(), + ); +} + +fn nuke_index(index_dir: &Path) { + if index_dir.exists() { + std::fs::remove_dir_all(index_dir).unwrap(); + } + std::fs::create_dir_all(index_dir).unwrap(); +} + +fn run_scale(block_count: u32) { + let label = match block_count { + n if n >= 10_000_000 => format!("{}M blocks", n / 1_000_000), + n if n >= 1_000_000 => format!("{}M blocks", n / 1_000_000), + n => format!("{}K blocks", n / 1_000), + }; + println!("\n-- {label} --"); + + let dir = tempfile::TempDir::new().unwrap(); + let data_dir = dir.path().join("data"); + let index_dir = dir.path().join("index"); + + println!("seeding {block_count} blocks, direct without index..."); + let seed_start = Instant::now(); + seed_blocks_direct(&data_dir, block_count); + println!( + " blocks seeded in {:.1}s", + seed_start.elapsed().as_secs_f64() + ); + + println!("\n-- hint scan to memory --"); + bench_hint_scan_only(&data_dir, block_count); + + println!("\n-- hash table rebuild from hints --"); + nuke_index(&index_dir); + bench_hash_table_rebuild_from_hints(&data_dir, &index_dir, block_count); + + println!("\n-- hash table rebuild from data files --"); + nuke_index(&index_dir); + bench_hash_table_rebuild_from_data_files(&data_dir, &index_dir, block_count); +} + +fn parse_scales(input: &str) -> Vec { + input + .split(';') + .map(|s| s.trim().replace('_', "").parse::().unwrap()) + .collect() +} + +fn main() { + let scales = parse_scales( + &std::env::var("BENCH_RECOVERY_SCALES") + .unwrap_or_else(|_| "100_000; 1_000_000; 10_000_000".into()), + ); + + println!("recovery performance benchmark, hash table index :3"); + println!("scales: {scales:?}"); + + scales.iter().for_each(|&blocks| { + run_scale(blocks); + }); +} diff --git a/crates/tranquil-store/src/archival.rs b/crates/tranquil-store/src/archival.rs new file mode 100644 index 0000000..29a11e7 --- /dev/null +++ b/crates/tranquil-store/src/archival.rs @@ -0,0 +1,547 @@ +use std::io; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use tracing::{debug, info, warn}; + +use crate::eventlog::{SEGMENT_FILE_EXTENSION, SegmentId, parse_segment_id, segment_path}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArchivalState { + pub last_archived_segment: Option, +} + +impl ArchivalState { + fn empty() -> Self { + Self { + last_archived_segment: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ArchivalPassResult { + pub segments_archived: u32, + pub bytes_archived: u64, +} + +pub trait ArchivalDestination: Send + Sync { + fn store_segment(&self, segment_id: SegmentId, data: &[u8]) -> io::Result<()>; +} + +pub struct LocalArchivalDestination { + dest_dir: PathBuf, +} + +impl LocalArchivalDestination { + pub fn new(dest_dir: PathBuf) -> io::Result { + std::fs::create_dir_all(&dest_dir)?; + Ok(Self { dest_dir }) + } +} + +impl ArchivalDestination for LocalArchivalDestination { + fn store_segment(&self, segment_id: SegmentId, data: &[u8]) -> io::Result<()> { + let dest_path = segment_path(&self.dest_dir, segment_id); + let tmp_path = dest_path.with_extension(format!("{SEGMENT_FILE_EXTENSION}.tmp")); + + std::fs::write(&tmp_path, data)?; + + let f = std::fs::File::open(&tmp_path)?; + f.sync_all()?; + drop(f); + + std::fs::rename(&tmp_path, &dest_path)?; + + sync_dir(&self.dest_dir)?; + + Ok(()) + } +} + +fn sync_dir(dir: &Path) -> io::Result<()> { + let d = std::fs::File::open(dir)?; + d.sync_all() +} + +fn list_segment_files(segments_dir: &Path) -> io::Result> { + let entries = match std::fs::read_dir(segments_dir) { + Ok(entries) => entries, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e), + }; + + let mut ids: Vec = entries + .filter_map(|entry| parse_segment_id(&entry.ok()?.path())) + .collect(); + ids.sort(); + Ok(ids) +} + +pub struct ArchivalSidecar { + path: PathBuf, +} + +impl ArchivalSidecar { + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + pub fn load(&self) -> io::Result { + match std::fs::read(&self.path) { + Ok(data) => serde_json::from_slice(&data) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(ArchivalState::empty()), + Err(e) => Err(e), + } + } + + pub fn save(&self, state: &ArchivalState) -> io::Result<()> { + let json = + serde_json::to_vec(state).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + let tmp_path = self.path.with_extension("tmp"); + std::fs::write(&tmp_path, &json)?; + let f = std::fs::File::open(&tmp_path)?; + f.sync_all()?; + drop(f); + std::fs::rename(&tmp_path, &self.path)?; + + self.path.parent().map(sync_dir).transpose()?; + + Ok(()) + } +} + +pub struct ContinuousArchiver { + segments_dir: PathBuf, + sidecar: ArchivalSidecar, + destination: Box, +} + +impl ContinuousArchiver { + pub fn new( + segments_dir: PathBuf, + sidecar_path: PathBuf, + destination: Box, + ) -> Self { + Self { + segments_dir, + sidecar: ArchivalSidecar::new(sidecar_path), + destination, + } + } + + pub fn run_pass(&self) -> io::Result { + let state = self.sidecar.load()?; + + let all_segments = list_segment_files(&self.segments_dir)?; + + let sealed_segments = match all_segments.len() { + 0 | 1 => Vec::new(), + n => all_segments[..n - 1].to_vec(), + }; + + let new_segments: Vec = match state.last_archived_segment { + Some(last) => sealed_segments + .into_iter() + .filter(|&id| id > last) + .collect(), + None => sealed_segments, + }; + + if new_segments.is_empty() { + debug!("no new sealed segments to archive"); + return Ok(ArchivalPassResult { + segments_archived: 0, + bytes_archived: 0, + }); + } + + let mut segments_archived = 0u32; + let mut bytes_archived = 0u64; + + let result = new_segments.iter().try_for_each(|&seg_id| { + let path = segment_path(&self.segments_dir, seg_id); + let data = std::fs::read(&path)?; + let size = data.len() as u64; + + self.destination.store_segment(seg_id, &data)?; + + self.sidecar.save(&ArchivalState { + last_archived_segment: Some(seg_id), + })?; + + segments_archived = segments_archived.saturating_add(1); + bytes_archived = bytes_archived.saturating_add(size); + + info!( + segment_id = %seg_id, + size_bytes = size, + "archived sealed segment" + ); + + Ok::<(), io::Error>(()) + }); + + match result { + Ok(()) => {} + Err(e) => { + warn!( + segments_archived, + bytes_archived, + error = %e, + "archival pass interrupted after partial progress" + ); + return Err(e); + } + } + + Ok(ArchivalPassResult { + segments_archived, + bytes_archived, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::{Arc, Mutex}; + + type ArchivedSegments = Arc)>>>; + + #[derive(Clone)] + struct CollectingDestination { + stored: ArchivedSegments, + } + + impl CollectingDestination { + fn new() -> Self { + Self { + stored: Arc::new(Mutex::new(Vec::new())), + } + } + + fn stored_ids(&self) -> Vec { + self.stored + .lock() + .unwrap() + .iter() + .map(|(id, _)| *id) + .collect() + } + } + + impl ArchivalDestination for CollectingDestination { + fn store_segment(&self, segment_id: SegmentId, data: &[u8]) -> io::Result<()> { + self.stored + .lock() + .unwrap() + .push((segment_id, data.to_vec())); + Ok(()) + } + } + + fn create_segment_file(dir: &Path, id: u32, content: &[u8]) { + let path = dir.join(format!("{:08}.{SEGMENT_FILE_EXTENSION}", id)); + std::fs::write(path, content).unwrap(); + } + + #[test] + fn sidecar_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let sidecar = ArchivalSidecar::new(dir.path().join("archival.state")); + + let state = sidecar.load().unwrap(); + assert!(state.last_archived_segment.is_none()); + + let updated = ArchivalState { + last_archived_segment: Some(SegmentId::new(42)), + }; + sidecar.save(&updated).unwrap(); + + let loaded = sidecar.load().unwrap(); + assert_eq!(loaded.last_archived_segment, Some(SegmentId::new(42))); + } + + #[test] + fn sidecar_missing_file_returns_empty() { + let dir = tempfile::tempdir().unwrap(); + let sidecar = ArchivalSidecar::new(dir.path().join("nonexistent.state")); + let state = sidecar.load().unwrap(); + assert!(state.last_archived_segment.is_none()); + } + + #[test] + fn list_segment_files_sorts_ascending() { + let dir = tempfile::tempdir().unwrap(); + create_segment_file(dir.path(), 5, b"e"); + create_segment_file(dir.path(), 1, b"a"); + create_segment_file(dir.path(), 3, b"c"); + std::fs::write(dir.path().join("notes.txt"), b"ignored").unwrap(); + + let ids = list_segment_files(dir.path()).unwrap(); + assert_eq!( + ids, + vec![SegmentId::new(1), SegmentId::new(3), SegmentId::new(5)] + ); + } + + #[test] + fn list_segment_files_empty_dir() { + let dir = tempfile::tempdir().unwrap(); + let ids = list_segment_files(dir.path()).unwrap(); + assert!(ids.is_empty()); + } + + #[test] + fn list_segment_files_missing_dir() { + let ids = list_segment_files(Path::new("/nonexistent/dir")).unwrap(); + assert!(ids.is_empty()); + } + + #[test] + fn no_segments_no_archival() { + let dir = tempfile::tempdir().unwrap(); + let seg_dir = dir.path().join("segments"); + std::fs::create_dir_all(&seg_dir).unwrap(); + + let dest = CollectingDestination::new(); + let dest_check = dest.clone(); + + let archiver = + ContinuousArchiver::new(seg_dir, dir.path().join("archival.state"), Box::new(dest)); + + let result = archiver.run_pass().unwrap(); + assert_eq!(result.segments_archived, 0); + assert_eq!(result.bytes_archived, 0); + assert!(dest_check.stored_ids().is_empty()); + } + + #[test] + fn single_active_segment_not_archived() { + let dir = tempfile::tempdir().unwrap(); + let seg_dir = dir.path().join("segments"); + std::fs::create_dir_all(&seg_dir).unwrap(); + create_segment_file(&seg_dir, 0, b"active segment data"); + + let dest = CollectingDestination::new(); + let dest_check = dest.clone(); + + let archiver = + ContinuousArchiver::new(seg_dir, dir.path().join("archival.state"), Box::new(dest)); + + let result = archiver.run_pass().unwrap(); + assert_eq!(result.segments_archived, 0); + assert!(dest_check.stored_ids().is_empty()); + } + + #[test] + fn archives_sealed_segments() { + let dir = tempfile::tempdir().unwrap(); + let seg_dir = dir.path().join("segments"); + std::fs::create_dir_all(&seg_dir).unwrap(); + create_segment_file(&seg_dir, 0, b"sealed-0"); + create_segment_file(&seg_dir, 1, b"sealed-1"); + create_segment_file(&seg_dir, 2, b"active"); + + let dest = CollectingDestination::new(); + let dest_check = dest.clone(); + + let archiver = + ContinuousArchiver::new(seg_dir, dir.path().join("archival.state"), Box::new(dest)); + + let result = archiver.run_pass().unwrap(); + assert_eq!(result.segments_archived, 2); + assert_eq!(result.bytes_archived, 16); + + let stored = dest_check.stored_ids(); + assert_eq!(stored, vec![SegmentId::new(0), SegmentId::new(1)]); + } + + #[test] + fn incremental_archival_skips_already_archived() { + let dir = tempfile::tempdir().unwrap(); + let seg_dir = dir.path().join("segments"); + std::fs::create_dir_all(&seg_dir).unwrap(); + create_segment_file(&seg_dir, 0, b"sealed-0"); + create_segment_file(&seg_dir, 1, b"sealed-1"); + create_segment_file(&seg_dir, 2, b"sealed-2"); + create_segment_file(&seg_dir, 3, b"active"); + + let sidecar_path = dir.path().join("archival.state"); + ArchivalSidecar::new(sidecar_path.clone()) + .save(&ArchivalState { + last_archived_segment: Some(SegmentId::new(0)), + }) + .unwrap(); + + let dest = CollectingDestination::new(); + let dest_check = dest.clone(); + + let archiver = ContinuousArchiver::new(seg_dir, sidecar_path.clone(), Box::new(dest)); + + let result = archiver.run_pass().unwrap(); + assert_eq!(result.segments_archived, 2); + + let stored = dest_check.stored_ids(); + assert_eq!(stored, vec![SegmentId::new(1), SegmentId::new(2)]); + + let final_state = ArchivalSidecar::new(sidecar_path).load().unwrap(); + assert_eq!(final_state.last_archived_segment, Some(SegmentId::new(2))); + } + + #[test] + fn sidecar_updated_per_segment_for_crash_safety() { + let dir = tempfile::tempdir().unwrap(); + let seg_dir = dir.path().join("segments"); + std::fs::create_dir_all(&seg_dir).unwrap(); + create_segment_file(&seg_dir, 0, b"sealed-0"); + create_segment_file(&seg_dir, 1, b"sealed-1"); + create_segment_file(&seg_dir, 2, b"active"); + + struct FailOnSecondDestination { + call_count: Mutex, + } + impl ArchivalDestination for FailOnSecondDestination { + fn store_segment(&self, _id: SegmentId, _data: &[u8]) -> io::Result<()> { + let mut count = self.call_count.lock().unwrap(); + *count += 1; + match *count { + 1 => Ok(()), + _ => Err(io::Error::other("simulated failure")), + } + } + } + + let sidecar_path = dir.path().join("archival.state"); + + let archiver = ContinuousArchiver::new( + seg_dir, + sidecar_path.clone(), + Box::new(FailOnSecondDestination { + call_count: Mutex::new(0), + }), + ); + + let err = archiver.run_pass().unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::Other); + + let state = ArchivalSidecar::new(sidecar_path).load().unwrap(); + assert_eq!(state.last_archived_segment, Some(SegmentId::new(0))); + } + + #[test] + fn idempotent_rerun_after_full_archival() { + let dir = tempfile::tempdir().unwrap(); + let seg_dir = dir.path().join("segments"); + std::fs::create_dir_all(&seg_dir).unwrap(); + create_segment_file(&seg_dir, 0, b"sealed-0"); + create_segment_file(&seg_dir, 1, b"sealed-1"); + create_segment_file(&seg_dir, 2, b"active"); + + let sidecar_path = dir.path().join("archival.state"); + + let dest1 = CollectingDestination::new(); + let dest1_check = dest1.clone(); + let archiver1 = + ContinuousArchiver::new(seg_dir.clone(), sidecar_path.clone(), Box::new(dest1)); + archiver1.run_pass().unwrap(); + assert_eq!(dest1_check.stored_ids().len(), 2); + + let dest2 = CollectingDestination::new(); + let dest2_check = dest2.clone(); + let archiver2 = ContinuousArchiver::new(seg_dir, sidecar_path, Box::new(dest2)); + let result = archiver2.run_pass().unwrap(); + assert_eq!(result.segments_archived, 0); + assert!(dest2_check.stored_ids().is_empty()); + } + + #[test] + fn new_segments_after_initial_archival() { + let dir = tempfile::tempdir().unwrap(); + let seg_dir = dir.path().join("segments"); + std::fs::create_dir_all(&seg_dir).unwrap(); + create_segment_file(&seg_dir, 0, b"sealed-0"); + create_segment_file(&seg_dir, 1, b"active"); + + let sidecar_path = dir.path().join("archival.state"); + + let dest1 = CollectingDestination::new(); + let archiver1 = + ContinuousArchiver::new(seg_dir.clone(), sidecar_path.clone(), Box::new(dest1)); + let r1 = archiver1.run_pass().unwrap(); + assert_eq!(r1.segments_archived, 1); + + create_segment_file(&seg_dir, 2, b"new-active"); + + let dest2 = CollectingDestination::new(); + let dest2_check = dest2.clone(); + let archiver2 = ContinuousArchiver::new(seg_dir, sidecar_path, Box::new(dest2)); + let r2 = archiver2.run_pass().unwrap(); + assert_eq!(r2.segments_archived, 1); + assert_eq!(dest2_check.stored_ids(), vec![SegmentId::new(1)]); + } + + #[test] + fn local_destination_writes_files() { + let dir = tempfile::tempdir().unwrap(); + let dest_dir = dir.path().join("archive"); + + let dest = LocalArchivalDestination::new(dest_dir.clone()).unwrap(); + + let payload = b"segment data here"; + dest.store_segment(SegmentId::new(5), payload).unwrap(); + + let written = + std::fs::read(dest_dir.join(format!("00000005.{SEGMENT_FILE_EXTENSION}"))).unwrap(); + assert_eq!(written, payload); + } + + #[test] + fn local_destination_atomic_overwrite() { + let dir = tempfile::tempdir().unwrap(); + let dest_dir = dir.path().join("archive"); + + let dest = LocalArchivalDestination::new(dest_dir.clone()).unwrap(); + + dest.store_segment(SegmentId::new(1), b"first").unwrap(); + dest.store_segment(SegmentId::new(1), b"second").unwrap(); + + let written = + std::fs::read(dest_dir.join(format!("00000001.{SEGMENT_FILE_EXTENSION}"))).unwrap(); + assert_eq!(written, b"second"); + + assert!( + !dest_dir + .join(format!("00000001.{SEGMENT_FILE_EXTENSION}.tmp")) + .exists() + ); + } + + #[test] + fn archived_data_matches_source() { + let dir = tempfile::tempdir().unwrap(); + let seg_dir = dir.path().join("segments"); + std::fs::create_dir_all(&seg_dir).unwrap(); + + let content_0 = b"sealed segment zero content with some bulk data"; + let content_1 = b"sealed segment one with different content"; + create_segment_file(&seg_dir, 0, content_0); + create_segment_file(&seg_dir, 1, content_1); + create_segment_file(&seg_dir, 2, b"active"); + + let dest = CollectingDestination::new(); + let dest_check = dest.clone(); + + let archiver = + ContinuousArchiver::new(seg_dir, dir.path().join("archival.state"), Box::new(dest)); + archiver.run_pass().unwrap(); + + let stored = dest_check.stored.lock().unwrap(); + assert_eq!(stored[0].1, content_0); + assert_eq!(stored[1].1, content_1); + } +} diff --git a/crates/tranquil-store/src/backup.rs b/crates/tranquil-store/src/backup.rs new file mode 100644 index 0000000..14046cd --- /dev/null +++ b/crates/tranquil-store/src/backup.rs @@ -0,0 +1,1756 @@ +use std::collections::HashSet; +use std::io::{self, Read}; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::blockstore::{ + BlockOffset, BlockstoreSnapshot, CommitEpoch, CommitError, DataFileId, QuiesceGuard, + RebuildError, TranquilBlockStore, +}; +use crate::eventlog::{ + EventLog, EventLogConfig, EventLogFreezeGuard, EventLogSnapshotState, EventSequence, + EventWithMutations, SegmentId, SegmentOffset, +}; +use crate::io::{RealIO, StorageIO}; +use crate::metastore::event_keys::{did_events_key, metastore_cursor_key, rev_to_seq_key}; +use crate::metastore::keys::UserHash; +use crate::metastore::partitions::Partition; +use crate::metastore::recovery::{CommitMutationSet, replay_mutation_set}; +use crate::metastore::repo_meta::{RepoMetaValue, RepoStatus, repo_meta_key}; +use crate::metastore::{Metastore, MetastoreConfig, MetastoreError}; + +const BACKUP_FORMAT_VERSION: u32 = 1; +const MANIFEST_FILENAME: &str = "backup.manifest"; +const BLOCKS_DIR: &str = "blocks"; +const EVENTS_DIR: &str = "events"; +const METASTORE_DIR: &str = "metastore"; +const INDEX_DIR: &str = "block_index"; +const LOCK_FILE_NAME: &str = ".lock"; + +#[derive(Debug, thiserror::Error)] +pub enum BackupError { + #[error("backup io: {0}")] + Io(#[from] io::Error), + #[error("blockstore quiesce failed: {0}")] + Quiesce(#[from] CommitError), + #[error("metastore persist failed: {0}")] + Metastore(#[from] MetastoreError), + #[error("index error: {0}")] + Index(io::Error), + #[error("index rebuild failed: {0}")] + IndexRebuild(#[from] RebuildError), + #[error("checksum mismatch for {path}: expected {expected:#018x}, got {actual:#018x}")] + ChecksumMismatch { + path: String, + expected: u64, + actual: u64, + }, + #[error("size mismatch for {path}: expected {expected} bytes, got {actual}")] + SizeMismatch { + path: String, + expected: u64, + actual: u64, + }, + #[error("missing file in backup: {0}")] + MissingFile(String), + #[error("restore verification failed: {0}")] + RestoreVerification(String), + #[error("target directory not empty: {0}")] + TargetNotEmpty(String), + #[error("incremental chain mismatch: {0}")] + ChainMismatch(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct MetastoreSeqno(u64); + +impl MetastoreSeqno { + pub fn new(seqno: u64) -> Self { + Self(seqno) + } + + pub fn raw(self) -> u64 { + self.0 + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum BackupKind { + #[default] + Full, + Incremental, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackupManifest { + pub version: u32, + pub created_at_ms: u64, + pub blockstore: BlockstoreManifest, + pub eventlog: EventLogManifest, + pub metastore_seqno: MetastoreSeqno, + pub files: Vec, + #[serde(default)] + pub kind: BackupKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_blockstore: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_eventlog: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BlockstoreManifest { + pub write_cursor_file_id: DataFileId, + pub write_cursor_offset: BlockOffset, + pub epoch: CommitEpoch, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub shard_cursors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShardCursorEntry { + pub file_id: DataFileId, + pub offset: BlockOffset, +} + +impl BlockstoreManifest { + pub fn min_active_file_id(&self) -> DataFileId { + match self.shard_cursors.is_empty() { + true => self.write_cursor_file_id, + false => self + .shard_cursors + .iter() + .map(|c| c.file_id) + .min() + .unwrap_or(self.write_cursor_file_id), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EventLogManifest { + pub max_seq: EventSequence, + pub active_segment_id: SegmentId, + pub active_segment_position: SegmentOffset, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackupFileEntry { + pub path: String, + pub size: u64, + pub xxh3_checksum: u64, +} + +struct ConsistentSnapshot { + blockstore: BlockstoreSnapshot, + eventlog: EventLogSnapshotState, + metastore_seqno: MetastoreSeqno, + metastore_files: Vec, + quiesce_guard: QuiesceGuard, + _eventlog_guard: EventLogFreezeGuard, +} + +enum BackupLineage<'a> { + Full, + Incremental { base: &'a BackupManifest }, +} + +pub struct BackupCoordinator<'a, S: StorageIO> { + blockstore: &'a TranquilBlockStore, + eventlog: &'a EventLog, + metastore: &'a Metastore, +} + +impl<'a, S: StorageIO + Send + Sync + 'static> BackupCoordinator<'a, S> { + pub fn new( + blockstore: &'a TranquilBlockStore, + eventlog: &'a EventLog, + metastore: &'a Metastore, + ) -> Self { + Self { + blockstore, + eventlog, + metastore, + } + } + + pub fn create_backup(&self, destination: &Path) -> Result { + std::fs::create_dir_all(destination)?; + + let ConsistentSnapshot { + blockstore: bs, + eventlog: el, + metastore_seqno, + metastore_files, + quiesce_guard, + _eventlog_guard: eventlog_guard, + } = self.take_consistent_snapshot(destination)?; + + let mut files = metastore_files; + + copy_blockstore_files( + &bs, + DataFileId::new(0), + self.blockstore.data_dir(), + &destination.join(BLOCKS_DIR), + &mut files, + )?; + + copy_eventlog_files( + &el, + SegmentId::new(0), + self.eventlog.segments_dir(), + &destination.join(EVENTS_DIR), + &mut files, + )?; + + quiesce_guard.resume(); + drop(eventlog_guard); + + let manifest = self.build_manifest(bs, el, metastore_seqno, files, BackupLineage::Full); + + write_manifest(&manifest, destination)?; + + Ok(manifest) + } + + pub fn create_incremental_backup( + &self, + base: &BackupManifest, + destination: &Path, + ) -> Result { + std::fs::create_dir_all(destination)?; + + let ConsistentSnapshot { + blockstore: bs, + eventlog: el, + metastore_seqno, + metastore_files, + quiesce_guard, + _eventlog_guard: eventlog_guard, + } = self.take_consistent_snapshot(destination)?; + + let mut files = metastore_files; + + copy_blockstore_files( + &bs, + base.blockstore.min_active_file_id(), + self.blockstore.data_dir(), + &destination.join(BLOCKS_DIR), + &mut files, + )?; + + copy_eventlog_files( + &el, + base.eventlog.active_segment_id, + self.eventlog.segments_dir(), + &destination.join(EVENTS_DIR), + &mut files, + )?; + + quiesce_guard.resume(); + drop(eventlog_guard); + + let manifest = self.build_manifest( + bs, + el, + metastore_seqno, + files, + BackupLineage::Incremental { base }, + ); + + write_manifest(&manifest, destination)?; + + Ok(manifest) + } + + fn take_consistent_snapshot( + &self, + destination: &Path, + ) -> Result { + let (bs_snapshot, quiesce_guard) = self.blockstore.quiesce()?; + + let (el_snapshot, eventlog_guard) = self.eventlog.freeze()?; + + self.metastore.persist()?; + let metastore_seqno = MetastoreSeqno::new(self.metastore.database().seqno()); + + let mut metastore_files = Vec::new(); + copy_metastore_files( + self.metastore.path(), + &destination.join(METASTORE_DIR), + &mut metastore_files, + )?; + + Ok(ConsistentSnapshot { + blockstore: bs_snapshot, + eventlog: el_snapshot, + metastore_seqno, + metastore_files, + quiesce_guard, + _eventlog_guard: eventlog_guard, + }) + } + + fn build_manifest( + &self, + bs: BlockstoreSnapshot, + el: EventLogSnapshotState, + metastore_seqno: MetastoreSeqno, + files: Vec, + lineage: BackupLineage<'_>, + ) -> BackupManifest { + let (kind, base_blockstore, base_eventlog) = match lineage { + BackupLineage::Full => (BackupKind::Full, None, None), + BackupLineage::Incremental { base } => ( + BackupKind::Incremental, + Some(base.blockstore.clone()), + Some(base.eventlog.clone()), + ), + }; + + BackupManifest { + version: BACKUP_FORMAT_VERSION, + created_at_ms: crate::wall_clock_ms().raw(), + blockstore: { + let max_cursor = bs + .shard_cursors + .iter() + .max_by_key(|c| (c.file_id, c.offset)) + .copied(); + let shard_cursor_entries: Vec = bs + .shard_cursors + .iter() + .map(|c| ShardCursorEntry { + file_id: c.file_id, + offset: c.offset, + }) + .collect(); + BlockstoreManifest { + write_cursor_file_id: max_cursor + .map(|c| c.file_id) + .unwrap_or(DataFileId::new(0)), + write_cursor_offset: max_cursor + .map(|c| c.offset) + .unwrap_or(BlockOffset::new(0)), + epoch: bs.epoch, + shard_cursors: shard_cursor_entries, + } + }, + eventlog: EventLogManifest { + max_seq: el.max_seq, + active_segment_id: el.active_segment_id, + active_segment_position: el.active_segment_position, + }, + metastore_seqno, + files, + kind, + base_blockstore, + base_eventlog, + } + } +} + +fn copy_blockstore_files( + snapshot: &BlockstoreSnapshot, + min_file_id: DataFileId, + data_dir: &Path, + dest_dir: &Path, + files: &mut Vec, +) -> io::Result<()> { + std::fs::create_dir_all(dest_dir)?; + + snapshot + .data_files + .iter() + .filter(|&&fid| fid >= min_file_id) + .try_for_each(|&file_id| { + let src = data_dir.join(format!("{file_id}.tqb")); + + let max_bytes = snapshot + .shard_cursors + .iter() + .find(|c| c.file_id == file_id) + .map(|c| c.offset.raw()); + + let dest = dest_dir.join(format!("{file_id}.tqb")); + let size = copy_file_synced(&src, &dest, max_bytes)?; + + if let Some(expected) = max_bytes { + verify_copy_size(size, expected, &src)?; + } + + let checksum = checksum_file(&dest)?; + + files.push(BackupFileEntry { + path: format!("{BLOCKS_DIR}/{file_id}.tqb"), + size, + xxh3_checksum: checksum, + }); + + Ok::<(), io::Error>(()) + })?; + + sync_dir(dest_dir) +} + +fn copy_eventlog_files( + snapshot: &EventLogSnapshotState, + min_segment_id: SegmentId, + segments_dir: &Path, + dest_dir: &Path, + files: &mut Vec, +) -> io::Result<()> { + std::fs::create_dir_all(dest_dir)?; + + snapshot + .sealed_segments + .iter() + .filter(|&&sid| sid >= min_segment_id) + .try_for_each(|&seg_id| { + let src = segments_dir.join(format!("{seg_id}.tqe")); + let dest = dest_dir.join(format!("{seg_id}.tqe")); + let size = copy_file_synced(&src, &dest, None)?; + let checksum = checksum_file(&dest)?; + + files.push(BackupFileEntry { + path: format!("{EVENTS_DIR}/{seg_id}.tqe"), + size, + xxh3_checksum: checksum, + }); + + Ok::<(), io::Error>(()) + })?; + + let active_src = segments_dir.join(format!("{}.tqe", snapshot.active_segment_id)); + let active_dest = dest_dir.join(format!("{}.tqe", snapshot.active_segment_id)); + let expected = snapshot.active_segment_position.raw(); + let size = copy_file_synced(&active_src, &active_dest, Some(expected))?; + verify_copy_size(size, expected, &active_src)?; + let checksum = checksum_file(&active_dest)?; + + files.push(BackupFileEntry { + path: format!("{EVENTS_DIR}/{}.tqe", snapshot.active_segment_id), + size, + xxh3_checksum: checksum, + }); + + sync_dir(dest_dir) +} + +fn copy_metastore_files( + src: &Path, + dest: &Path, + files: &mut Vec, +) -> io::Result<()> { + std::fs::create_dir_all(dest)?; + copy_dir_recursive(src, dest, src, files)?; + sync_dir(dest) +} + +fn copy_dir_recursive( + base: &Path, + dest_base: &Path, + current: &Path, + files: &mut Vec, +) -> io::Result<()> { + std::fs::read_dir(current)?.try_for_each(|entry| { + let entry = entry?; + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + + if name == LOCK_FILE_NAME { + return Ok(()); + } + + let path = entry.path(); + let relative = path + .strip_prefix(base) + .map_err(|e| io::Error::other(e.to_string()))?; + let dest_path = dest_base.join(relative); + + match entry.file_type()?.is_dir() { + true => { + std::fs::create_dir_all(&dest_path)?; + copy_dir_recursive(base, dest_base, &path, files) + } + false => { + let size = copy_file_synced(&path, &dest_path, None)?; + let checksum = checksum_file(&dest_path)?; + files.push(BackupFileEntry { + path: format!("{METASTORE_DIR}/{}", relative.display()), + size, + xxh3_checksum: checksum, + }); + Ok(()) + } + } + }) +} + +fn copy_file_synced(src: &Path, dest: &Path, max_bytes: Option) -> io::Result { + let src_file = std::fs::File::open(src)?; + let mut dest_file = std::fs::File::create(dest)?; + let total = match max_bytes { + None => io::copy(&mut &src_file, &mut dest_file)?, + Some(limit) => io::copy(&mut src_file.take(limit), &mut dest_file)?, + }; + dest_file.sync_all()?; + Ok(total) +} + +fn verify_copy_size(actual: u64, expected: u64, src: &Path) -> io::Result<()> { + (actual == expected).then_some(()).ok_or_else(|| { + io::Error::new( + io::ErrorKind::UnexpectedEof, + format!("{}: expected {expected} bytes, got {actual}", src.display(),), + ) + }) +} + +struct HashWriter(xxhash_rust::xxh3::Xxh3Default); + +impl io::Write for HashWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.update(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn checksum_file(path: &Path) -> io::Result { + let mut file = std::fs::File::open(path)?; + let mut hasher = HashWriter(xxhash_rust::xxh3::Xxh3Default::new()); + io::copy(&mut file, &mut hasher)?; + Ok(hasher.0.digest()) +} + +fn write_manifest(manifest: &BackupManifest, destination: &Path) -> io::Result<()> { + let json = serde_json::to_string_pretty(manifest) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + let manifest_path = destination.join(MANIFEST_FILENAME); + std::fs::write(&manifest_path, json.as_bytes())?; + let f = std::fs::File::open(&manifest_path)?; + f.sync_all()?; + sync_dir(destination)?; + Ok(()) +} + +pub fn read_manifest(backup_dir: &Path) -> io::Result { + let data = std::fs::read(backup_dir.join(MANIFEST_FILENAME))?; + let manifest: BackupManifest = + serde_json::from_slice(&data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + if manifest.version != BACKUP_FORMAT_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "backup format version {}, expected {BACKUP_FORMAT_VERSION}", + manifest.version, + ), + )); + } + Ok(manifest) +} + +#[derive(Debug)] +pub struct RestoreResult { + pub blocks_files_restored: u32, + pub event_segments_restored: u32, + pub metastore_files_restored: u32, +} + +#[derive(Debug)] +pub struct PitrResult { + pub restore: RestoreResult, + pub events_replayed: u64, + pub target_seq: EventSequence, +} + +#[derive(Debug)] +pub enum FileFailure { + SizeMismatch(String), + ChecksumMismatch(String), + Unreadable(String, io::Error), +} + +#[derive(Debug)] +pub struct VerifyResult { + pub total_blocks: u64, + pub total_events: u64, + pub corrupted_blocks: u64, + pub corrupted_events: u64, + pub file_failures: Vec, +} + +impl VerifyResult { + pub fn is_healthy(&self) -> bool { + self.corrupted_blocks == 0 && self.corrupted_events == 0 && self.file_failures.is_empty() + } +} + +fn copy_validated_entry( + entry: &BackupFileEntry, + source_dir: &Path, + target: &Path, +) -> Result<(), BackupError> { + let src_path = source_dir.join(&entry.path); + let dest_path = target.join(&entry.path); + + if let Some(parent) = dest_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let size = copy_file_synced(&src_path, &dest_path, None)?; + + if size != entry.size { + return Err(BackupError::SizeMismatch { + path: entry.path.clone(), + expected: entry.size, + actual: size, + }); + } + + let actual_checksum = checksum_file(&dest_path)?; + if actual_checksum != entry.xxh3_checksum { + return Err(BackupError::ChecksumMismatch { + path: entry.path.clone(), + expected: entry.xxh3_checksum, + actual: actual_checksum, + }); + } + + Ok(()) +} + +struct RestoreDirs { + blocks: std::path::PathBuf, + events: std::path::PathBuf, + metastore: std::path::PathBuf, + index: std::path::PathBuf, +} + +impl RestoreDirs { + fn create(target: &Path) -> Result { + let dirs = Self { + blocks: target.join(BLOCKS_DIR), + events: target.join(EVENTS_DIR), + metastore: target.join(METASTORE_DIR), + index: target.join(INDEX_DIR), + }; + + [&dirs.blocks, &dirs.events, &dirs.metastore, &dirs.index] + .iter() + .try_for_each(std::fs::create_dir_all)?; + + Ok(dirs) + } + + fn finalize( + &self, + manifest: &BackupManifest, + file_count: impl Fn(&str) -> u32, + ) -> Result { + [&self.blocks, &self.events, &self.metastore] + .iter() + .try_for_each(|d| sync_dir(d))?; + + std::fs::create_dir_all(&self.index).map_err(BackupError::Io)?; + + verify_restored_eventlog(&self.events, manifest)?; + verify_restored_metastore(&self.metastore)?; + + Ok(RestoreResult { + blocks_files_restored: file_count(BLOCKS_DIR), + event_segments_restored: file_count(EVENTS_DIR), + metastore_files_restored: file_count(METASTORE_DIR), + }) + } +} + +pub fn restore_from_backup(source: &Path, target: &Path) -> Result { + reject_nonempty_target(target)?; + + let manifest = read_manifest(source)?; + validate_manifest_checksums(&manifest, source)?; + + let staging = staging_dir(target)?; + + let result = (|| { + let dirs = RestoreDirs::create(&staging)?; + + manifest + .files + .iter() + .try_for_each(|entry| copy_validated_entry(entry, source, &staging))?; + + dirs.finalize(&manifest, |prefix| { + u32::try_from( + manifest + .files + .iter() + .filter(|e| e.path.starts_with(prefix)) + .count(), + ) + .unwrap_or(u32::MAX) + }) + })(); + + promote_or_cleanup(staging, target, result) +} + +fn validate_incremental_chain( + base: &BackupManifest, + incr: &BackupManifest, +) -> Result<(), BackupError> { + match incr.kind { + BackupKind::Incremental => {} + BackupKind::Full => { + return Err(BackupError::ChainMismatch( + "incremental manifest has kind=Full".into(), + )); + } + } + + if incr.created_at_ms < base.created_at_ms { + return Err(BackupError::ChainMismatch( + "incremental backup predates its base".into(), + )); + } + + match (&incr.base_blockstore, &incr.base_eventlog) { + (Some(recorded_bs), Some(recorded_el)) => { + if *recorded_bs != base.blockstore { + return Err(BackupError::ChainMismatch( + "incremental blockstore base does not match provided base manifest".into(), + )); + } + if *recorded_el != base.eventlog { + return Err(BackupError::ChainMismatch( + "incremental eventlog base does not match provided base manifest".into(), + )); + } + } + _ => { + return Err(BackupError::ChainMismatch( + "incremental manifest missing base_blockstore or base_eventlog".into(), + )); + } + } + + if incr.blockstore.epoch < base.blockstore.epoch { + return Err(BackupError::ChainMismatch( + "incremental blockstore epoch regressed from base".into(), + )); + } + + if incr.eventlog.max_seq < base.eventlog.max_seq { + return Err(BackupError::ChainMismatch( + "incremental eventlog max_seq regressed from base".into(), + )); + } + + Ok(()) +} + +pub fn restore_from_incremental( + base_dir: &Path, + incremental_dir: &Path, + target: &Path, +) -> Result { + reject_nonempty_target(target)?; + + let base_manifest = read_manifest(base_dir)?; + let incr_manifest = read_manifest(incremental_dir)?; + + validate_incremental_chain(&base_manifest, &incr_manifest)?; + + validate_manifest_checksums(&base_manifest, base_dir)?; + validate_manifest_checksums(&incr_manifest, incremental_dir)?; + + let staging = staging_dir(target)?; + + let result = (|| { + let dirs = RestoreDirs::create(&staging)?; + + let incr_paths: HashSet<&str> = incr_manifest + .files + .iter() + .map(|e| e.path.as_str()) + .collect(); + + base_manifest + .files + .iter() + .filter(|entry| !incr_paths.contains(entry.path.as_str())) + .try_for_each(|entry| copy_validated_entry(entry, base_dir, &staging))?; + + incr_manifest + .files + .iter() + .try_for_each(|entry| copy_validated_entry(entry, incremental_dir, &staging))?; + + dirs.finalize(&incr_manifest, |prefix| { + let from_base = base_manifest + .files + .iter() + .filter(|e| e.path.starts_with(prefix) && !incr_paths.contains(e.path.as_str())) + .count(); + let from_incr = incr_manifest + .files + .iter() + .filter(|e| e.path.starts_with(prefix)) + .count(); + u32::try_from(from_base.saturating_add(from_incr)).unwrap_or(u32::MAX) + }) + })(); + + promote_or_cleanup(staging, target, result) +} + +pub fn recover_to_sequence( + backup: &Path, + eventlog_archive: &Path, + target_seq: EventSequence, + target: &Path, +) -> Result { + let manifest = read_manifest(backup)?; + let backup_max_seq = manifest.eventlog.max_seq; + + if target_seq < backup_max_seq { + return Err(BackupError::RestoreVerification(format!( + "target_seq ({target_seq}) precedes backup max_seq ({backup_max_seq})", + ))); + } + + let restore = restore_from_backup(backup, target)?; + + if target_seq == backup_max_seq { + return Ok(PitrResult { + restore, + events_replayed: 0, + target_seq, + }); + } + + let target_events = target.join(EVENTS_DIR); + merge_archived_segments(eventlog_archive, &target_events, &manifest)?; + + let metastore = Metastore::open(&target.join(METASTORE_DIR), MetastoreConfig::default())?; + + let eventlog = EventLog::open( + EventLogConfig { + segments_dir: target_events, + ..EventLogConfig::default() + }, + RealIO::new(), + )?; + + let eventlog_max = eventlog.max_seq(); + if target_seq > eventlog_max { + let _ = eventlog.shutdown(); + return Err(BackupError::RestoreVerification(format!( + "target_seq ({target_seq}) exceeds available eventlog max_seq ({eventlog_max})", + ))); + } + + let events_replayed = + replay_mutations_bounded(&eventlog, &metastore, backup_max_seq, target_seq)?; + + metastore.persist()?; + let _ = eventlog.shutdown(); + + Ok(PitrResult { + restore, + events_replayed, + target_seq, + }) +} + +const PITR_BATCH_SIZE: usize = 4096; + +fn merge_archived_segments( + archive: &Path, + target_events: &Path, + manifest: &BackupManifest, +) -> Result<(), BackupError> { + let backup_active_id = manifest.eventlog.active_segment_id; + + std::fs::read_dir(archive)? + .filter(|entry| { + entry.as_ref().map_or(true, |e| { + e.path() + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext == "tqe") + }) + }) + .try_for_each(|entry| { + let entry = entry?; + let src = entry.path(); + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + let seg_id = name + .strip_suffix(".tqe") + .and_then(|s| s.parse::().ok()) + .map(SegmentId::new); + + match seg_id { + Some(id) if id > backup_active_id => { + let dest = target_events.join(&*file_name); + copy_file_synced(&src, &dest, None)?; + Ok::<(), io::Error>(()) + } + Some(id) if id == backup_active_id => { + let archive_size = entry.metadata()?.len(); + let backup_size = manifest.eventlog.active_segment_position.raw(); + if archive_size > backup_size { + let dest = target_events.join(&*file_name); + copy_file_synced(&src, &dest, None)?; + } + Ok(()) + } + _ => Ok(()), + } + })?; + + sync_dir(target_events)?; + Ok(()) +} + +fn ewm_to_event_sequence(ewm: &EventWithMutations) -> Result { + EventSequence::try_from(ewm.event.seq).map_err(|reason| { + BackupError::RestoreVerification(format!( + "event sequence {} not convertible: {reason}", + ewm.event.seq + )) + }) +} + +fn replay_mutations_bounded( + eventlog: &EventLog, + metastore: &Metastore, + from_seq: EventSequence, + target_seq: EventSequence, +) -> Result { + let repo_data = metastore.partition(Partition::RepoData); + let indexes = metastore.partition(Partition::Indexes); + let db = metastore.database(); + let cursor_key = metastore_cursor_key(); + + let mut cursor = from_seq; + let mut total = 0u64; + + loop { + let page = eventlog.get_events_with_mutations_since(cursor, PITR_BATCH_SIZE)?; + let reached_end = page.len() < PITR_BATCH_SIZE; + + let cutoff = page + .iter() + .position(|ewm| ewm_to_event_sequence(ewm).is_ok_and(|es| es > target_seq)) + .unwrap_or(page.len()); + + let (new_cursor, new_total) = + page[..cutoff] + .iter() + .try_fold((cursor, total), |(_, count), ewm| { + let event_es = ewm_to_event_sequence(ewm)?; + replay_single_event(db, repo_data, indexes, &cursor_key, ewm, event_es)?; + Ok::<_, BackupError>((event_es, count.saturating_add(1))) + })?; + + cursor = new_cursor; + total = new_total; + + if cutoff < page.len() || reached_end { + return Ok(total); + } + } +} + +fn replay_single_event( + db: &fjall::Database, + repo_data: &fjall::Keyspace, + indexes: &fjall::Keyspace, + cursor_key: &[u8], + ewm: &EventWithMutations, + event_es: EventSequence, +) -> Result<(), BackupError> { + let seq_raw = event_es.raw(); + let user_hash = UserHash::from_did(ewm.event.did.as_str()); + let mut batch = db.batch(); + + let de_key = did_events_key(user_hash, seq_raw); + batch.insert(repo_data, de_key.as_slice(), []); + + if let Some(rev) = &ewm.event.rev { + let rs_key = rev_to_seq_key(user_hash, rev); + batch.insert(repo_data, rs_key.as_slice(), seq_raw.to_be_bytes()); + } + + if let Some(ms_bytes) = &ewm.mutation_set { + let ms = CommitMutationSet::deserialize(ms_bytes).ok_or_else(|| { + BackupError::RestoreVerification(format!("corrupt CommitMutationSet at seq {seq_raw}")) + })?; + + let meta_key = repo_meta_key(user_hash); + let current_meta = repo_data + .get(meta_key.as_slice()) + .map_err(MetastoreError::from)? + .and_then(|raw| RepoMetaValue::deserialize(&raw)) + .unwrap_or_else(|| RepoMetaValue { + repo_root_cid: vec![], + repo_rev: String::new(), + handle: String::new(), + status: RepoStatus::Active, + deactivated_at_ms: None, + takedown_ref: None, + did: Some(ewm.event.did.as_str().to_owned()), + }); + + replay_mutation_set( + &mut batch, + repo_data, + indexes, + user_hash, + ¤t_meta, + &ms, + ) + .map_err(BackupError::Metastore)?; + } + + batch.insert(repo_data, cursor_key, seq_raw.to_be_bytes()); + batch.commit().map_err(MetastoreError::from)?; + + Ok(()) +} + +fn reject_nonempty_target(target: &Path) -> Result<(), BackupError> { + match std::fs::read_dir(target) { + Ok(mut entries) => match entries.next() { + Some(_) => Err(BackupError::TargetNotEmpty(target.display().to_string())), + None => Ok(()), + }, + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(BackupError::Io(e)), + } +} + +fn staging_dir(target: &Path) -> Result { + let parent = target.parent().ok_or_else(|| { + BackupError::Io(io::Error::new( + io::ErrorKind::InvalidInput, + "target path has no parent directory", + )) + })?; + std::fs::create_dir_all(parent)?; + let stem = target + .file_name() + .map(|n| n.to_string_lossy()) + .unwrap_or_default(); + let staging = parent.join(format!(".{stem}.restore-staging")); + if staging.exists() { + std::fs::remove_dir_all(&staging)?; + } + std::fs::create_dir_all(&staging)?; + Ok(staging) +} + +fn promote_or_cleanup( + staging: std::path::PathBuf, + target: &Path, + result: Result, +) -> Result { + match result { + Ok(val) => { + std::fs::rename(&staging, target)?; + if let Some(parent) = target.parent() { + sync_dir(parent)?; + } + Ok(val) + } + Err(e) => { + let _ = std::fs::remove_dir_all(&staging); + Err(e) + } + } +} + +fn validate_manifest_checksums( + manifest: &BackupManifest, + backup_dir: &Path, +) -> Result<(), BackupError> { + manifest.files.iter().try_for_each(|entry| { + let file_path = backup_dir.join(&entry.path); + + let metadata = std::fs::metadata(&file_path).map_err(|e| match e.kind() { + io::ErrorKind::NotFound => BackupError::MissingFile(entry.path.clone()), + _ => BackupError::Io(e), + })?; + + let actual_size = metadata.len(); + if actual_size != entry.size { + return Err(BackupError::SizeMismatch { + path: entry.path.clone(), + expected: entry.size, + actual: actual_size, + }); + } + + let actual_checksum = checksum_file(&file_path)?; + if actual_checksum != entry.xxh3_checksum { + return Err(BackupError::ChecksumMismatch { + path: entry.path.clone(), + expected: entry.xxh3_checksum, + actual: actual_checksum, + }); + } + + Ok(()) + }) +} + +fn verify_restored_eventlog( + events_dir: &Path, + manifest: &BackupManifest, +) -> Result<(), BackupError> { + use crate::eventlog::{DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD, rebuild_from_segment}; + + if manifest.eventlog.max_seq == EventSequence::BEFORE_ALL { + return Ok(()); + } + + let real_io = RealIO::new(); + let active_path = events_dir.join(format!("{}.tqe", manifest.eventlog.active_segment_id)); + let fd = real_io.open(&active_path, crate::OpenOptions::read_only_existing())?; + let (_, last_seq) = + match rebuild_from_segment(&real_io, fd, DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) { + Ok(result) => { + real_io.close(fd)?; + result + } + Err(e) => { + let _ = real_io.close(fd); + return Err(e.into()); + } + }; + + let restored_max = last_seq.unwrap_or(EventSequence::BEFORE_ALL); + if restored_max != manifest.eventlog.max_seq { + return Err(BackupError::RestoreVerification(format!( + "eventlog max_seq mismatch: manifest={}, restored={}", + manifest.eventlog.max_seq, restored_max, + ))); + } + + Ok(()) +} + +fn verify_restored_metastore(metastore_dir: &Path) -> Result<(), BackupError> { + let _metastore = Metastore::open(metastore_dir, MetastoreConfig::default())?; + Ok(()) +} + +pub fn verify_backup(source: &Path) -> Result { + let manifest = read_manifest(source)?; + + let file_failures: Vec = manifest + .files + .iter() + .filter_map(|entry| { + let file_path = source.join(&entry.path); + match std::fs::metadata(&file_path) { + Err(e) => return Some(FileFailure::Unreadable(entry.path.clone(), e)), + Ok(m) if m.len() != entry.size => { + return Some(FileFailure::SizeMismatch(entry.path.clone())); + } + _ => {} + } + match checksum_file(&file_path) { + Ok(actual) if actual != entry.xxh3_checksum => { + Some(FileFailure::ChecksumMismatch(entry.path.clone())) + } + Err(e) => Some(FileFailure::Unreadable(entry.path.clone(), e)), + _ => None, + } + }) + .collect(); + + let io = RealIO::new(); + let blocks_dir = source.join(BLOCKS_DIR); + let (total_blocks, corrupted_blocks) = match blocks_dir.is_dir() { + true => verify_blockstore_integrity(&io, &blocks_dir)?, + false => (0, 0), + }; + + let events_dir = source.join(EVENTS_DIR); + let (total_events, corrupted_events) = match events_dir.is_dir() { + true => verify_eventlog_integrity(&io, &events_dir)?, + false => (0, 0), + }; + + Ok(VerifyResult { + total_blocks, + total_events, + corrupted_blocks, + corrupted_events, + file_failures, + }) +} + +enum RecordHealth { + Valid, + Corrupted, +} + +fn tally_record_health(records: impl Iterator) -> (u64, u64) { + records.fold((0u64, 0u64), |(total, corrupted), health| match health { + RecordHealth::Valid => (total.saturating_add(1), corrupted), + RecordHealth::Corrupted => (total.saturating_add(1), corrupted.saturating_add(1)), + }) +} + +fn verify_blockstore_integrity( + io: &S, + blocks_dir: &Path, +) -> Result<(u64, u64), BackupError> { + use crate::blockstore::{DataFileReader, ReadBlockRecord, list_files_by_extension}; + + let file_ids = list_files_by_extension(io, blocks_dir, "tqb")?; + + file_ids.iter().try_fold( + (0u64, 0u64), + |(total, corrupted), &file_id| -> Result<(u64, u64), BackupError> { + let path = blocks_dir.join(format!("{file_id}.tqb")); + let fd = io.open(&path, crate::OpenOptions::read_only_existing())?; + let reader = match DataFileReader::open(io, fd) { + Ok(r) => r, + Err(e) => { + let _ = io.close(fd); + return Err(e.into()); + } + }; + + let (ft, fc) = tally_record_health(reader.map(|r| match r { + Ok(ReadBlockRecord::Valid { .. }) => RecordHealth::Valid, + _ => RecordHealth::Corrupted, + })); + + io.close(fd)?; + Ok((total.saturating_add(ft), corrupted.saturating_add(fc))) + }, + ) +} + +fn verify_eventlog_integrity( + io: &S, + events_dir: &Path, +) -> Result<(u64, u64), BackupError> { + use crate::eventlog::{MAX_EVENT_PAYLOAD, ReadEventRecord, SegmentReader}; + + let entries = io.list_dir(events_dir)?; + let mut segment_paths: Vec = entries + .into_iter() + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("tqe")) + .collect(); + segment_paths.sort(); + + segment_paths.iter().try_fold( + (0u64, 0u64), + |(total, corrupted), path| -> Result<(u64, u64), BackupError> { + let fd = io.open(path, crate::OpenOptions::read_only_existing())?; + let reader = match SegmentReader::open(io, fd, MAX_EVENT_PAYLOAD) { + Ok(r) => r, + Err(e) => { + let _ = io.close(fd); + return Err(e.into()); + } + }; + + let (st, sc) = tally_record_health(reader.map(|r| match r { + Ok(ReadEventRecord::Valid { .. }) => RecordHealth::Valid, + _ => RecordHealth::Corrupted, + })); + + io.close(fd)?; + Ok((total.saturating_add(st), corrupted.saturating_add(sc))) + }, + ) +} + +fn sync_dir(path: &Path) -> io::Result<()> { + let dir = std::fs::File::open(path)?; + dir.sync_all() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::blockstore::WriteCursor; + + fn make_full_manifest(files: Vec) -> BackupManifest { + BackupManifest { + version: BACKUP_FORMAT_VERSION, + created_at_ms: 1_700_000_000_000, + blockstore: BlockstoreManifest { + write_cursor_file_id: DataFileId::new(5), + write_cursor_offset: BlockOffset::new(102400), + epoch: CommitEpoch::new(42), + shard_cursors: vec![ShardCursorEntry { + file_id: DataFileId::new(5), + offset: BlockOffset::new(102400), + }], + }, + eventlog: EventLogManifest { + max_seq: EventSequence::new(1000), + active_segment_id: SegmentId::new(3), + active_segment_position: SegmentOffset::new(32768), + }, + metastore_seqno: MetastoreSeqno::new(5000), + files, + kind: BackupKind::Full, + base_blockstore: None, + base_eventlog: None, + } + } + + #[test] + fn manifest_round_trip() { + let manifest = make_full_manifest(vec![ + BackupFileEntry { + path: "blocks/000000.tqb".into(), + size: 256000, + xxh3_checksum: 0xDEAD_BEEF_CAFE_1234, + }, + BackupFileEntry { + path: "events/00000001.tqe".into(), + size: 64000, + xxh3_checksum: 0x1234_5678_9ABC_DEF0, + }, + ]); + + let json = serde_json::to_string_pretty(&manifest).unwrap(); + let decoded: BackupManifest = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded.version, manifest.version); + assert_eq!(decoded.blockstore.write_cursor_file_id, DataFileId::new(5)); + assert_eq!(decoded.blockstore.epoch, CommitEpoch::new(42)); + assert_eq!(decoded.eventlog.max_seq, EventSequence::new(1000)); + assert_eq!(decoded.metastore_seqno, MetastoreSeqno::new(5000)); + assert_eq!(decoded.files.len(), 2); + assert_eq!(decoded.files[0].xxh3_checksum, 0xDEAD_BEEF_CAFE_1234); + assert_eq!(decoded.kind, BackupKind::Full); + assert!(decoded.base_blockstore.is_none()); + } + + #[test] + fn checksum_deterministic() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("test.bin"); + std::fs::write(&path, b"hello world checksum test").unwrap(); + + let c1 = checksum_file(&path).unwrap(); + let c2 = checksum_file(&path).unwrap(); + assert_eq!(c1, c2); + assert_ne!(c1, 0); + } + + #[test] + fn copy_file_synced_full() { + let dir = tempfile::TempDir::new().unwrap(); + let src = dir.path().join("src.bin"); + let dest = dir.path().join("dest.bin"); + let data = vec![0xABu8; 1024]; + std::fs::write(&src, &data).unwrap(); + + let copied = copy_file_synced(&src, &dest, None).unwrap(); + assert_eq!(copied, 1024); + assert_eq!(std::fs::read(&dest).unwrap(), data); + } + + #[test] + fn copy_file_synced_partial() { + let dir = tempfile::TempDir::new().unwrap(); + let src = dir.path().join("src.bin"); + let dest = dir.path().join("dest.bin"); + let data = vec![0xCDu8; 1024]; + std::fs::write(&src, &data).unwrap(); + + let copied = copy_file_synced(&src, &dest, Some(512)).unwrap(); + assert_eq!(copied, 512); + + let result = std::fs::read(&dest).unwrap(); + assert_eq!(result.len(), 512); + assert_eq!(result, &data[..512]); + } + + #[test] + fn copy_dir_recursive_skips_lock() { + let dir = tempfile::TempDir::new().unwrap(); + let src = dir.path().join("src"); + let dest = dir.path().join("dest"); + std::fs::create_dir_all(src.join("sub")).unwrap(); + std::fs::write(src.join("data.sst"), b"sst data").unwrap(); + std::fs::write(src.join("sub/nested.sst"), b"nested").unwrap(); + std::fs::write(src.join(".lock"), b"locked").unwrap(); + + let mut files = Vec::new(); + copy_metastore_files(&src, &dest, &mut files).unwrap(); + + assert!(dest.join("data.sst").exists()); + assert!(dest.join("sub/nested.sst").exists()); + assert!(!dest.join(".lock").exists()); + assert_eq!(files.len(), 2); + assert!(files.iter().all(|f| f.path.starts_with(METASTORE_DIR))); + } + + #[test] + fn manifest_write_and_read() { + let dir = tempfile::TempDir::new().unwrap(); + let manifest = make_full_manifest(Vec::new()); + + write_manifest(&manifest, dir.path()).unwrap(); + let loaded = read_manifest(dir.path()).unwrap(); + assert_eq!(loaded.version, BACKUP_FORMAT_VERSION); + assert_eq!(loaded.kind, BackupKind::Full); + } + + #[test] + fn read_manifest_rejects_unknown_version() { + let dir = tempfile::TempDir::new().unwrap(); + let mut manifest = make_full_manifest(Vec::new()); + manifest.version = 999; + + let json = serde_json::to_string_pretty(&manifest).unwrap(); + std::fs::write(dir.path().join(MANIFEST_FILENAME), json.as_bytes()).unwrap(); + + let err = read_manifest(dir.path()).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn verify_copy_size_mismatch() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("dummy.bin"); + std::fs::write(&path, b"").unwrap(); + + let err = verify_copy_size(100, 200, &path).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof); + } + + #[test] + fn legacy_manifest_without_kind_deserializes_as_full() { + let json = r#"{ + "version": 1, + "created_at_ms": 42, + "blockstore": { + "write_cursor_file_id": 0, + "write_cursor_offset": 0, + "epoch": 0 + }, + "eventlog": { + "max_seq": 0, + "active_segment_id": 0, + "active_segment_position": 0 + }, + "metastore_seqno": 0, + "files": [] + }"#; + + let decoded: BackupManifest = serde_json::from_str(json).unwrap(); + assert_eq!(decoded.kind, BackupKind::Full); + assert!(decoded.base_blockstore.is_none()); + assert!(decoded.base_eventlog.is_none()); + } + + #[test] + fn incremental_manifest_round_trip() { + let base_bs = BlockstoreManifest { + write_cursor_file_id: DataFileId::new(3), + write_cursor_offset: BlockOffset::new(50000), + epoch: CommitEpoch::new(10), + shard_cursors: vec![ShardCursorEntry { + file_id: DataFileId::new(3), + offset: BlockOffset::new(50000), + }], + }; + let base_el = EventLogManifest { + max_seq: EventSequence::new(500), + active_segment_id: SegmentId::new(2), + active_segment_position: SegmentOffset::new(16384), + }; + + let manifest = BackupManifest { + version: BACKUP_FORMAT_VERSION, + created_at_ms: 1_700_000_000_000, + blockstore: BlockstoreManifest { + write_cursor_file_id: DataFileId::new(7), + write_cursor_offset: BlockOffset::new(200000), + epoch: CommitEpoch::new(50), + shard_cursors: vec![ShardCursorEntry { + file_id: DataFileId::new(7), + offset: BlockOffset::new(200000), + }], + }, + eventlog: EventLogManifest { + max_seq: EventSequence::new(2000), + active_segment_id: SegmentId::new(5), + active_segment_position: SegmentOffset::new(65536), + }, + metastore_seqno: MetastoreSeqno::new(9000), + files: vec![BackupFileEntry { + path: "blocks/000007.tqb".into(), + size: 200000, + xxh3_checksum: 0xAAAA_BBBB_CCCC_DDDD, + }], + kind: BackupKind::Incremental, + base_blockstore: Some(base_bs.clone()), + base_eventlog: Some(base_el.clone()), + }; + + let json = serde_json::to_string_pretty(&manifest).unwrap(); + let decoded: BackupManifest = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded.kind, BackupKind::Incremental); + let decoded_base_bs = decoded.base_blockstore.unwrap(); + assert_eq!(decoded_base_bs.write_cursor_file_id, DataFileId::new(3)); + assert_eq!(decoded_base_bs.write_cursor_offset, BlockOffset::new(50000)); + let decoded_base_el = decoded.base_eventlog.unwrap(); + assert_eq!(decoded_base_el.active_segment_id, SegmentId::new(2)); + } + + #[test] + fn incremental_blockstore_filters_old_files() { + let base = BlockstoreManifest { + write_cursor_file_id: DataFileId::new(3), + write_cursor_offset: BlockOffset::new(50000), + epoch: CommitEpoch::new(10), + shard_cursors: vec![ShardCursorEntry { + file_id: DataFileId::new(3), + offset: BlockOffset::new(50000), + }], + }; + + let snapshot = BlockstoreSnapshot { + shard_cursors: vec![WriteCursor { + file_id: DataFileId::new(5), + offset: BlockOffset::new(1024), + }], + epoch: CommitEpoch::new(15), + data_files: vec![ + DataFileId::new(0), + DataFileId::new(1), + DataFileId::new(2), + DataFileId::new(3), + DataFileId::new(4), + DataFileId::new(5), + ], + }; + + let dir = tempfile::TempDir::new().unwrap(); + let data_dir = dir.path().join("data"); + let dest_dir = dir.path().join("dest"); + std::fs::create_dir_all(&data_dir).unwrap(); + + (0u32..=5).for_each(|id| { + let path = data_dir.join(format!("{}.tqb", DataFileId::new(id))); + std::fs::write(&path, vec![0xABu8; 2048]).unwrap(); + }); + + let mut files = Vec::new(); + copy_blockstore_files( + &snapshot, + base.write_cursor_file_id, + &data_dir, + &dest_dir, + &mut files, + ) + .unwrap(); + + let copied_ids: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); + + assert_eq!(copied_ids.len(), 3); + assert!(copied_ids.contains(&"blocks/000003.tqb")); + assert!(copied_ids.contains(&"blocks/000004.tqb")); + assert!(copied_ids.contains(&"blocks/000005.tqb")); + + assert!(!dest_dir.join("000000.tqb").exists()); + assert!(!dest_dir.join("000001.tqb").exists()); + assert!(!dest_dir.join("000002.tqb").exists()); + } + + #[test] + fn incremental_eventlog_filters_old_segments() { + let base = EventLogManifest { + max_seq: EventSequence::new(500), + active_segment_id: SegmentId::new(2), + active_segment_position: SegmentOffset::new(8192), + }; + + let snapshot = EventLogSnapshotState { + max_seq: EventSequence::new(1500), + active_segment_id: SegmentId::new(4), + active_segment_position: SegmentOffset::new(4096), + sealed_segments: vec![ + SegmentId::new(0), + SegmentId::new(1), + SegmentId::new(2), + SegmentId::new(3), + ], + }; + + let dir = tempfile::TempDir::new().unwrap(); + let seg_dir = dir.path().join("segments"); + let dest_dir = dir.path().join("dest"); + std::fs::create_dir_all(&seg_dir).unwrap(); + + (0u32..=4).for_each(|id| { + let path = seg_dir.join(format!("{}.tqe", SegmentId::new(id))); + std::fs::write(&path, vec![0xCDu8; 4096]).unwrap(); + }); + + let mut files = Vec::new(); + copy_eventlog_files( + &snapshot, + base.active_segment_id, + &seg_dir, + &dest_dir, + &mut files, + ) + .unwrap(); + + let copied_ids: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); + + assert_eq!(copied_ids.len(), 3); + assert!(copied_ids.contains(&"events/00000002.tqe")); + assert!(copied_ids.contains(&"events/00000003.tqe")); + assert!(copied_ids.contains(&"events/00000004.tqe")); + + assert!(!dest_dir.join("00000000.tqe").exists()); + assert!(!dest_dir.join("00000001.tqe").exists()); + } + + #[test] + fn incremental_no_change_produces_minimal_delta() { + let base = BlockstoreManifest { + write_cursor_file_id: DataFileId::new(2), + write_cursor_offset: BlockOffset::new(4096), + epoch: CommitEpoch::new(5), + shard_cursors: vec![ShardCursorEntry { + file_id: DataFileId::new(2), + offset: BlockOffset::new(4096), + }], + }; + + let snapshot = BlockstoreSnapshot { + shard_cursors: vec![WriteCursor { + file_id: DataFileId::new(2), + offset: BlockOffset::new(4096), + }], + epoch: CommitEpoch::new(5), + data_files: vec![DataFileId::new(0), DataFileId::new(1), DataFileId::new(2)], + }; + + let dir = tempfile::TempDir::new().unwrap(); + let data_dir = dir.path().join("data"); + let dest_dir = dir.path().join("dest"); + std::fs::create_dir_all(&data_dir).unwrap(); + + (0u32..=2).for_each(|id| { + let path = data_dir.join(format!("{}.tqb", DataFileId::new(id))); + std::fs::write(&path, vec![0u8; 4096]).unwrap(); + }); + + let mut files = Vec::new(); + copy_blockstore_files( + &snapshot, + base.write_cursor_file_id, + &data_dir, + &dest_dir, + &mut files, + ) + .unwrap(); + + assert_eq!(files.len(), 1); + assert!(files[0].path.contains("000002")); + } + + #[test] + fn chain_validation_rejects_kind_full() { + let base = make_full_manifest(Vec::new()); + let incr = make_full_manifest(Vec::new()); + + let err = validate_incremental_chain(&base, &incr).unwrap_err(); + assert!(matches!(err, BackupError::ChainMismatch(_))); + } + + #[test] + fn chain_validation_rejects_mismatched_base() { + let base = make_full_manifest(Vec::new()); + let mut incr = make_full_manifest(Vec::new()); + incr.kind = BackupKind::Incremental; + incr.base_blockstore = Some(BlockstoreManifest { + write_cursor_file_id: DataFileId::new(99), + write_cursor_offset: BlockOffset::new(0), + epoch: CommitEpoch::new(0), + shard_cursors: Vec::new(), + }); + incr.base_eventlog = Some(base.eventlog.clone()); + + let err = validate_incremental_chain(&base, &incr).unwrap_err(); + assert!(matches!(err, BackupError::ChainMismatch(_))); + } + + #[test] + fn chain_validation_accepts_matching_base() { + let base = make_full_manifest(Vec::new()); + let mut incr = make_full_manifest(Vec::new()); + incr.kind = BackupKind::Incremental; + incr.base_blockstore = Some(base.blockstore.clone()); + incr.base_eventlog = Some(base.eventlog.clone()); + + validate_incremental_chain(&base, &incr).unwrap(); + } + + #[test] + fn chain_validation_rejects_timestamp_regression() { + let base = make_full_manifest(Vec::new()); + let mut incr = make_full_manifest(Vec::new()); + incr.kind = BackupKind::Incremental; + incr.base_blockstore = Some(base.blockstore.clone()); + incr.base_eventlog = Some(base.eventlog.clone()); + incr.created_at_ms = base.created_at_ms - 1; + + let err = validate_incremental_chain(&base, &incr).unwrap_err(); + assert!(matches!(err, BackupError::ChainMismatch(_))); + } + + #[test] + fn chain_validation_rejects_epoch_regression() { + let base = make_full_manifest(Vec::new()); + let mut incr = make_full_manifest(Vec::new()); + incr.kind = BackupKind::Incremental; + incr.base_blockstore = Some(base.blockstore.clone()); + incr.base_eventlog = Some(base.eventlog.clone()); + incr.blockstore.epoch = CommitEpoch::new(base.blockstore.epoch.raw() - 1); + + let err = validate_incremental_chain(&base, &incr).unwrap_err(); + assert!(matches!(err, BackupError::ChainMismatch(_))); + } + + #[test] + fn chain_validation_rejects_eventlog_regression() { + let base = make_full_manifest(Vec::new()); + let mut incr = make_full_manifest(Vec::new()); + incr.kind = BackupKind::Incremental; + incr.base_blockstore = Some(base.blockstore.clone()); + incr.base_eventlog = Some(base.eventlog.clone()); + incr.eventlog.max_seq = EventSequence::new(base.eventlog.max_seq.raw() - 1); + + let err = validate_incremental_chain(&base, &incr).unwrap_err(); + assert!(matches!(err, BackupError::ChainMismatch(_))); + } +} diff --git a/crates/tranquil-store/src/blockstore/compaction.rs b/crates/tranquil-store/src/blockstore/compaction.rs new file mode 100644 index 0000000..7b2c722 --- /dev/null +++ b/crates/tranquil-store/src/blockstore/compaction.rs @@ -0,0 +1,212 @@ +use std::io; + +use crate::io::{FileId, OpenOptions, StorageIO}; + +use super::data_file::{DataFileReader, DataFileWriter, ReadBlockRecord}; +use super::group_commit::{ActiveFileSet, FileIdAllocator}; +use super::hash_index::{BlockIndex, BlockIndexError}; +use super::hint::{HintFileWriter, hint_file_path}; +use super::manager::DataFileManager; +use super::types::{BlockLocation, CidBytes, CommitEpoch, CompactionResult, DataFileId}; + +#[derive(Debug)] +pub enum CompactionError { + Io(io::Error), + Index(BlockIndexError), + ChannelClosed, + ActiveFileCannotBeCompacted, +} + +impl std::fmt::Display for CompactionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(e) => write!(f, "io: {e}"), + Self::Index(e) => write!(f, "index: {e}"), + Self::ChannelClosed => write!(f, "commit channel closed"), + Self::ActiveFileCannotBeCompacted => { + write!(f, "cannot compact the active data file") + } + } + } +} + +impl std::error::Error for CompactionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(e) => Some(e), + Self::Index(e) => Some(e), + Self::ChannelClosed | Self::ActiveFileCannotBeCompacted => None, + } + } +} + +impl From for CompactionError { + fn from(e: io::Error) -> Self { + Self::Io(e) + } +} + +impl From for CompactionError { + fn from(e: BlockIndexError) -> Self { + Self::Index(e) + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn compact_on_writer_thread( + manager: &DataFileManager, + index: &BlockIndex, + source_file_id: DataFileId, + current_epoch: CommitEpoch, + grace_period_ms: u64, + file_ids: &FileIdAllocator, + active_files: &ActiveFileSet, + hint_positions: &super::group_commit::ShardHintPositions, + epoch: &super::types::EpochCounter, +) -> Result { + if active_files.contains(source_file_id) { + return Err(CompactionError::ActiveFileCannotBeCompacted); + } + + let source_fd = manager.open_for_read(source_file_id)?; + let source_size = manager.io().file_size(source_fd)?; + + let new_file_id = file_ids.allocate(); + + let result = stream_compact( + manager, + index, + source_file_id, + source_fd, + new_file_id, + current_epoch, + grace_period_ms, + ); + + match result { + Err(e) => { + manager.delete_data_file(new_file_id).ok(); + manager + .io() + .delete(&hint_file_path(manager.data_dir(), new_file_id)) + .ok(); + Err(e) + } + Ok((new_size, live_count, dead_count)) => { + let positions = hint_positions.snapshot(); + if let Err(e) = index.write_checkpoint(epoch.current(), &positions) { + tracing::warn!(error = %e, "pre-delete checkpoint failed during compaction"); + } + + manager.delete_data_file(source_file_id)?; + manager + .io() + .delete(&hint_file_path(manager.data_dir(), source_file_id)) + .ok(); + manager.io().sync_dir(manager.data_dir())?; + + let reclaimed_bytes = source_size.saturating_sub(new_size); + + tracing::info!( + source = %source_file_id, + dest = %new_file_id, + old_size = source_size, + new_size, + live_count, + dead_count, + reclaimed_bytes, + "compaction complete" + ); + + Ok(CompactionResult { + file_id: source_file_id, + old_size: source_size, + new_size, + live_blocks: live_count, + dead_blocks: dead_count, + reclaimed_bytes, + }) + } + } +} + +fn stream_compact( + manager: &DataFileManager, + index: &BlockIndex, + source_file_id: DataFileId, + source_fd: FileId, + new_file_id: DataFileId, + current_epoch: CommitEpoch, + grace_period_ms: u64, +) -> Result<(u64, u64, u64), CompactionError> { + let mut reader = DataFileReader::open(manager.io(), source_fd)?; + let now = crate::wall_clock_ms(); + + let new_fd = manager.open_for_append(new_file_id)?; + let mut writer = DataFileWriter::new(manager.io(), new_fd, new_file_id)?; + + let hint_path = hint_file_path(manager.data_dir(), new_file_id); + let hint_fd = manager.io().open(&hint_path, OpenOptions::read_write())?; + let mut hint_writer = HintFileWriter::new(manager.io(), hint_fd); + + let mut relocations: Vec<(CidBytes, BlockLocation)> = Vec::new(); + let mut dead_cids: Vec = Vec::new(); + let mut live_count: u64 = 0; + let mut dead_count: u64 = 0; + + reader.try_for_each(|r| { + let record = r?; + match record { + ReadBlockRecord::Valid { + cid_bytes, data, .. + } => match index.get(&cid_bytes) { + Some(e) if e.location.file_id == source_file_id && !e.refcount.is_zero() => { + let loc = writer.append_block(&cid_bytes, &data)?; + hint_writer.append_relocate(&cid_bytes, loc.file_id, loc.offset, loc.length)?; + relocations.push((cid_bytes, loc)); + live_count = live_count.saturating_add(1); + } + Some(e) if e.location.file_id == source_file_id && e.refcount.is_zero() => { + let eligible = + index.is_gc_eligible(&cid_bytes, current_epoch, now, grace_period_ms); + match eligible { + true => { + tracing::debug!( + ?cid_bytes, + file_id = %source_file_id, + "gc: collecting dead block" + ); + hint_writer.append_remove(&cid_bytes)?; + dead_cids.push(cid_bytes); + dead_count = dead_count.saturating_add(1); + } + false => { + let loc = writer.append_block(&cid_bytes, &data)?; + hint_writer.append_relocate( + &cid_bytes, + loc.file_id, + loc.offset, + loc.length, + )?; + relocations.push((cid_bytes, loc)); + live_count = live_count.saturating_add(1); + } + } + } + _ => {} + }, + ReadBlockRecord::Corrupted { .. } | ReadBlockRecord::Truncated { .. } => {} + } + Ok::<_, CompactionError>(()) + })?; + + writer.sync()?; + hint_writer.sync()?; + manager.io().sync_dir(manager.data_dir())?; + + let new_size = writer.position().raw(); + + index.apply_compaction(&relocations, &dead_cids); + + Ok((new_size, live_count, dead_count)) +} diff --git a/crates/tranquil-store/src/blockstore/group_commit.rs b/crates/tranquil-store/src/blockstore/group_commit.rs index 50cf054..feb49aa 100644 --- a/crates/tranquil-store/src/blockstore/group_commit.rs +++ b/crates/tranquil-store/src/blockstore/group_commit.rs @@ -1,24 +1,110 @@ -use std::cell::Cell; use std::collections::HashMap; use std::io; use std::sync::Arc; +use std::sync::atomic::{AtomicU8, AtomicU32, Ordering}; use std::thread; +use parking_lot::RwLock; + use crate::fsync_order::PostBlockstoreHook; use super::BlocksSynced; use crate::io::{FileId, OpenOptions, StorageIO}; use super::data_file::{CID_SIZE, DataFileWriter}; +use super::hash_index::{BlockIndex, BlockIndexError, CheckpointPositions}; use super::hint::{HintFileWriter, hint_file_path}; -use super::key_index::{KeyIndex, KeyIndexError}; use super::manager::DataFileManager; -use super::types::{BlockLocation, BlockOffset, DataFileId, HintOffset, WriteCursor}; +use super::types::{ + BlockLocation, BlockOffset, BlockstoreSnapshot, CommitEpoch, DataFileId, EpochCounter, + HintOffset, ShardId, WriteCursor, +}; + +pub struct FileIdAllocator { + next: AtomicU32, +} + +impl FileIdAllocator { + pub fn new(max_existing: DataFileId) -> Self { + Self { + next: AtomicU32::new(max_existing.raw().saturating_add(1)), + } + } + + pub fn allocate(&self) -> DataFileId { + let id = self + .next + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| v.checked_add(1)) + .expect("FileIdAllocator overflow: exhausted u32 file ID space"); + DataFileId::new(id) + } + + pub fn peek(&self) -> DataFileId { + DataFileId::new(self.next.load(Ordering::Relaxed)) + } +} + +pub struct ActiveFileSet { + files: RwLock>, +} + +impl ActiveFileSet { + pub fn with_capacity(n: usize) -> Self { + Self { + files: RwLock::new(Vec::with_capacity(n)), + } + } + + pub fn register(&self, shard: ShardId, file_id: DataFileId) { + let mut files = self.files.write(); + let idx = shard.as_usize(); + if idx >= files.len() { + files.resize(idx.saturating_add(1), DataFileId::new(0)); + } + files[idx] = file_id; + } + + pub fn contains(&self, file_id: DataFileId) -> bool { + self.files.read().contains(&file_id) + } + + pub fn snapshot(&self) -> Vec { + self.files.read().clone() + } +} + +pub struct ShardHintPositions { + positions: RwLock>, +} + +impl ShardHintPositions { + pub fn new(shard_count: u8) -> Self { + Self { + positions: RwLock::new( + (0..shard_count as usize) + .map(|_| (DataFileId::new(0), HintOffset::new(0))) + .collect(), + ), + } + } + + pub fn update(&self, shard_id: ShardId, file_id: DataFileId, offset: HintOffset) { + let mut positions = self.positions.write(); + let idx = shard_id.as_usize(); + if idx < positions.len() { + positions[idx] = (file_id, offset); + } + } + + pub fn snapshot(&self) -> CheckpointPositions { + CheckpointPositions(self.positions.read().clone()) + } +} #[derive(Debug, Clone)] pub enum CommitError { Io(Arc), - Index(Arc), + Index(String), ChannelClosed, } @@ -26,7 +112,7 @@ impl std::fmt::Display for CommitError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Io(e) => write!(f, "io: {}", e.as_ref()), - Self::Index(e) => write!(f, "index: {}", e.as_ref()), + Self::Index(e) => write!(f, "index: {e}"), Self::ChannelClosed => write!(f, "commit channel closed"), } } @@ -36,8 +122,7 @@ impl std::error::Error for CommitError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(e) => Some(e.as_ref()), - Self::Index(e) => Some(e.as_ref()), - Self::ChannelClosed => None, + Self::Index(_) | Self::ChannelClosed => None, } } } @@ -48,14 +133,21 @@ impl From for CommitError { } } -impl From for CommitError { - fn from(e: KeyIndexError) -> Self { - Self::Index(Arc::new(e)) +impl From for CommitError { + fn from(e: BlockIndexError) -> Self { + Self::Index(e.to_string()) } } +use super::compaction::{self, CompactionError}; +use super::types::{CidBytes, CompactionResult, RefCount}; + type PutResponse = tokio::sync::oneshot::Sender, CommitError>>; type ApplyResponse = tokio::sync::oneshot::Sender>; +type CompactResponse = tokio::sync::oneshot::Sender>; +type RepairResponse = tokio::sync::oneshot::Sender>; +type QuiesceResponse = tokio::sync::oneshot::Sender; +type QuiesceResume = tokio::sync::oneshot::Receiver<()>; pub enum CommitRequest { PutBlocks { @@ -67,6 +159,19 @@ pub enum CommitRequest { deleted_cids: Vec<[u8; CID_SIZE]>, response: ApplyResponse, }, + Compact { + file_id: DataFileId, + grace_period_ms: u64, + response: CompactResponse, + }, + RepairLeaked { + leaked_cids: Vec<(CidBytes, RefCount)>, + response: RepairResponse, + }, + Quiesce { + response: QuiesceResponse, + resume: QuiesceResume, + }, Shutdown, } @@ -74,6 +179,8 @@ pub enum CommitRequest { pub struct GroupCommitConfig { pub max_batch_size: usize, pub channel_capacity: usize, + pub checkpoint_interval_ms: u64, + pub checkpoint_write_threshold: u64, } impl Default for GroupCommitConfig { @@ -81,10 +188,20 @@ impl Default for GroupCommitConfig { Self { max_batch_size: 1024, channel_capacity: 4096, + checkpoint_interval_ms: 60_000, + checkpoint_write_threshold: 100_000, } } } +struct ShardContext { + shard_id: ShardId, + epoch: EpochCounter, + file_ids: Arc, + active_files: Arc, + hint_positions: Arc, +} + struct ActiveState { file_id: DataFileId, fd: FileId, @@ -102,33 +219,29 @@ fn log_thread_panic(payload: Box, context: &str) { tracing::error!(panic = msg, "{context}"); } -pub struct GroupCommitWriter { +struct SingleShardWriter { sender: flume::Sender, handle: Option>, } -impl GroupCommitWriter { - pub fn spawn( +impl SingleShardWriter { + fn spawn( + ctx: ShardContext, manager: DataFileManager, - index: Arc, - config: GroupCommitConfig, - ) -> Result { - Self::spawn_with_hook(manager, index, config, None) - } - - pub fn spawn_with_hook( - manager: DataFileManager, - index: Arc, + index: Arc, config: GroupCommitConfig, post_sync_hook: Option>, + cursor: Option, ) -> Result { - let cursor = index.read_write_cursor().map_err(CommitError::from)?; - let mut state = initialize_active_state(&manager, cursor)?; + let mut state = initialize_active_state(&manager, cursor, &ctx.file_ids)?; + ctx.active_files.register(ctx.shard_id, state.file_id); + ctx.hint_positions + .update(ctx.shard_id, state.file_id, state.hint_position); let (sender, receiver) = flume::bounded(config.channel_capacity); let handle = thread::Builder::new() - .name("blockstore-group-commit".into()) + .name(format!("blockstore-commit-{}", ctx.shard_id)) .spawn(move || { commit_loop( &manager, @@ -137,6 +250,7 @@ impl GroupCommitWriter { &config, &mut state, post_sync_hook.as_deref(), + &ctx, ); }) .map_err(|e| CommitError::from(io::Error::other(e)))?; @@ -147,11 +261,7 @@ impl GroupCommitWriter { }) } - pub fn sender(&self) -> &flume::Sender { - &self.sender - } - - pub fn shutdown(mut self) { + fn shutdown(&mut self) { let _ = self.sender.send(CommitRequest::Shutdown); if let Some(handle) = self.handle.take() && let Err(payload) = handle.join() @@ -161,7 +271,7 @@ impl GroupCommitWriter { } } -impl Drop for GroupCommitWriter { +impl Drop for SingleShardWriter { fn drop(&mut self) { let _ = self.sender.try_send(CommitRequest::Shutdown); if let Some(handle) = self.handle.take() @@ -172,12 +282,261 @@ impl Drop for GroupCommitWriter { } } +fn shard_for_cid(cid: &[u8; CID_SIZE], shard_count: u8) -> usize { + match shard_count { + 0 | 1 => 0, + n => { + let hash_bytes: [u8; 8] = cid[4..12].try_into().unwrap(); + let hash = u64::from_le_bytes(hash_bytes); + match n.is_power_of_two() { + true => (hash & (n as u64 - 1)) as usize, + false => (hash % n as u64) as usize, + } + } + } +} + +fn pick_shard_for_blocks(blocks: &[([u8; CID_SIZE], Vec)], shard_count: u8) -> usize { + match blocks.first() { + Some((cid, _)) => shard_for_cid(cid, shard_count), + None => 0, + } +} + +fn pick_shard_for_apply( + blocks: &[([u8; CID_SIZE], Vec)], + deleted_cids: &[[u8; CID_SIZE]], + shard_count: u8, +) -> usize { + match blocks.first() { + Some((cid, _)) => shard_for_cid(cid, shard_count), + None => match deleted_cids.first() { + Some(cid) => shard_for_cid(cid, shard_count), + None => 0, + }, + } +} + +pub struct GroupCommitWriter { + shards: Vec, + epoch: EpochCounter, + shard_count: u8, + round_robin: AtomicU8, + _file_ids: Arc, + _active_files: Arc, + _hint_positions: Arc, +} + +impl GroupCommitWriter { + pub fn spawn( + make_manager: impl Fn() -> DataFileManager, + index: Arc, + config: GroupCommitConfig, + ) -> Result { + Self::spawn_sharded(make_manager, index, config, None, None, 1, None) + } + + pub fn spawn_with_hook( + make_manager: impl Fn() -> DataFileManager, + index: Arc, + config: GroupCommitConfig, + post_sync_hook: Option>, + initial_epoch: Option, + ) -> Result { + Self::spawn_sharded( + make_manager, + index, + config, + post_sync_hook, + initial_epoch, + 1, + None, + ) + } + + pub fn spawn_sharded( + make_manager: F, + index: Arc, + config: GroupCommitConfig, + post_sync_hook: Option>, + initial_epoch: Option, + shard_count: u8, + checkpoint_positions: Option<&CheckpointPositions>, + ) -> Result + where + S: StorageIO + 'static, + F: Fn() -> DataFileManager, + { + let shard_count = shard_count.max(1); + + let probe_manager = make_manager(); + let existing_files = probe_manager.list_files()?; + let max_existing = existing_files.last().copied().unwrap_or(DataFileId::new(0)); + let file_ids = Arc::new(FileIdAllocator::new(max_existing)); + let active_files = Arc::new(ActiveFileSet::with_capacity(shard_count as usize)); + let hint_positions = Arc::new(ShardHintPositions::new(shard_count)); + drop(probe_manager); + + let epoch = match initial_epoch { + Some(e) => EpochCounter::from_raw(e.raw()), + None => EpochCounter::new(), + }; + + let global_cursor = index.read_write_cursor(); + + let shards: Result, CommitError> = (0..shard_count) + .map(|i| { + let shard_cursor = checkpoint_positions + .and_then(|cp| cp.0.get(i as usize)) + .filter(|(fid, _)| fid.raw() > 0) + .map(|(fid, _)| WriteCursor { + file_id: *fid, + offset: BlockOffset::new(0), + }) + .or(match i { + 0 => global_cursor, + _ => None, + }); + let ctx = ShardContext { + shard_id: ShardId::new(i), + epoch: epoch.clone(), + file_ids: Arc::clone(&file_ids), + active_files: Arc::clone(&active_files), + hint_positions: Arc::clone(&hint_positions), + }; + SingleShardWriter::spawn( + ctx, + make_manager(), + Arc::clone(&index), + config.clone(), + post_sync_hook.clone(), + shard_cursor, + ) + }) + .collect(); + + Ok(Self { + shards: shards?, + epoch, + shard_count, + round_robin: AtomicU8::new(0), + _file_ids: file_ids, + _active_files: active_files, + _hint_positions: hint_positions, + }) + } + + pub fn epoch(&self) -> &EpochCounter { + &self.epoch + } + + pub fn sender_round_robin(&self) -> &flume::Sender { + let idx = self + .round_robin + .fetch_add(1, Ordering::Relaxed) + .wrapping_rem(self.shard_count) as usize; + &self.shards[idx].sender + } + + pub fn sender_for_blocks( + &self, + blocks: &[([u8; CID_SIZE], Vec)], + ) -> &flume::Sender { + &self.shards[pick_shard_for_blocks(blocks, self.shard_count)].sender + } + + pub fn sender_for_apply( + &self, + blocks: &[([u8; CID_SIZE], Vec)], + deleted_cids: &[[u8; CID_SIZE]], + ) -> &flume::Sender { + &self.shards[pick_shard_for_apply(blocks, deleted_cids, self.shard_count)].sender + } + + pub fn quiesce_all( + &self, + ) -> Result<(BlockstoreSnapshot, Vec>), CommitError> { + let pending: Result, CommitError> = self + .shards + .iter() + .map(|shard| { + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + let (resume_tx, resume_rx) = tokio::sync::oneshot::channel(); + shard + .sender + .send(CommitRequest::Quiesce { + response: response_tx, + resume: resume_rx, + }) + .map_err(|_| CommitError::ChannelClosed)?; + Ok((response_rx, resume_tx)) + }) + .collect(); + + let pairs: Result, CommitError> = pending? + .into_iter() + .map(|(response_rx, resume_tx)| { + let snapshot = response_rx + .blocking_recv() + .map_err(|_| CommitError::ChannelClosed)?; + Ok((snapshot, resume_tx)) + }) + .collect(); + let pairs = pairs?; + + let mut all_data_files: Vec = Vec::new(); + let mut shard_cursors: Vec = Vec::new(); + let mut max_epoch = CommitEpoch::zero(); + + pairs.iter().for_each(|(snap, _)| { + shard_cursors.extend(&snap.shard_cursors); + all_data_files.extend(&snap.data_files); + if snap.epoch > max_epoch { + max_epoch = snap.epoch; + } + }); + + all_data_files.sort(); + all_data_files.dedup(); + + let resumes = pairs.into_iter().map(|(_, resume)| resume).collect(); + + Ok(( + BlockstoreSnapshot { + shard_cursors, + epoch: max_epoch, + data_files: all_data_files, + }, + resumes, + )) + } + + pub fn shutdown(mut self) { + self.shards.iter_mut().for_each(|s| s.shutdown()); + } +} + +impl Drop for GroupCommitWriter { + fn drop(&mut self) { + self.shards.iter_mut().for_each(|s| { + let _ = s.sender.try_send(CommitRequest::Shutdown); + }); + self.shards.iter_mut().for_each(|s| { + if let Some(handle) = s.handle.take() + && let Err(payload) = handle.join() + { + log_thread_panic(payload, "group commit thread panicked during drop"); + } + }); + } +} + fn initialize_active_state( manager: &DataFileManager, cursor: Option, + file_ids: &FileIdAllocator, ) -> Result { let data_dir = manager.data_dir(); - let existing_files = manager.list_files()?; match cursor { Some(wc) => { @@ -204,11 +563,7 @@ fn initialize_active_state( }) } None => { - let file_id = existing_files - .last() - .copied() - .map(|id| id.next()) - .unwrap_or_else(|| DataFileId::new(0)); + let file_id = file_ids.allocate(); let fd = manager.open_for_append(file_id)?; let writer = DataFileWriter::new(manager.io(), fd, file_id)?; @@ -243,19 +598,56 @@ enum BatchEntry { }, } -fn classify_request(req: CommitRequest) -> Result { +enum ClassifyResult { + Batch(BatchEntry), + Shutdown, + Compact { + file_id: DataFileId, + grace_period_ms: u64, + response: CompactResponse, + }, + Repair { + leaked_cids: Vec<(CidBytes, RefCount)>, + response: RepairResponse, + }, + Quiesce { + response: QuiesceResponse, + resume: QuiesceResume, + }, +} + +fn classify_request(req: CommitRequest) -> ClassifyResult { match req { - CommitRequest::PutBlocks { blocks, response } => Ok(BatchEntry::Put { blocks, response }), + CommitRequest::PutBlocks { blocks, response } => { + ClassifyResult::Batch(BatchEntry::Put { blocks, response }) + } CommitRequest::ApplyCommit { blocks, deleted_cids, response, - } => Ok(BatchEntry::Apply { + } => ClassifyResult::Batch(BatchEntry::Apply { blocks, deleted_cids, response, }), - CommitRequest::Shutdown => Err(()), + CommitRequest::Compact { + file_id, + grace_period_ms, + response, + } => ClassifyResult::Compact { + file_id, + grace_period_ms, + response, + }, + CommitRequest::RepairLeaked { + leaked_cids, + response, + } => ClassifyResult::Repair { + leaked_cids, + response, + }, + CommitRequest::Quiesce { response, resume } => ClassifyResult::Quiesce { response, resume }, + CommitRequest::Shutdown => ClassifyResult::Shutdown, } } @@ -265,77 +657,330 @@ fn batch_entry_block_count(entry: &BatchEntry) -> usize { } } +struct DrainResult { + entries: Vec, + shutdown: bool, + deferred_compacts: Vec<(DataFileId, u64, CompactResponse)>, + deferred_repairs: Vec<(Vec<(CidBytes, RefCount)>, RepairResponse)>, + deferred_quiesces: Vec<(QuiesceResponse, QuiesceResume)>, +} + fn drain_batch( receiver: &flume::Receiver, first: CommitRequest, max_batch_size: usize, -) -> (Vec, bool) { +) -> DrainResult { let first_entry = match classify_request(first) { - Err(()) => return (Vec::new(), true), - Ok(entry) => entry, + ClassifyResult::Shutdown => { + return DrainResult { + entries: Vec::new(), + shutdown: true, + deferred_compacts: Vec::new(), + deferred_repairs: Vec::new(), + deferred_quiesces: Vec::new(), + }; + } + ClassifyResult::Compact { + file_id, + grace_period_ms, + response, + } => { + return DrainResult { + entries: Vec::new(), + shutdown: false, + deferred_compacts: vec![(file_id, grace_period_ms, response)], + deferred_repairs: Vec::new(), + deferred_quiesces: Vec::new(), + }; + } + ClassifyResult::Repair { + leaked_cids, + response, + } => { + return DrainResult { + entries: Vec::new(), + shutdown: false, + deferred_compacts: Vec::new(), + deferred_repairs: vec![(leaked_cids, response)], + deferred_quiesces: Vec::new(), + }; + } + ClassifyResult::Quiesce { response, resume } => { + return DrainResult { + entries: Vec::new(), + shutdown: false, + deferred_compacts: Vec::new(), + deferred_repairs: Vec::new(), + deferred_quiesces: vec![(response, resume)], + }; + } + ClassifyResult::Batch(entry) => entry, }; - let block_count = Cell::new(batch_entry_block_count(&first_entry)); - let mut entries = vec![first_entry]; + let mut block_count = batch_entry_block_count(&first_entry); + let mut result = DrainResult { + entries: vec![first_entry], + shutdown: false, + deferred_compacts: Vec::new(), + deferred_repairs: Vec::new(), + deferred_quiesces: Vec::new(), + }; - let saw_shutdown = std::iter::from_fn(|| receiver.try_recv().ok()) - .take_while(|_| block_count.get() < max_batch_size) - .try_for_each(|req| match classify_request(req) { - Err(()) => Err(()), - Ok(entry) => { - block_count.set( - block_count - .get() - .saturating_add(batch_entry_block_count(&entry)), - ); - entries.push(entry); - Ok(()) + let ingest = |req: CommitRequest, bc: &mut usize, r: &mut DrainResult| -> bool { + match classify_request(req) { + ClassifyResult::Shutdown => true, + ClassifyResult::Compact { + file_id, + grace_period_ms, + response, + } => { + r.deferred_compacts + .push((file_id, grace_period_ms, response)); + false } - }) - .is_err(); + ClassifyResult::Repair { + leaked_cids, + response, + } => { + r.deferred_repairs.push((leaked_cids, response)); + false + } + ClassifyResult::Quiesce { response, resume } => { + r.deferred_quiesces.push((response, resume)); + false + } + ClassifyResult::Batch(entry) => { + *bc = bc.saturating_add(batch_entry_block_count(&entry)); + r.entries.push(entry); + false + } + } + }; - (entries, saw_shutdown) + while block_count < max_batch_size { + match receiver.try_recv() { + Ok(req) => { + if ingest(req, &mut block_count, &mut result) { + result.shutdown = true; + break; + } + } + Err(_) => break, + } + } + + result +} + +fn capture_snapshot( + manager: &DataFileManager, + state: &ActiveState, + epoch: &EpochCounter, +) -> BlockstoreSnapshot { + BlockstoreSnapshot { + shard_cursors: vec![WriteCursor { + file_id: state.file_id, + offset: state.position, + }], + epoch: epoch.current(), + data_files: manager.list_files().unwrap_or_default(), + } +} + +fn handle_quiesce( + manager: &DataFileManager, + state: &ActiveState, + epoch: &EpochCounter, + response: QuiesceResponse, + resume: QuiesceResume, +) { + let snapshot = capture_snapshot(manager, state, epoch); + let _ = response.send(snapshot); + let _ = resume.blocking_recv(); +} + +fn maybe_checkpoint( + index: &BlockIndex, + epoch: &EpochCounter, + config: &GroupCommitConfig, + last_checkpoint: &mut std::time::Instant, + writes_since_checkpoint: &mut u64, + hint_positions: &ShardHintPositions, +) { + let interval = std::time::Duration::from_millis(config.checkpoint_interval_ms); + let elapsed = last_checkpoint.elapsed() >= interval; + let threshold = *writes_since_checkpoint >= config.checkpoint_write_threshold; + if !elapsed && !threshold { + return; + } + let positions = hint_positions.snapshot(); + match index.write_checkpoint(epoch.current(), &positions) { + Ok(()) => { + *last_checkpoint = std::time::Instant::now(); + *writes_since_checkpoint = 0; + tracing::debug!("periodic checkpoint written"); + } + Err(e) => { + tracing::warn!(error = %e, "periodic checkpoint failed"); + } + } +} + +fn shutdown_checkpoint( + index: &BlockIndex, + epoch: &EpochCounter, + hint_positions: &ShardHintPositions, +) { + let positions = hint_positions.snapshot(); + match index.write_checkpoint(epoch.current(), &positions) { + Ok(()) => tracing::debug!("shutdown checkpoint written"), + Err(e) => tracing::warn!(error = %e, "shutdown checkpoint failed"), + } } fn commit_loop( manager: &DataFileManager, - index: &KeyIndex, + index: &BlockIndex, receiver: &flume::Receiver, config: &GroupCommitConfig, state: &mut ActiveState, post_sync_hook: Option<&dyn PostBlockstoreHook>, + ctx: &ShardContext, ) { + let epoch = &ctx.epoch; + let mut last_checkpoint = std::time::Instant::now(); + let mut writes_since_checkpoint: u64 = 0; + loop { let first = match receiver.recv() { - Ok(CommitRequest::Shutdown) => return, + Ok(CommitRequest::Shutdown) => { + shutdown_checkpoint(index, epoch, &ctx.hint_positions); + return; + } + Ok(CommitRequest::Compact { + file_id, + grace_period_ms, + response, + }) => { + let result = compaction::compact_on_writer_thread( + manager, + index, + file_id, + epoch.current(), + grace_period_ms, + &ctx.file_ids, + &ctx.active_files, + &ctx.hint_positions, + epoch, + ); + let _ = response.send(result); + continue; + } + Ok(CommitRequest::RepairLeaked { + leaked_cids, + response, + }) => { + let repaired = index.repair_leaked_refcounts( + &leaked_cids, + epoch.current(), + crate::wall_clock_ms(), + ); + let _ = response.send(Ok(repaired)); + continue; + } + Ok(CommitRequest::Quiesce { response, resume }) => { + handle_quiesce(manager, state, epoch, response, resume); + continue; + } Ok(msg) => msg, - Err(_) => return, + Err(_) => { + shutdown_checkpoint(index, epoch, &ctx.hint_positions); + return; + } }; - let (batch, shutdown_after) = drain_batch(receiver, first, config.max_batch_size); + let drain_start = std::time::Instant::now(); + let drain = drain_batch(receiver, first, config.max_batch_size); + let drain_us = drain_start.elapsed().as_nanos() as u64 / 1000; - tracing::debug!( - batch_size = batch.len(), - file_id = %state.file_id, - "processing commit batch" + if !drain.entries.is_empty() { + tracing::debug!( + batch_size = drain.entries.len(), + drain_us, + file_id = %state.file_id, + "processing commit batch" + ); + + let result = process_batch(manager, index, &drain.entries, state, ctx); + + if let Ok((ref _dedup, ref proof)) = result { + run_post_sync_hook(post_sync_hook, proof); + } + + if let Err(ref e) = result { + tracing::warn!(error = %e, "commit batch failed"); + } + + if let Ok((ref dedup, _)) = result { + writes_since_checkpoint = + writes_since_checkpoint.saturating_add(dedup.len() as u64); + ctx.hint_positions + .update(ctx.shard_id, state.file_id, state.hint_position); + } + + dispatch_responses(drain.entries, result.map(|(dedup, _proof)| dedup)); + } + + drain + .deferred_compacts + .into_iter() + .for_each(|(file_id, grace_period_ms, response)| { + let result = compaction::compact_on_writer_thread( + manager, + index, + file_id, + epoch.current(), + grace_period_ms, + &ctx.file_ids, + &ctx.active_files, + &ctx.hint_positions, + epoch, + ); + let _ = response.send(result); + }); + + drain + .deferred_repairs + .into_iter() + .for_each(|(leaked_cids, response)| { + let repaired = index.repair_leaked_refcounts( + &leaked_cids, + epoch.current(), + crate::wall_clock_ms(), + ); + let _ = response.send(Ok(repaired)); + }); + + maybe_checkpoint( + index, + epoch, + config, + &mut last_checkpoint, + &mut writes_since_checkpoint, + &ctx.hint_positions, ); - let result = process_batch(manager, index, &batch, state); - - if let Ok((ref _dedup, ref proof)) = result { - run_post_sync_hook(post_sync_hook, proof); - } - - if let Err(ref e) = result { - tracing::warn!(error = %e, "commit batch failed"); - } - - dispatch_responses(batch, result.map(|(dedup, _proof)| dedup)); - - if shutdown_after { - drain_and_process_remaining(manager, index, receiver, state, post_sync_hook); + if drain.shutdown { + drain_and_process_remaining(manager, index, receiver, state, post_sync_hook, ctx); return; } + + drain + .deferred_quiesces + .into_iter() + .for_each(|(response, resume)| { + handle_quiesce(manager, state, epoch, response, resume); + }); } } @@ -349,26 +994,67 @@ fn run_post_sync_hook(hook: Option<&dyn PostBlockstoreHook>, proof: &BlocksSynce fn drain_and_process_remaining( manager: &DataFileManager, - index: &KeyIndex, + index: &BlockIndex, receiver: &flume::Receiver, state: &mut ActiveState, post_sync_hook: Option<&dyn PostBlockstoreHook>, + ctx: &ShardContext, ) { - let entries: Vec = std::iter::from_fn(|| receiver.try_recv().ok()) - .filter_map(|req| classify_request(req).ok()) - .collect(); + let epoch = &ctx.epoch; + let mut entries: Vec = Vec::new(); + let mut compacts: Vec<(DataFileId, u64, CompactResponse)> = Vec::new(); + let mut repairs: Vec<(Vec<(CidBytes, RefCount)>, RepairResponse)> = Vec::new(); - if entries.is_empty() { - return; + std::iter::from_fn(|| receiver.try_recv().ok()).for_each(|req| match classify_request(req) { + ClassifyResult::Batch(entry) => entries.push(entry), + ClassifyResult::Compact { + file_id, + grace_period_ms, + response, + } => compacts.push((file_id, grace_period_ms, response)), + ClassifyResult::Repair { + leaked_cids, + response, + } => repairs.push((leaked_cids, response)), + ClassifyResult::Shutdown | ClassifyResult::Quiesce { .. } => {} + }); + + if !entries.is_empty() { + let result = process_batch(manager, index, &entries, state, ctx); + + if let Ok((ref _dedup, ref proof)) = result { + run_post_sync_hook(post_sync_hook, proof); + ctx.hint_positions + .update(ctx.shard_id, state.file_id, state.hint_position); + } + + dispatch_responses(entries, result.map(|(dedup, _proof)| dedup)); } - let result = process_batch(manager, index, &entries, state); + compacts + .into_iter() + .for_each(|(file_id, grace_period_ms, response)| { + let result = compaction::compact_on_writer_thread( + manager, + index, + file_id, + epoch.current(), + grace_period_ms, + &ctx.file_ids, + &ctx.active_files, + &ctx.hint_positions, + epoch, + ); + let _ = response.send(result); + }); - if let Ok((ref _dedup, ref proof)) = result { - run_post_sync_hook(post_sync_hook, proof); - } + repairs.into_iter().for_each(|(leaked_cids, response)| { + let repaired = + index.repair_leaked_refcounts(&leaked_cids, epoch.current(), crate::wall_clock_ms()); + let _ = response.send(Ok(repaired)); + }); - dispatch_responses(entries, result.map(|(dedup, _proof)| dedup)); + shutdown_checkpoint(index, epoch, &ctx.hint_positions); } struct RotationState { @@ -378,10 +1064,14 @@ struct RotationState { fn process_batch( manager: &DataFileManager, - index: &KeyIndex, + index: &BlockIndex, batch: &[BatchEntry], state: &mut ActiveState, + ctx: &ShardContext, ) -> Result<(HashMap<[u8; CID_SIZE], BlockLocation>, BlocksSynced), CommitError> { + let epoch = &ctx.epoch; + let batch_start = std::time::Instant::now(); + let mut dedup: HashMap<[u8; CID_SIZE], BlockLocation> = HashMap::new(); let mut index_entries: Vec<([u8; CID_SIZE], BlockLocation)> = Vec::new(); let mut all_decrements: Vec<[u8; CID_SIZE]> = Vec::new(); @@ -394,6 +1084,10 @@ fn process_batch( let mut hint_writer = HintFileWriter::resume(manager.io(), current_hint_fd, state.hint_position); + let mut block_bytes: u64 = 0; + let mut block_count: u64 = 0; + let mut dedup_hits: u64 = 0; + let write_result: Result<(), CommitError> = batch.iter().try_for_each(|entry| { let (blocks, decrements) = match entry { BatchEntry::Put { blocks, .. } => (blocks.as_slice(), None), @@ -406,13 +1100,17 @@ fn process_batch( blocks.iter().try_for_each(|(cid_bytes, data)| { let location = match dedup.get(cid_bytes) { - Some(&loc) => loc, + Some(&loc) => { + dedup_hits = dedup_hits.saturating_add(1); + loc + } None => { if manager.should_rotate(data_writer.position()) { data_writer.sync()?; hint_writer.sync()?; - let (next_id, next_fd) = manager.prepare_rotation(data_writer.file_id())?; + let next_id = ctx.file_ids.allocate(); + let next_fd = manager.open_for_append(next_id)?; tracing::info!( from = %data_writer.file_id(), @@ -440,6 +1138,8 @@ fn process_batch( let loc = data_writer.append_block(cid_bytes, data)?; hint_writer.append_hint(cid_bytes, loc.file_id, loc.offset, loc.length)?; + block_bytes = block_bytes.saturating_add(data.len() as u64); + block_count = block_count.saturating_add(1); dedup.insert(*cid_bytes, loc); loc } @@ -463,11 +1163,23 @@ fn process_batch( return Err(e); } + let write_nanos = batch_start.elapsed().as_nanos() as u64; + + let current_epoch = epoch.current(); + let now = crate::wall_clock_ms(); + + all_decrements + .iter() + .try_for_each(|cid| hint_writer.append_decrement(cid, current_epoch, now))?; + + let t = std::time::Instant::now(); data_writer.sync()?; hint_writer.sync()?; + let sync_nanos = t.elapsed().as_nanos() as u64; if let Some(ref rot) = rotation { manager.commit_rotation(rot.file_id, rot.fd); + ctx.active_files.register(ctx.shard_id, rot.file_id); } state.file_id = data_writer.file_id(); @@ -480,9 +1192,28 @@ fn process_batch( file_id: state.file_id, offset: state.position, }; + let t = std::time::Instant::now(); index - .batch_put(&index_entries, &all_decrements, cursor) + .batch_put(&index_entries, &all_decrements, cursor, current_epoch, now) .map_err(CommitError::from)?; + let index_nanos = t.elapsed().as_nanos() as u64; + + epoch.advance(); + + let total_nanos = batch_start.elapsed().as_nanos() as u64; + + tracing::info!( + blocks = block_count, + bytes = block_bytes, + dedup_hits, + decrements = all_decrements.len(), + entries = batch.len(), + write_us = write_nanos / 1000, + sync_us = sync_nanos / 1000, + index_us = index_nanos / 1000, + total_us = total_nanos / 1000, + "commit batch profile" + ); Ok((dedup, BlocksSynced::new())) } @@ -534,458 +1265,3 @@ fn dispatch_responses( } } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::RealIO; - use crate::blockstore::data_file::DataFileReader; - use crate::blockstore::manager::DATA_FILE_EXTENSION; - use crate::blockstore::test_cid; - use futures::StreamExt; - - fn setup_real(dir: &std::path::Path) -> (DataFileManager, Arc) { - let data_dir = dir.join("data"); - std::fs::create_dir_all(&data_dir).unwrap(); - let index_dir = dir.join("index"); - let manager = DataFileManager::with_default_max_size(RealIO::new(), data_dir); - let index = Arc::new(KeyIndex::open(&index_dir).unwrap().into_inner()); - (manager, index) - } - - async fn put_blocks( - sender: &flume::Sender, - blocks: Vec<([u8; CID_SIZE], Vec)>, - ) -> Result, CommitError> { - let (tx, rx) = tokio::sync::oneshot::channel(); - sender - .send_async(CommitRequest::PutBlocks { - blocks, - response: tx, - }) - .await - .map_err(|_| CommitError::ChannelClosed)?; - rx.await.map_err(|_| CommitError::ChannelClosed)? - } - - async fn apply_commit_req( - sender: &flume::Sender, - blocks: Vec<([u8; CID_SIZE], Vec)>, - deleted_cids: Vec<[u8; CID_SIZE]>, - ) -> Result<(), CommitError> { - let (tx, rx) = tokio::sync::oneshot::channel(); - sender - .send_async(CommitRequest::ApplyCommit { - blocks, - deleted_cids, - response: tx, - }) - .await - .map_err(|_| CommitError::ChannelClosed)?; - rx.await.map_err(|_| CommitError::ChannelClosed)? - } - - fn count_data_file_blocks(data_dir: &std::path::Path) -> usize { - let io = RealIO::new(); - let data_files = - super::super::list_files_by_extension(&io, data_dir, DATA_FILE_EXTENSION).unwrap(); - data_files - .iter() - .map(|&fid| { - let path = data_dir.join(format!("{fid}.tqb")); - let fd = io.open(&path, OpenOptions::read_only_existing()).unwrap(); - let count = DataFileReader::open(&io, fd) - .unwrap() - .valid_blocks() - .unwrap() - .len(); - let _ = io.close(fd); - count - }) - .sum() - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn concurrent_100_writes_from_10_tasks() { - let dir = tempfile::TempDir::new().unwrap(); - let (manager, index) = setup_real(dir.path()); - let data_dir = manager.data_dir().to_path_buf(); - let writer = - GroupCommitWriter::spawn(manager, index, GroupCommitConfig::default()).unwrap(); - let sender = writer.sender().clone(); - - let handles: Vec<_> = (0u8..10) - .map(|task_id| { - let sender = sender.clone(); - tokio::spawn(async move { - let blocks: Vec<_> = (0u8..10) - .map(|block_id| { - let idx = task_id * 10 + block_id; - (test_cid(idx), vec![idx; (idx as usize + 1) * 8]) - }) - .collect(); - - futures::stream::iter(blocks) - .fold( - Vec::::new(), - |mut acc, (cid, data): ([u8; CID_SIZE], Vec)| { - let sender = sender.clone(); - async move { - let locs = - put_blocks(&sender, vec![(cid, data)]).await.unwrap(); - acc.extend(locs); - acc - } - }, - ) - .await - }) - }) - .collect(); - - let all_locations: Vec> = futures::future::join_all(handles) - .await - .into_iter() - .map(|r| r.unwrap()) - .collect(); - - let total: usize = all_locations.iter().map(|v| v.len()).sum(); - assert_eq!(total, 100); - - writer.shutdown(); - - let index_dir = dir.path().join("index"); - let index = KeyIndex::open(&index_dir).unwrap().into_inner(); - (0u8..100).for_each(|i| { - assert!( - index.has(&test_cid(i)).unwrap(), - "block {i} missing from index" - ); - }); - - assert_eq!(count_data_file_blocks(&data_dir), 100); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn duplicate_cids_in_same_batch_write_once() { - let dir = tempfile::TempDir::new().unwrap(); - let (manager, index) = setup_real(dir.path()); - let data_dir = manager.data_dir().to_path_buf(); - let writer = - GroupCommitWriter::spawn(manager, index, GroupCommitConfig::default()).unwrap(); - let sender = writer.sender().clone(); - - let cid = test_cid(42); - let data = vec![0xAB; 128]; - let blocks = vec![ - (cid, data.clone()), - (cid, data.clone()), - (cid, data.clone()), - ]; - - let locations = put_blocks(&sender, blocks).await.unwrap(); - - assert_eq!(locations.len(), 3); - assert_eq!(locations[0], locations[1]); - assert_eq!(locations[1], locations[2]); - - writer.shutdown(); - - let index_dir = dir.path().join("index"); - let index = KeyIndex::open(&index_dir).unwrap().into_inner(); - let entry = index.get(&cid).unwrap().unwrap(); - assert_eq!(entry.refcount.raw(), 3); - - assert_eq!( - count_data_file_blocks(&data_dir), - 1, - "duplicate CID should only be written once to data file" - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn apply_commit_with_blocks_and_deletes() { - let dir = tempfile::TempDir::new().unwrap(); - let (manager, index) = setup_real(dir.path()); - let writer = - GroupCommitWriter::spawn(manager, index, GroupCommitConfig::default()).unwrap(); - let sender = writer.sender().clone(); - - let cid_a = test_cid(1); - let cid_b = test_cid(2); - put_blocks( - &sender, - vec![(cid_a, vec![0x01; 64]), (cid_b, vec![0x02; 64])], - ) - .await - .unwrap(); - - let cid_c = test_cid(3); - apply_commit_req(&sender, vec![(cid_c, vec![0x03; 64])], vec![cid_a]) - .await - .unwrap(); - - writer.shutdown(); - - let index_dir = dir.path().join("index"); - let index = KeyIndex::open(&index_dir).unwrap().into_inner(); - assert_eq!(index.get(&cid_a).unwrap().unwrap().refcount.raw(), 0); - assert_eq!(index.get(&cid_b).unwrap().unwrap().refcount.raw(), 1); - assert_eq!(index.get(&cid_c).unwrap().unwrap().refcount.raw(), 1); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn graceful_shutdown_processes_remaining() { - let dir = tempfile::TempDir::new().unwrap(); - let (manager, index) = setup_real(dir.path()); - let writer = - GroupCommitWriter::spawn(manager, index, GroupCommitConfig::default()).unwrap(); - let sender = writer.sender().clone(); - - let cid = test_cid(99); - let locations = put_blocks(&sender, vec![(cid, vec![0xFF; 32])]) - .await - .unwrap(); - assert_eq!(locations.len(), 1); - - writer.shutdown(); - - let index_dir = dir.path().join("index"); - let index = KeyIndex::open(&index_dir).unwrap().into_inner(); - assert!(index.has(&cid).unwrap()); - } - - #[test] - fn sim_crash_between_write_and_fsync_loses_unsynced() { - use crate::SimulatedIO; - use std::path::Path; - use std::sync::Arc; - - let sim = Arc::new(SimulatedIO::pristine(42)); - let data_dir = Path::new("/data"); - sim.mkdir(data_dir).unwrap(); - sim.sync_dir(data_dir).unwrap(); - - let manager = - DataFileManager::with_default_max_size(Arc::clone(&sim), data_dir.to_path_buf()); - let fd = manager.open_for_append(DataFileId::new(0)).unwrap(); - let mut writer = DataFileWriter::new(&*sim, fd, DataFileId::new(0)).unwrap(); - - let synced_cids: Vec<_> = (0u8..5) - .map(|i| { - let cid = test_cid(i); - let _ = writer.append_block(&cid, &vec![i; 64]).unwrap(); - cid - }) - .collect(); - writer.sync().unwrap(); - sim.sync_dir(data_dir).unwrap(); - - (5u8..10).for_each(|i| { - let cid = test_cid(i); - let _ = writer.append_block(&cid, &vec![i; 64]).unwrap(); - }); - - sim.crash(); - - let fd_after = sim - .open(Path::new("/data/000000.tqb"), OpenOptions::read()) - .unwrap(); - let recovered = DataFileReader::open(&*sim, fd_after) - .unwrap() - .valid_blocks() - .unwrap(); - - assert!( - recovered.len() <= 5, - "expected at most 5 synced blocks, got {}", - recovered.len() - ); - - recovered.iter().enumerate().for_each(|(i, (_, cid, _))| { - assert_eq!(*cid, synced_cids[i], "recovered block {i} CID mismatch"); - }); - } - - #[test] - fn sim_crash_between_fsync_and_index_update_recovers_via_hints() { - use crate::SimulatedIO; - use crate::blockstore::data_file::{BLOCK_HEADER_SIZE, BLOCK_RECORD_OVERHEAD}; - use crate::blockstore::hint::rebuild_index_from_hints; - use crate::blockstore::types::BlockLength; - use std::path::Path; - use std::sync::Arc; - - let sim = Arc::new(SimulatedIO::pristine(42)); - let data_dir = Path::new("/data"); - sim.mkdir(data_dir).unwrap(); - sim.sync_dir(data_dir).unwrap(); - - let manager = - DataFileManager::with_default_max_size(Arc::clone(&sim), data_dir.to_path_buf()); - let fd = manager.open_for_append(DataFileId::new(0)).unwrap(); - let mut writer = DataFileWriter::new(&*sim, fd, DataFileId::new(0)).unwrap(); - - let phase1_cids: Vec<_> = (0u8..3) - .map(|i| { - let cid = test_cid(i); - let _ = writer.append_block(&cid, &vec![i; 64]).unwrap(); - cid - }) - .collect(); - writer.sync().unwrap(); - let phase1_end = writer.position(); - - let real_dir = tempfile::TempDir::new().unwrap(); - let index_path = real_dir.path().join("index"); - let index = KeyIndex::open(&index_path).unwrap().into_inner(); - - let entries: Vec<_> = phase1_cids - .iter() - .enumerate() - .map(|(i, cid)| { - let offset = BlockOffset::new( - BLOCK_HEADER_SIZE as u64 + i as u64 * (BLOCK_RECORD_OVERHEAD as u64 + 64), - ); - ( - *cid, - BlockLocation { - file_id: DataFileId::new(0), - offset, - length: BlockLength::new(64), - }, - ) - }) - .collect(); - index - .batch_put( - &entries, - &[], - WriteCursor { - file_id: DataFileId::new(0), - offset: phase1_end, - }, - ) - .unwrap(); - index.persist().unwrap(); - - let phase2_cids: Vec<_> = (10u8..15) - .map(|i| { - let cid = test_cid(i); - let _ = writer.append_block(&cid, &vec![i; 128]).unwrap(); - cid - }) - .collect(); - writer.sync().unwrap(); - sim.sync_dir(data_dir).unwrap(); - - let hint_path = hint_file_path(data_dir, DataFileId::new(0)); - let hint_fd = sim.open(&hint_path, OpenOptions::read_write()).unwrap(); - let mut hint_writer = HintFileWriter::new(&*sim, hint_fd); - - let mut offset_tracker = BlockOffset::new(BLOCK_HEADER_SIZE as u64); - phase1_cids.iter().for_each(|cid| { - hint_writer - .append_hint( - cid, - DataFileId::new(0), - offset_tracker, - BlockLength::new(64), - ) - .unwrap(); - offset_tracker = offset_tracker.advance(BLOCK_RECORD_OVERHEAD as u64 + 64); - }); - phase2_cids.iter().for_each(|cid| { - hint_writer - .append_hint( - cid, - DataFileId::new(0), - offset_tracker, - BlockLength::new(128), - ) - .unwrap(); - offset_tracker = offset_tracker.advance(BLOCK_RECORD_OVERHEAD as u64 + 128); - }); - hint_writer.sync().unwrap(); - sim.sync_dir(data_dir).unwrap(); - - sim.crash(); - - drop(index); - let rebuilt_index_path = real_dir.path().join("rebuilt_index"); - let rebuilt_index = KeyIndex::open(&rebuilt_index_path).unwrap().into_inner(); - rebuild_index_from_hints(&*sim, data_dir, &rebuilt_index).unwrap(); - - phase1_cids.iter().for_each(|cid| { - assert!( - rebuilt_index.has(cid).unwrap(), - "phase1 CID should be in rebuilt index" - ); - }); - phase2_cids.iter().for_each(|cid| { - assert!( - rebuilt_index.has(cid).unwrap(), - "phase2 CID should be in rebuilt index, was synced and hinted before crash" - ); - }); - - let cursor = rebuilt_index.read_write_cursor().unwrap().unwrap(); - assert!( - cursor.offset.raw() > phase1_end.raw(), - "cursor should be past phase1 after rebuild" - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn rotation_during_batch() { - let dir = tempfile::TempDir::new().unwrap(); - let data_dir = dir.path().join("data"); - std::fs::create_dir_all(&data_dir).unwrap(); - let index_dir = dir.path().join("index"); - - let small_max = 512u64; - let manager = DataFileManager::new(RealIO::new(), data_dir.clone(), small_max); - let index = Arc::new(KeyIndex::open(&index_dir).unwrap().into_inner()); - let writer = - GroupCommitWriter::spawn(manager, index, GroupCommitConfig::default()).unwrap(); - let sender = writer.sender().clone(); - - let all_cids: Vec<_> = (0u8..20).map(test_cid).collect(); - - let handles: Vec<_> = all_cids - .iter() - .map(|&cid| { - let sender = sender.clone(); - tokio::spawn(async move { - put_blocks(&sender, vec![(cid, vec![cid[4]; 100])]) - .await - .unwrap() - }) - }) - .collect(); - - let results: Vec<_> = futures::future::join_all(handles) - .await - .into_iter() - .map(|r| r.unwrap()) - .collect(); - - assert_eq!(results.len(), 20); - - writer.shutdown(); - - let io = RealIO::new(); - let data_files = - super::super::list_files_by_extension(&io, &data_dir, DATA_FILE_EXTENSION).unwrap(); - assert!( - data_files.len() > 1, - "expected rotation to create multiple files, got {}", - data_files.len() - ); - - let index = KeyIndex::open(&index_dir).unwrap().into_inner(); - all_cids.iter().for_each(|cid| { - assert!(index.has(cid).unwrap()); - }); - } -} diff --git a/crates/tranquil-store/src/blockstore/hash_index.rs b/crates/tranquil-store/src/blockstore/hash_index.rs new file mode 100644 index 0000000..c157624 --- /dev/null +++ b/crates/tranquil-store/src/blockstore/hash_index.rs @@ -0,0 +1,2051 @@ +use std::collections::HashMap; +use std::io; +use std::path::{Path, PathBuf}; + +use parking_lot::RwLock; + +use super::data_file::CID_SIZE; +use super::types::{ + BlockLength, BlockLocation, BlockOffset, CidBytes, CollectionResult, CommitEpoch, DataFileId, + HintOffset, IndexEntry, LivenessInfo, RefCount, WallClockMs, WriteCursor, +}; + +const EMPTY_CID: [u8; CID_SIZE] = [0u8; CID_SIZE]; + +fn is_empty(cid: &[u8; CID_SIZE]) -> bool { + *cid == EMPTY_CID +} + +fn is_occupied(cid: &[u8; CID_SIZE]) -> bool { + !is_empty(cid) +} + +fn fibonacci_hash(cid: &[u8; CID_SIZE], shift: u32) -> usize { + let hash_bytes: [u8; 8] = cid[4..12].try_into().unwrap(); + let hash = u64::from_le_bytes(hash_bytes); + (hash.wrapping_mul(11_400_714_819_323_198_485u64) >> shift) as usize +} + +#[derive(Debug, Clone, Copy)] +#[repr(C)] +pub struct Slot { + pub cid: [u8; CID_SIZE], + pub file_id: DataFileId, + pub offset: BlockOffset, + pub length: BlockLength, + pub refcount: RefCount, + pub gc_since_ms: WallClockMs, + pub gc_epoch: CommitEpoch, +} + +const _: () = assert!(std::mem::size_of::() == 72); +const _: () = assert!(std::mem::size_of::() == 36 + 4 + 8 + 4 + 4 + 8 + 8); +const _: () = assert!(!std::mem::needs_drop::()); +const _: () = assert!(std::mem::align_of::() == 8); +#[cfg(not(target_endian = "little"))] +compile_error!( + "checkpoint format uses native-endian slot serialization; only little-endian targets are supported" +); + +impl Slot { + const EMPTY: Self = Self { + cid: EMPTY_CID, + file_id: DataFileId::new(0), + offset: BlockOffset::new(0), + length: BlockLength::from_raw(0), + refcount: RefCount::new(0), + gc_since_ms: WallClockMs::new(0), + gc_epoch: CommitEpoch::new(0), + }; + + fn to_location(self) -> BlockLocation { + BlockLocation { + file_id: self.file_id, + offset: self.offset, + length: self.length, + } + } + + fn to_index_entry(self) -> IndexEntry { + IndexEntry { + location: self.to_location(), + refcount: self.refcount, + } + } + + fn from_location(cid: [u8; CID_SIZE], location: BlockLocation) -> Self { + Self { + cid, + file_id: location.file_id, + offset: location.offset, + length: location.length, + refcount: RefCount::one(), + gc_since_ms: WallClockMs::new(0), + gc_epoch: CommitEpoch::zero(), + } + } +} + +#[derive(Debug)] +pub struct CapacityExhausted; + +const MAX_SLOTS: usize = 1 << 30; + +pub struct HashTable { + slots: Vec, + capacity: usize, + count: usize, + shift: u32, + write_cursor: Option, +} + +impl HashTable { + pub fn with_capacity(min_capacity: usize) -> Self { + let capacity = min_capacity + .max(64) + .checked_next_power_of_two() + .expect("capacity overflow"); + assert!( + capacity <= MAX_SLOTS, + "requested capacity {min_capacity} rounds to {capacity} which exceeds MAX_SLOTS {MAX_SLOTS}" + ); + let shift = 64 - capacity.trailing_zeros(); + Self { + slots: vec![Slot::EMPTY; capacity], + capacity, + count: 0, + shift, + write_cursor: None, + } + } + + pub fn capacity(&self) -> usize { + self.capacity + } + + pub fn len(&self) -> usize { + self.count + } + + pub fn is_empty(&self) -> bool { + self.count == 0 + } + + fn needs_grow(&self) -> bool { + (self.count + 1) * 10 > self.capacity * 7 + } + + fn slot_index(&self, cid: &[u8; CID_SIZE]) -> usize { + fibonacci_hash(cid, self.shift) + } + + fn probe_distance(&self, slot_idx: usize, home: usize) -> usize { + (slot_idx.wrapping_sub(home)) & (self.capacity - 1) + } + + pub fn get(&self, cid: &[u8; CID_SIZE]) -> Option<&Slot> { + let home = self.slot_index(cid); + let mut idx = home; + let mut dist = 0usize; + + loop { + let slot = &self.slots[idx]; + + if is_empty(&slot.cid) { + return None; + } + + let slot_home = self.slot_index(&slot.cid); + let slot_dist = self.probe_distance(idx, slot_home); + if slot_dist < dist { + return None; + } + if slot.cid == *cid { + return Some(slot); + } + + dist += 1; + idx = (idx + 1) & (self.capacity - 1); + + if dist >= self.capacity { + return None; + } + } + } + + pub fn get_mut(&mut self, cid: &[u8; CID_SIZE]) -> Option<&mut Slot> { + let home = self.slot_index(cid); + let mut idx = home; + let mut dist = 0usize; + + loop { + let slot_cid = self.slots[idx].cid; + + if is_empty(&slot_cid) { + return None; + } + + let slot_home = self.slot_index(&slot_cid); + let slot_dist = self.probe_distance(idx, slot_home); + if slot_dist < dist { + return None; + } + if slot_cid == *cid { + return Some(&mut self.slots[idx]); + } + + dist += 1; + idx = (idx + 1) & (self.capacity - 1); + + if dist >= self.capacity { + return None; + } + } + } + + pub fn contains(&self, cid: &[u8; CID_SIZE]) -> bool { + self.get(cid).is_some() + } + + pub fn insert(&mut self, new_slot: Slot) -> Result, CapacityExhausted> { + if is_empty(&new_slot.cid) { + tracing::error!("attempted to insert all-zero CID into hash table"); + return Ok(None); + } + if self.needs_grow() { + self.grow()?; + } + Ok(self.insert_probing(new_slot)) + } + + fn insert_probing(&mut self, mut new_slot: Slot) -> Option { + let home = self.slot_index(&new_slot.cid); + let mut idx = home; + let mut dist = 0usize; + + loop { + let slot_cid = self.slots[idx].cid; + + if is_empty(&slot_cid) { + self.slots[idx] = new_slot; + self.count += 1; + return None; + } + + if slot_cid == new_slot.cid { + let old = self.slots[idx]; + self.slots[idx] = new_slot; + return Some(old); + } + + let slot_home = self.slot_index(&slot_cid); + let slot_dist = self.probe_distance(idx, slot_home); + if slot_dist < dist { + std::mem::swap(&mut self.slots[idx], &mut new_slot); + dist = slot_dist; + } + + dist += 1; + idx = (idx + 1) & (self.capacity - 1); + } + } + + pub fn insert_or_increment( + &mut self, + cid: &[u8; CID_SIZE], + location: BlockLocation, + ) -> Result { + if is_empty(cid) { + tracing::error!("attempted to insert all-zero CID into hash table"); + return Ok(RefCount::new(0)); + } + if self.needs_grow() { + self.grow()?; + } + Ok(self.insert_or_increment_probing(cid, location)) + } + + pub fn insert_if_absent( + &mut self, + cid: &[u8; CID_SIZE], + location: BlockLocation, + ) -> Result { + if is_empty(cid) { + return Ok(false); + } + match self.get(cid) { + Some(_) => Ok(false), + None => { + if self.needs_grow() { + self.grow()?; + } + self.insert_probing(Slot::from_location(*cid, location)); + Ok(true) + } + } + } + + fn insert_or_increment_probing( + &mut self, + cid: &[u8; CID_SIZE], + location: BlockLocation, + ) -> RefCount { + let home = self.slot_index(cid); + let mut idx = home; + let mut dist = 0usize; + + loop { + let slot_cid = self.slots[idx].cid; + + if is_empty(&slot_cid) { + self.slots[idx] = Slot::from_location(*cid, location); + self.count += 1; + return RefCount::one(); + } + + if slot_cid == *cid { + let slot = &mut self.slots[idx]; + slot.refcount = slot.refcount.saturating_increment(); + if slot.gc_since_ms > WallClockMs::new(0) { + slot.gc_since_ms = WallClockMs::new(0); + slot.gc_epoch = CommitEpoch::zero(); + } + return slot.refcount; + } + + let slot_home = self.slot_index(&slot_cid); + let slot_dist = self.probe_distance(idx, slot_home); + if slot_dist < dist { + let mut displaced = Slot::from_location(*cid, location); + std::mem::swap(&mut self.slots[idx], &mut displaced); + self.count += 1; + self.relocate_displaced(displaced, idx, slot_dist); + return RefCount::one(); + } + + dist += 1; + idx = (idx + 1) & (self.capacity - 1); + } + } + + fn relocate_displaced(&mut self, mut entry: Slot, from_idx: usize, mut dist: usize) { + dist += 1; + let mut idx = (from_idx + 1) & (self.capacity - 1); + + loop { + if is_empty(&self.slots[idx].cid) { + self.slots[idx] = entry; + return; + } + + let slot_home = self.slot_index(&self.slots[idx].cid); + let slot_dist = self.probe_distance(idx, slot_home); + if slot_dist < dist { + std::mem::swap(&mut self.slots[idx], &mut entry); + dist = slot_dist; + } + + dist += 1; + idx = (idx + 1) & (self.capacity - 1); + } + } + + #[must_use] + pub fn decrement( + &mut self, + cid: &[u8; CID_SIZE], + epoch: CommitEpoch, + now: WallClockMs, + ) -> Option { + let slot = self.get_mut(cid)?; + match slot.refcount.is_zero() { + true => { + tracing::warn!(?cid, "decrement on zero-refcount entry, skipping"); + Some(RefCount::new(0)) + } + false => { + slot.refcount = slot.refcount.decrement(); + if slot.refcount.is_zero() { + slot.gc_since_ms = now; + slot.gc_epoch = epoch; + } + Some(slot.refcount) + } + } + } + + pub fn relocate( + &mut self, + cid: &[u8; CID_SIZE], + new_location: BlockLocation, + ) -> Result { + if is_empty(cid) { + return Ok(false); + } + if self.needs_grow() { + self.grow()?; + } + + let home = self.slot_index(cid); + let mut idx = home; + let mut dist = 0usize; + + loop { + let slot_cid = self.slots[idx].cid; + + if is_empty(&slot_cid) { + self.slots[idx] = Slot::from_location(*cid, new_location); + self.count += 1; + return Ok(false); + } + + if slot_cid == *cid { + let slot = &mut self.slots[idx]; + slot.file_id = new_location.file_id; + slot.offset = new_location.offset; + slot.length = new_location.length; + return Ok(true); + } + + let slot_home = self.slot_index(&slot_cid); + let slot_dist = self.probe_distance(idx, slot_home); + if slot_dist < dist { + let mut displaced = Slot::from_location(*cid, new_location); + std::mem::swap(&mut self.slots[idx], &mut displaced); + self.count += 1; + self.relocate_displaced(displaced, idx, slot_dist); + return Ok(false); + } + + dist += 1; + idx = (idx + 1) & (self.capacity - 1); + } + } + + #[must_use] + pub fn remove(&mut self, cid: &[u8; CID_SIZE]) -> bool { + let home = self.slot_index(cid); + let mut idx = home; + let mut dist = 0usize; + + loop { + let slot_cid = self.slots[idx].cid; + + if is_empty(&slot_cid) { + return false; + } + + let slot_home = self.slot_index(&slot_cid); + let slot_dist = self.probe_distance(idx, slot_home); + if slot_dist < dist { + return false; + } + if slot_cid == *cid { + self.slots[idx] = Slot::EMPTY; + self.count -= 1; + self.backward_shift(idx); + return true; + } + + dist += 1; + idx = (idx + 1) & (self.capacity - 1); + + if dist >= self.capacity { + return false; + } + } + } + + fn backward_shift(&mut self, removed_idx: usize) { + let mut empty = removed_idx; + let mut probe = (empty + 1) & (self.capacity - 1); + + loop { + let slot_cid = self.slots[probe].cid; + + if is_empty(&slot_cid) { + break; + } + + let slot_home = self.slot_index(&slot_cid); + let slot_dist = self.probe_distance(probe, slot_home); + if slot_dist == 0 { + break; + } + self.slots[empty] = self.slots[probe]; + self.slots[probe] = Slot::EMPTY; + empty = probe; + + probe = (probe + 1) & (self.capacity - 1); + } + } + + fn grow(&mut self) -> Result<(), CapacityExhausted> { + let new_capacity = self.capacity.checked_mul(2).ok_or(CapacityExhausted)?; + if new_capacity > MAX_SLOTS { + return Err(CapacityExhausted); + } + self.rebuild(new_capacity); + Ok(()) + } + + fn rebuild(&mut self, new_capacity: usize) { + let new_shift = 64 - new_capacity.trailing_zeros(); + let old_slots = std::mem::replace(&mut self.slots, vec![Slot::EMPTY; new_capacity]); + + let old_count = self.count; + self.capacity = new_capacity; + self.shift = new_shift; + self.count = 0; + + old_slots + .into_iter() + .filter(|s| is_occupied(&s.cid)) + .for_each(|s| { + self.insert_probing(s); + }); + + debug_assert_eq!(self.count, old_count); + } + + pub fn set_write_cursor(&mut self, cursor: WriteCursor) { + self.write_cursor = Some(cursor); + } + + pub fn write_cursor(&self) -> Option { + self.write_cursor + } + + pub fn iter(&self) -> impl Iterator { + self.slots.iter().filter(|s| is_occupied(&s.cid)) + } + + pub fn collect_dead_blocks( + &self, + current_epoch: CommitEpoch, + now: WallClockMs, + grace_period_ms: u64, + ) -> CollectionResult { + let mut candidates: HashMap> = HashMap::new(); + let mut total_bytes: u64 = 0; + + self.iter() + .filter(|s| s.refcount.is_zero() && s.gc_since_ms > WallClockMs::new(0)) + .filter(|s| { + let epoch_advanced = current_epoch > s.gc_epoch; + let grace_expired = now.raw().saturating_sub(s.gc_since_ms.raw()) > grace_period_ms; + epoch_advanced && grace_expired + }) + .for_each(|s| { + let record_bytes = + s.length.as_u64() + super::data_file::BLOCK_RECORD_OVERHEAD as u64; + total_bytes = total_bytes.saturating_add(record_bytes); + candidates.entry(s.file_id).or_default().push(s.cid); + }); + + CollectionResult { + candidates, + total_bytes, + } + } + + pub fn is_gc_eligible( + &self, + cid: &[u8; CID_SIZE], + current_epoch: CommitEpoch, + now: WallClockMs, + grace_period_ms: u64, + ) -> bool { + self.get(cid) + .filter(|s| s.gc_since_ms > WallClockMs::new(0)) + .is_some_and(|s| { + let epoch_advanced = current_epoch > s.gc_epoch; + let grace_expired = now.raw().saturating_sub(s.gc_since_ms.raw()) > grace_period_ms; + epoch_advanced && grace_expired + }) + } + + pub fn apply_compaction( + &mut self, + relocations: &[(CidBytes, BlockLocation)], + removals: &[CidBytes], + ) { + relocations.iter().for_each(|(cid, new_loc)| { + if let Err(e) = self.relocate(cid, *new_loc) { + tracing::error!(?e, "capacity exhausted during compaction relocation"); + } + }); + + removals.iter().for_each(|cid| { + if let Some(slot) = self.get_mut(cid) + && !slot.refcount.is_zero() + { + tracing::error!( + ?cid, + refcount = slot.refcount.raw(), + "BUG: compaction removing block with non-zero refcount" + ); + } + let _ = self.remove(cid); + }); + } + + pub fn cleanup_stale_gc(&mut self) -> u64 { + self.slots + .iter_mut() + .filter(|s| { + is_occupied(&s.cid) && s.gc_since_ms > WallClockMs::new(0) && !s.refcount.is_zero() + }) + .fold(0u64, |acc, s| { + s.gc_since_ms = WallClockMs::new(0); + s.gc_epoch = CommitEpoch::zero(); + acc.saturating_add(1) + }) + } + + pub fn liveness_info(&self, file_id: DataFileId) -> LivenessInfo { + self.iter().filter(|s| s.file_id == file_id).fold( + LivenessInfo { + live_bytes: 0, + total_bytes: 0, + live_blocks: 0, + total_blocks: 0, + }, + |mut info, s| { + let record_bytes = + s.length.as_u64() + super::data_file::BLOCK_RECORD_OVERHEAD as u64; + info.total_bytes = info.total_bytes.saturating_add(record_bytes); + info.total_blocks = info.total_blocks.saturating_add(1); + if !s.refcount.is_zero() { + info.live_bytes = info.live_bytes.saturating_add(record_bytes); + info.live_blocks = info.live_blocks.saturating_add(1); + } + info + }, + ) + } + + pub fn liveness_by_file( + &self, + current_epoch: CommitEpoch, + now: WallClockMs, + grace_period_ms: u64, + ) -> HashMap { + self.iter().fold(HashMap::new(), |mut stats, s| { + let record_bytes = s.length.as_u64() + super::data_file::BLOCK_RECORD_OVERHEAD as u64; + + let info = stats.entry(s.file_id).or_insert(LivenessInfo { + live_bytes: 0, + total_bytes: 0, + live_blocks: 0, + total_blocks: 0, + }); + + info.total_bytes = info.total_bytes.saturating_add(record_bytes); + info.total_blocks = info.total_blocks.saturating_add(1); + + let is_live = match s.refcount.is_zero() { + false => true, + true => { + let gc_eligible = s.gc_since_ms > WallClockMs::new(0) + && current_epoch > s.gc_epoch + && now.raw().saturating_sub(s.gc_since_ms.raw()) > grace_period_ms; + !gc_eligible + } + }; + + if is_live { + info.live_bytes = info.live_bytes.saturating_add(record_bytes); + info.live_blocks = info.live_blocks.saturating_add(1); + } + + stats + }) + } + + pub fn find_leaked_refcounts( + &self, + is_reachable: impl Fn(&CidBytes) -> bool, + ) -> (Vec<(CidBytes, RefCount)>, u64) { + let mut leaked = Vec::new(); + let mut live_scanned: u64 = 0; + + self.iter().filter(|s| !s.refcount.is_zero()).for_each(|s| { + live_scanned = live_scanned.saturating_add(1); + if !is_reachable(&s.cid) { + leaked.push((s.cid, s.refcount)); + } + }); + + (leaked, live_scanned) + } + + pub fn approximate_count(&self) -> u64 { + self.count as u64 + } +} + +const CHECKPOINT_MAGIC: [u8; 8] = *b"TQCKPT01"; +const CHECKPOINT_VERSION_V1: u32 = 1; +const CHECKPOINT_VERSION_V2: u32 = 2; +const CHECKPOINT_HEADER_SIZE: usize = 128; +const TRAILER_MAGIC: u64 = 0xDEAD_BEEF_CAFE_F00D; +const SLOT_SIZE: usize = std::mem::size_of::(); +const SHARD_POSITION_SIZE: usize = 12; + +#[derive(Debug, Clone)] +pub struct CheckpointPositions(pub Vec<(DataFileId, HintOffset)>); + +impl CheckpointPositions { + pub fn single(file_id: DataFileId, offset: HintOffset) -> Self { + Self(vec![(file_id, offset)]) + } + + pub fn as_slice(&self) -> &[(DataFileId, HintOffset)] { + &self.0 + } +} + +const H_MAGIC: usize = 0; +const H_VERSION: usize = 8; +const H_SHARD_COUNT: usize = 12; +const H_SLOT_COUNT: usize = 16; +const H_ENTRY_COUNT: usize = 24; +const H_CURSOR_FILE_ID: usize = 40; +const H_CURSOR_OFFSET: usize = 48; +const H_CHECKPOINT_EPOCH: usize = 56; +const H_HINT_FILE_ID: usize = 64; +const H_HINT_OFFSET: usize = 72; +const H_HEADER_CHECKSUM: usize = 80; + +fn header_checksum(buf: &[u8; CHECKPOINT_HEADER_SIZE]) -> u64 { + xxhash_rust::xxh3::xxh3_64(&buf[..H_HEADER_CHECKSUM]) +} + +fn serialize_header( + slot_count: u64, + entry_count: u64, + cursor_file_id: u32, + cursor_offset: u64, + checkpoint_epoch: u64, + shard_count: u16, +) -> [u8; CHECKPOINT_HEADER_SIZE] { + let mut buf = [0u8; CHECKPOINT_HEADER_SIZE]; + buf[H_MAGIC..H_MAGIC + 8].copy_from_slice(&CHECKPOINT_MAGIC); + buf[H_VERSION..H_VERSION + 4].copy_from_slice(&CHECKPOINT_VERSION_V2.to_le_bytes()); + buf[H_SHARD_COUNT..H_SHARD_COUNT + 2].copy_from_slice(&shard_count.to_le_bytes()); + buf[H_SLOT_COUNT..H_SLOT_COUNT + 8].copy_from_slice(&slot_count.to_le_bytes()); + buf[H_ENTRY_COUNT..H_ENTRY_COUNT + 8].copy_from_slice(&entry_count.to_le_bytes()); + buf[H_CURSOR_FILE_ID..H_CURSOR_FILE_ID + 4].copy_from_slice(&cursor_file_id.to_le_bytes()); + buf[H_CURSOR_OFFSET..H_CURSOR_OFFSET + 8].copy_from_slice(&cursor_offset.to_le_bytes()); + buf[H_CHECKPOINT_EPOCH..H_CHECKPOINT_EPOCH + 8] + .copy_from_slice(&checkpoint_epoch.to_le_bytes()); + let checksum = header_checksum(&buf); + buf[H_HEADER_CHECKSUM..H_HEADER_CHECKSUM + 8].copy_from_slice(&checksum.to_le_bytes()); + buf +} + +fn slots_as_bytes(slots: &[Slot]) -> &[u8] { + unsafe { std::slice::from_raw_parts(slots.as_ptr().cast::(), slots.len() * SLOT_SIZE) } +} + +fn copy_bytes_to_slots(bytes: &[u8], count: usize) -> io::Result> { + let expected = count * SLOT_SIZE; + if bytes.len() < expected { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "checkpoint slot region too short", + )); + } + let mut slots = vec![Slot::EMPTY; count]; + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), slots.as_mut_ptr().cast::(), expected); + } + Ok(slots) +} + +fn serialize_shard_positions(positions: &[(DataFileId, HintOffset)]) -> Vec { + positions + .iter() + .flat_map(|(fid, off)| { + let mut buf = [0u8; SHARD_POSITION_SIZE]; + buf[0..4].copy_from_slice(&fid.raw().to_le_bytes()); + buf[4..12].copy_from_slice(&off.raw().to_le_bytes()); + buf + }) + .collect() +} + +pub fn write_checkpoint( + table: &HashTable, + path: &Path, + epoch: CommitEpoch, + positions: &CheckpointPositions, +) -> io::Result<()> { + use std::io::Write; + + let tmp_path = path.with_extension("tqc.tmp"); + + let shard_count = u16::try_from(positions.0.len()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "shard count exceeds u16::MAX"))?; + + let (cursor_file_id, cursor_offset) = table + .write_cursor() + .map(|c| (c.file_id.raw(), c.offset.raw())) + .unwrap_or((0, 0)); + + let header_bytes = serialize_header( + table.capacity() as u64, + table.len() as u64, + cursor_file_id, + cursor_offset, + epoch.raw(), + shard_count, + ); + + let slot_bytes = slots_as_bytes(&table.slots); + let shard_pos_bytes = serialize_shard_positions(&positions.0); + + let mut hasher = xxhash_rust::xxh3::Xxh3::new(); + hasher.update(slot_bytes); + hasher.update(&shard_pos_bytes); + let data_checksum = hasher.digest(); + + let mut file = std::fs::File::create(&tmp_path)?; + file.write_all(&header_bytes)?; + file.write_all(slot_bytes)?; + file.write_all(&shard_pos_bytes)?; + file.write_all(&data_checksum.to_le_bytes())?; + file.write_all(&TRAILER_MAGIC.to_le_bytes())?; + file.sync_all()?; + + std::fs::rename(&tmp_path, path)?; + + path.parent() + .map(|dir| std::fs::File::open(dir).and_then(|d| d.sync_all())) + .transpose()?; + + Ok(()) +} + +fn parse_checkpoint_header(data: &[u8]) -> io::Result<(usize, usize, u32, u64, u64, u16)> { + if data.len() < CHECKPOINT_HEADER_SIZE + 16 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "checkpoint file too small", + )); + } + + let hdr: &[u8; CHECKPOINT_HEADER_SIZE] = data[..CHECKPOINT_HEADER_SIZE].try_into().unwrap(); + + let magic: [u8; 8] = hdr[H_MAGIC..H_MAGIC + 8].try_into().unwrap(); + if magic != CHECKPOINT_MAGIC { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "checkpoint magic mismatch", + )); + } + + let version = u32::from_le_bytes(hdr[H_VERSION..H_VERSION + 4].try_into().unwrap()); + if version != CHECKPOINT_VERSION_V1 && version != CHECKPOINT_VERSION_V2 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("checkpoint version {version} unsupported"), + )); + } + + let stored_checksum = u64::from_le_bytes( + hdr[H_HEADER_CHECKSUM..H_HEADER_CHECKSUM + 8] + .try_into() + .unwrap(), + ); + if stored_checksum != header_checksum(hdr) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "checkpoint header checksum mismatch", + )); + } + + let slot_count = + u64::from_le_bytes(hdr[H_SLOT_COUNT..H_SLOT_COUNT + 8].try_into().unwrap()) as usize; + if slot_count == 0 || !slot_count.is_power_of_two() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("checkpoint slot_count {slot_count} is not a positive power of two"), + )); + } + if slot_count > MAX_SLOTS { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("checkpoint slot_count {slot_count} exceeds MAX_SLOTS {MAX_SLOTS}"), + )); + } + let entry_count = + u64::from_le_bytes(hdr[H_ENTRY_COUNT..H_ENTRY_COUNT + 8].try_into().unwrap()) as usize; + if entry_count > slot_count { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("checkpoint entry_count {entry_count} exceeds slot_count {slot_count}"), + )); + } + let cursor_file_id = u32::from_le_bytes( + hdr[H_CURSOR_FILE_ID..H_CURSOR_FILE_ID + 4] + .try_into() + .unwrap(), + ); + let cursor_offset = u64::from_le_bytes( + hdr[H_CURSOR_OFFSET..H_CURSOR_OFFSET + 8] + .try_into() + .unwrap(), + ); + let checkpoint_epoch = u64::from_le_bytes( + hdr[H_CHECKPOINT_EPOCH..H_CHECKPOINT_EPOCH + 8] + .try_into() + .unwrap(), + ); + + let shard_count = match version { + CHECKPOINT_VERSION_V2 => { + u16::from_le_bytes(hdr[H_SHARD_COUNT..H_SHARD_COUNT + 2].try_into().unwrap()) + } + _ => 0, + }; + + Ok(( + slot_count, + entry_count, + cursor_file_id, + cursor_offset, + checkpoint_epoch, + shard_count, + )) +} + +fn deserialize_shard_positions(data: &[u8], count: usize) -> Vec<(DataFileId, HintOffset)> { + (0..count) + .map(|i| { + let base = i * SHARD_POSITION_SIZE; + let fid = u32::from_le_bytes(data[base..base + 4].try_into().unwrap()); + let off = u64::from_le_bytes(data[base + 4..base + 12].try_into().unwrap()); + (DataFileId::new(fid), HintOffset::new(off)) + }) + .collect() +} + +pub fn read_checkpoint(path: &Path) -> io::Result<(HashTable, CommitEpoch, CheckpointPositions)> { + let data = std::fs::read(path)?; + + let (slot_count, entry_count, cursor_file_id, cursor_offset, checkpoint_epoch, shard_count) = + parse_checkpoint_header(&data)?; + + let hdr: &[u8; CHECKPOINT_HEADER_SIZE] = data[..CHECKPOINT_HEADER_SIZE].try_into().unwrap(); + let version = u32::from_le_bytes(hdr[H_VERSION..H_VERSION + 4].try_into().unwrap()); + + let slot_region_size = slot_count * SLOT_SIZE; + let shard_pos_size = shard_count as usize * SHARD_POSITION_SIZE; + let expected_total = CHECKPOINT_HEADER_SIZE + slot_region_size + shard_pos_size + 16; + if data.len() < expected_total { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "checkpoint file truncated", + )); + } + + let slot_region = &data[CHECKPOINT_HEADER_SIZE..CHECKPOINT_HEADER_SIZE + slot_region_size]; + let shard_pos_start = CHECKPOINT_HEADER_SIZE + slot_region_size; + let shard_pos_region = &data[shard_pos_start..shard_pos_start + shard_pos_size]; + + let data_checksum = match version { + CHECKPOINT_VERSION_V2 => { + let mut hasher = xxhash_rust::xxh3::Xxh3::new(); + hasher.update(slot_region); + hasher.update(shard_pos_region); + hasher.digest() + } + _ => xxhash_rust::xxh3::xxh3_64(slot_region), + }; + + let trailer_start = shard_pos_start + shard_pos_size; + let stored_data_checksum = + u64::from_le_bytes(data[trailer_start..trailer_start + 8].try_into().unwrap()); + let stored_trailer_magic = u64::from_le_bytes( + data[trailer_start + 8..trailer_start + 16] + .try_into() + .unwrap(), + ); + + if stored_data_checksum != data_checksum { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "checkpoint data checksum mismatch", + )); + } + if stored_trailer_magic != TRAILER_MAGIC { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "checkpoint trailer magic mismatch", + )); + } + + let slots = copy_bytes_to_slots(slot_region, slot_count)?; + let shift = 64 - slot_count.trailing_zeros(); + + let actual_count = slots.iter().filter(|s| is_occupied(&s.cid)).count(); + if actual_count != entry_count { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "checkpoint entry_count mismatch: header says {entry_count}, actual {actual_count}" + ), + )); + } + + let cursor = match (cursor_file_id, cursor_offset) { + (0, 0) => None, + (fid, off) => Some(WriteCursor { + file_id: DataFileId::new(fid), + offset: BlockOffset::new(off), + }), + }; + + let table = HashTable { + slots, + capacity: slot_count, + count: entry_count, + shift, + write_cursor: cursor, + }; + + let epoch = CommitEpoch::new(checkpoint_epoch); + + let positions = match version { + CHECKPOINT_VERSION_V2 if shard_count > 0 => CheckpointPositions( + deserialize_shard_positions(shard_pos_region, shard_count as usize), + ), + _ => { + let hint_file_id = + u32::from_le_bytes(hdr[H_HINT_FILE_ID..H_HINT_FILE_ID + 4].try_into().unwrap()); + let hint_offset = + u64::from_le_bytes(hdr[H_HINT_OFFSET..H_HINT_OFFSET + 8].try_into().unwrap()); + CheckpointPositions::single(DataFileId::new(hint_file_id), HintOffset::new(hint_offset)) + } + }; + + Ok((table, epoch, positions)) +} + +pub fn load_best_checkpoint( + index_dir: &Path, +) -> Option<(HashTable, CommitEpoch, CheckpointPositions)> { + let path_a = index_dir.join("checkpoint_a.tqc"); + let path_b = index_dir.join("checkpoint_b.tqc"); + + let result_a = read_checkpoint(&path_a).ok(); + let result_b = read_checkpoint(&path_b).ok(); + + match (result_a, result_b) { + (Some(a), Some(b)) => match a.1.raw() >= b.1.raw() { + true => Some(a), + false => Some(b), + }, + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + } +} + +fn read_checkpoint_epoch(path: &Path) -> Option { + let mut file = std::fs::File::open(path).ok()?; + let mut buf = [0u8; CHECKPOINT_HEADER_SIZE]; + std::io::Read::read_exact(&mut file, &mut buf).ok()?; + + let magic: [u8; 8] = buf[H_MAGIC..H_MAGIC + 8].try_into().ok()?; + if magic != CHECKPOINT_MAGIC { + return None; + } + + let version = u32::from_le_bytes(buf[H_VERSION..H_VERSION + 4].try_into().ok()?); + if version != CHECKPOINT_VERSION_V1 && version != CHECKPOINT_VERSION_V2 { + return None; + } + + let stored = u64::from_le_bytes( + buf[H_HEADER_CHECKSUM..H_HEADER_CHECKSUM + 8] + .try_into() + .ok()?, + ); + if stored != header_checksum(&buf) { + return None; + } + + Some(u64::from_le_bytes( + buf[H_CHECKPOINT_EPOCH..H_CHECKPOINT_EPOCH + 8] + .try_into() + .ok()?, + )) +} + +pub fn write_checkpoint_ab( + table: &HashTable, + index_dir: &Path, + epoch: CommitEpoch, + positions: &CheckpointPositions, +) -> io::Result<()> { + let path_a = index_dir.join("checkpoint_a.tqc"); + let path_b = index_dir.join("checkpoint_b.tqc"); + + let epoch_a = read_checkpoint_epoch(&path_a); + let epoch_b = read_checkpoint_epoch(&path_b); + + let target_path = match (epoch_a, epoch_b) { + (Some(a), Some(b)) if a >= b => path_b, + (Some(_), Some(_)) => path_a, + (Some(_), None) => path_b, + (None, _) => path_a, + }; + + write_checkpoint(table, &target_path, epoch, positions) +} + +#[derive(Debug)] +pub enum BlockIndexError { + MissingEntry, + CapacityExhausted, +} + +impl std::fmt::Display for BlockIndexError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingEntry => write!(f, "entry not found"), + Self::CapacityExhausted => write!(f, "hash table capacity exhausted"), + } + } +} + +impl std::error::Error for BlockIndexError {} + +pub struct BlockIndex { + table: RwLock, + index_dir: PathBuf, + checkpoint_lock: parking_lot::Mutex<()>, + loaded_checkpoint_positions: Option, + loaded_checkpoint_epoch: Option, +} + +impl BlockIndex { + pub fn new(table: HashTable, index_dir: PathBuf) -> Self { + Self { + table: RwLock::new(table), + index_dir, + checkpoint_lock: parking_lot::Mutex::new(()), + loaded_checkpoint_positions: None, + loaded_checkpoint_epoch: None, + } + } + + pub fn open(index_dir: &Path) -> io::Result { + std::fs::create_dir_all(index_dir)?; + let (table, checkpoint_positions, checkpoint_epoch) = match load_best_checkpoint(index_dir) + { + Some((table, epoch, positions)) => { + tracing::info!( + blocks = table.len(), + epoch = epoch.raw(), + shard_positions = positions.0.len(), + "loaded block index from checkpoint" + ); + (table, Some(positions), Some(epoch)) + } + None => { + tracing::info!("no valid checkpoint found, starting with empty index"); + (HashTable::with_capacity(64), None, None) + } + }; + Ok(Self { + table: RwLock::new(table), + index_dir: index_dir.to_path_buf(), + checkpoint_lock: parking_lot::Mutex::new(()), + loaded_checkpoint_positions: checkpoint_positions, + loaded_checkpoint_epoch: checkpoint_epoch, + }) + } + + pub fn loaded_checkpoint_positions(&self) -> Option<&CheckpointPositions> { + self.loaded_checkpoint_positions.as_ref() + } + + pub fn loaded_checkpoint_epoch(&self) -> Option { + self.loaded_checkpoint_epoch + } + + pub fn get(&self, cid: &[u8; CID_SIZE]) -> Option { + self.table.read().get(cid).map(|s| s.to_index_entry()) + } + + pub fn has(&self, cid: &[u8; CID_SIZE]) -> bool { + self.table.read().contains(cid) + } + + pub fn batch_put( + &self, + entries: &[([u8; CID_SIZE], BlockLocation)], + decrements: &[[u8; CID_SIZE]], + cursor: WriteCursor, + epoch: CommitEpoch, + now: WallClockMs, + ) -> Result<(), BlockIndexError> { + let mut table = self.table.write(); + + entries.iter().try_for_each(|(cid, location)| { + table + .insert_or_increment(cid, *location) + .map(|_| ()) + .map_err(|_| BlockIndexError::CapacityExhausted) + })?; + + decrements.iter().for_each(|cid| { + if table.decrement(cid, epoch, now).is_none() { + tracing::warn!( + ?cid, + "decrement on missing entry during batch_put, skipping" + ); + } + }); + + table.set_write_cursor(cursor); + Ok(()) + } + + pub fn batch_put_buffered( + &self, + entries: &[([u8; CID_SIZE], BlockLocation)], + cursor: WriteCursor, + ) -> Result<(), BlockIndexError> { + let mut table = self.table.write(); + entries.iter().try_for_each(|(cid, location)| { + table + .insert_or_increment(cid, *location) + .map(|_| ()) + .map_err(|_| BlockIndexError::CapacityExhausted) + })?; + table.set_write_cursor(cursor); + Ok(()) + } + + pub fn batch_insert_buffered( + &self, + entries: &[([u8; CID_SIZE], BlockLocation)], + ) -> Result<(), BlockIndexError> { + if entries.is_empty() { + return Ok(()); + } + let mut table = self.table.write(); + entries.iter().try_for_each(|(cid, location)| { + table + .insert_or_increment(cid, *location) + .map(|_| ()) + .map_err(|_| BlockIndexError::CapacityExhausted) + })?; + Ok(()) + } + + pub fn batch_put_if_absent( + &self, + entries: &[([u8; CID_SIZE], BlockLocation)], + cursor: WriteCursor, + ) -> Result { + let mut table = self.table.write(); + let inserted = entries.iter().try_fold(0u64, |acc, (cid, location)| { + table + .insert_if_absent(cid, *location) + .map(|was_new| acc.saturating_add(was_new as u64)) + .map_err(|_| BlockIndexError::CapacityExhausted) + })?; + table.set_write_cursor(cursor); + Ok(inserted) + } + + pub fn batch_relocate( + &self, + relocations: &[(CidBytes, BlockLocation)], + ) -> Result<(), BlockIndexError> { + if relocations.is_empty() { + return Ok(()); + } + let mut table = self.table.write(); + relocations.iter().try_for_each(|(cid, location)| { + table + .relocate(cid, *location) + .map(|_| ()) + .map_err(|_| BlockIndexError::CapacityExhausted) + }) + } + + pub fn batch_remove(&self, cids: &[CidBytes]) { + if cids.is_empty() { + return; + } + let mut table = self.table.write(); + cids.iter().for_each(|cid| { + let _ = table.remove(cid); + }); + } + + pub fn batch_decrement( + &self, + decrements: &[[u8; CID_SIZE]], + epoch: CommitEpoch, + now: WallClockMs, + ) -> Result<(), BlockIndexError> { + if decrements.is_empty() { + return Ok(()); + } + let mut table = self.table.write(); + decrements.iter().for_each(|cid| { + if table.decrement(cid, epoch, now).is_none() { + tracing::warn!(?cid, "deferred decrement on missing entry, skipping"); + } + }); + Ok(()) + } + + pub fn decrement_refcount( + &self, + cid: &[u8; CID_SIZE], + epoch: CommitEpoch, + now: WallClockMs, + ) -> Result { + self.table + .write() + .decrement(cid, epoch, now) + .ok_or(BlockIndexError::MissingEntry) + } + + pub fn collect_dead_blocks( + &self, + current_epoch: CommitEpoch, + now: WallClockMs, + grace_period_ms: u64, + ) -> CollectionResult { + self.table + .read() + .collect_dead_blocks(current_epoch, now, grace_period_ms) + } + + pub fn is_gc_eligible( + &self, + cid: &[u8; CID_SIZE], + current_epoch: CommitEpoch, + now: WallClockMs, + grace_period_ms: u64, + ) -> bool { + self.table + .read() + .is_gc_eligible(cid, current_epoch, now, grace_period_ms) + } + + pub fn apply_compaction( + &self, + relocations: &[(CidBytes, BlockLocation)], + removals: &[CidBytes], + ) { + self.table.write().apply_compaction(relocations, removals); + } + + pub fn cleanup_stale_gc_meta(&self) -> u64 { + self.table.write().cleanup_stale_gc() + } + + pub fn liveness_info(&self, file_id: DataFileId) -> LivenessInfo { + self.table.read().liveness_info(file_id) + } + + pub fn liveness_by_file( + &self, + current_epoch: CommitEpoch, + now: WallClockMs, + grace_period_ms: u64, + ) -> HashMap { + self.table + .read() + .liveness_by_file(current_epoch, now, grace_period_ms) + } + + pub fn find_leaked_refcounts( + &self, + is_reachable: impl Fn(&CidBytes) -> bool, + ) -> (Vec<(CidBytes, RefCount)>, u64) { + self.table.read().find_leaked_refcounts(is_reachable) + } + + pub fn repair_leaked_refcounts( + &self, + leaked_cids: &[(CidBytes, RefCount)], + epoch: CommitEpoch, + now: WallClockMs, + ) -> u64 { + let mut table = self.table.write(); + leaked_cids + .iter() + .fold(0u64, |acc, (cid, expected_rc)| match table.get_mut(cid) { + Some(slot) if slot.refcount == *expected_rc => { + slot.refcount = RefCount::new(0); + slot.gc_since_ms = now; + slot.gc_epoch = epoch; + acc.saturating_add(1) + } + _ => acc, + }) + } + + pub fn read_write_cursor(&self) -> Option { + self.table.read().write_cursor() + } + + pub fn set_write_cursor(&self, cursor: WriteCursor) -> Result<(), BlockIndexError> { + self.table.write().set_write_cursor(cursor); + Ok(()) + } + + pub fn approximate_block_count(&self) -> u64 { + self.table.read().approximate_count() + } + + pub fn write_checkpoint( + &self, + epoch: CommitEpoch, + positions: &CheckpointPositions, + ) -> io::Result<()> { + let _guard = self.checkpoint_lock.lock(); + let table = self.table.read(); + write_checkpoint_ab(&table, &self.index_dir, epoch, positions) + } + + pub fn index_dir(&self) -> &Path { + &self.index_dir + } + + pub fn rebuild_from_hints( + &self, + io: &S, + data_dir: &Path, + ) -> Result<(), super::hint::RebuildError> { + use super::data_file::BLOCK_RECORD_OVERHEAD; + use super::hint::{ + HINT_FILE_EXTENSION, HintFileReader, ReadHintRecord, RebuildError, hint_file_path, + }; + use super::list_files_by_extension; + + let hint_files = list_files_by_extension(io, data_dir, HINT_FILE_EXTENSION)?; + if hint_files.is_empty() { + return Err(RebuildError::Io(io::Error::new( + io::ErrorKind::NotFound, + "no hint files found for rebuild", + ))); + } + + let mut table = self.table.write(); + let mut max_cursor: Option = None; + + hint_files.iter().try_for_each(|&fid| { + let path = hint_file_path(data_dir, fid); + let fd = io.open(&path, crate::io::OpenOptions::read_only_existing())?; + let mut reader = HintFileReader::open(io, fd)?; + + reader.try_for_each(|result| { + match result? { + ReadHintRecord::Put { + cid_bytes, + file_id, + offset, + length, + } => { + let loc = BlockLocation { + file_id, + offset, + length, + }; + table.insert_or_increment(&cid_bytes, loc).map_err(|_| { + io::Error::other("hash table capacity exhausted during rebuild") + })?; + + let end = offset.advance(BLOCK_RECORD_OVERHEAD as u64 + length.as_u64()); + let candidate = WriteCursor { + file_id, + offset: end, + }; + match &max_cursor { + Some(c) + if (candidate.file_id, candidate.offset) + > (c.file_id, c.offset) => + { + max_cursor = Some(candidate); + } + None => max_cursor = Some(candidate), + _ => {} + } + } + ReadHintRecord::Decrement { + cid_bytes, + epoch, + timestamp, + } => { + if table.decrement(&cid_bytes, epoch, timestamp).is_none() { + tracing::warn!("decrement for missing entry during rebuild, skipping"); + } + } + ReadHintRecord::Relocate { + cid_bytes, + file_id, + offset, + length, + } => { + let loc = BlockLocation { + file_id, + offset, + length, + }; + table.relocate(&cid_bytes, loc).map_err(|_| { + io::Error::other("hash table capacity exhausted during rebuild") + })?; + } + ReadHintRecord::Remove { cid_bytes } => { + let _ = table.remove(&cid_bytes); + } + ReadHintRecord::UnknownVersion { .. } + | ReadHintRecord::UnknownType { .. } + | ReadHintRecord::Corrupted + | ReadHintRecord::Truncated => {} + } + Ok::<_, io::Error>(()) + })?; + + let _ = io.close(fd); + Ok::<_, RebuildError>(()) + })?; + + if let Some(cursor) = max_cursor { + table.set_write_cursor(cursor); + } + + Ok(()) + } + + pub fn rebuild_from_data_files( + &self, + io: &S, + data_dir: &Path, + ) -> Result<(), super::hint::RebuildError> { + use super::hint::RebuildError; + use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; + + let data_files = + super::list_files_by_extension(io, data_dir, super::manager::DATA_FILE_EXTENSION)?; + + let file_results: Vec, RebuildError>> = data_files + .par_iter() + .map(|&file_id| { + let path = + data_dir.join(format!("{file_id}.{}", super::manager::DATA_FILE_EXTENSION,)); + let fd = io.open(&path, crate::io::OpenOptions::read_only_existing())?; + let reader = super::data_file::DataFileReader::open(io, fd)?; + + let entries: Result, RebuildError> = reader + .filter_map(|r| match r { + Ok(super::data_file::ReadBlockRecord::Valid { + offset, + cid_bytes, + data, + }) => { + let length = super::types::BlockLength::new( + u32::try_from(data.len()).expect("block size validated"), + ); + Some(Ok(( + cid_bytes, + super::types::BlockLocation { + file_id, + offset, + length, + }, + ))) + } + Ok(_) => None, + Err(e) => Some(Err(RebuildError::Io(e))), + }) + .collect(); + + let _ = io.close(fd); + entries + }) + .collect(); + + let mut table = self.table.write(); + let mut max_cursor: Option = None; + file_results.into_iter().try_for_each(|result| { + result?.into_iter().try_for_each(|(cid_bytes, location)| { + table + .insert_or_increment(&cid_bytes, location) + .map_err(|_| { + RebuildError::Io(io::Error::other( + "hash table capacity exhausted during rebuild", + )) + })?; + let end = location.offset.advance( + super::data_file::BLOCK_RECORD_OVERHEAD as u64 + location.length.as_u64(), + ); + let new_cursor = WriteCursor { + file_id: location.file_id, + offset: end, + }; + match &max_cursor { + Some(c) if (new_cursor.file_id, new_cursor.offset) > (c.file_id, c.offset) => { + max_cursor = Some(new_cursor); + } + None => { + max_cursor = Some(new_cursor); + } + _ => {} + } + Ok::<_, RebuildError>(()) + }) + })?; + + if let Some(cursor) = max_cursor { + table.set_write_cursor(cursor); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_cid(seed: u8) -> [u8; CID_SIZE] { + let mut cid = [0u8; CID_SIZE]; + cid[0] = 0x01; + cid[1] = 0x71; + cid[2] = 0x12; + cid[3] = 0x20; + cid[4] = seed; + cid + } + + fn test_loc(file: u32, offset: u64, length: u32) -> BlockLocation { + BlockLocation { + file_id: DataFileId::new(file), + offset: BlockOffset::new(offset), + length: BlockLength::new(length), + } + } + + #[test] + fn insert_and_get() { + let mut table = HashTable::with_capacity(64); + let cid = test_cid(1); + let loc = test_loc(0, 100, 256); + + table.insert_or_increment(&cid, loc).unwrap(); + + let slot = table.get(&cid).unwrap(); + assert_eq!(slot.refcount, RefCount::one()); + assert_eq!(slot.file_id, DataFileId::new(0)); + assert_eq!(slot.offset, BlockOffset::new(100)); + assert_eq!(slot.length, BlockLength::new(256)); + assert_eq!(table.len(), 1); + } + + #[test] + fn get_missing_returns_none() { + let table = HashTable::with_capacity(64); + assert!(table.get(&test_cid(42)).is_none()); + } + + #[test] + fn duplicate_insert_increments_refcount() { + let mut table = HashTable::with_capacity(64); + let cid = test_cid(1); + let loc = test_loc(0, 100, 256); + + table.insert_or_increment(&cid, loc).unwrap(); + table + .insert_or_increment(&cid, test_loc(1, 200, 512)) + .unwrap(); + + let slot = table.get(&cid).unwrap(); + assert_eq!(slot.refcount, RefCount::new(2)); + assert_eq!(slot.file_id, DataFileId::new(0)); + assert_eq!(slot.offset, BlockOffset::new(100)); + } + + #[test] + fn decrement_to_zero_sets_gc() { + let mut table = HashTable::with_capacity(64); + let cid = test_cid(1); + table.insert_or_increment(&cid, test_loc(0, 0, 10)).unwrap(); + + let rc = table + .decrement(&cid, CommitEpoch::new(5), WallClockMs::new(1_000_000)) + .unwrap(); + assert!(rc.is_zero()); + + let slot = table.get(&cid).unwrap(); + assert_eq!(slot.gc_since_ms, WallClockMs::new(1_000_000)); + assert_eq!(slot.gc_epoch, CommitEpoch::new(5)); + } + + #[test] + fn re_increment_clears_gc() { + let mut table = HashTable::with_capacity(64); + let cid = test_cid(1); + let loc = test_loc(0, 0, 10); + table.insert_or_increment(&cid, loc).unwrap(); + let _ = table.decrement(&cid, CommitEpoch::new(1), WallClockMs::new(1000)); + + assert!(table.get(&cid).unwrap().gc_since_ms > WallClockMs::new(0)); + + table.insert_or_increment(&cid, loc).unwrap(); + + let slot = table.get(&cid).unwrap(); + assert_eq!(slot.refcount, RefCount::one()); + assert_eq!(slot.gc_since_ms, WallClockMs::new(0)); + assert_eq!(slot.gc_epoch, CommitEpoch::new(0)); + } + + #[test] + fn decrement_missing_returns_none() { + let mut table = HashTable::with_capacity(64); + assert!( + table + .decrement(&test_cid(99), CommitEpoch::zero(), WallClockMs::new(0)) + .is_none() + ); + } + + #[test] + fn remove_entry() { + let mut table = HashTable::with_capacity(64); + let cid = test_cid(1); + table.insert_or_increment(&cid, test_loc(0, 0, 10)).unwrap(); + + assert!(table.remove(&cid)); + assert!(!table.contains(&cid)); + assert_eq!(table.len(), 0); + } + + #[test] + fn remove_missing_returns_false() { + let mut table = HashTable::with_capacity(64); + assert!(!table.remove(&test_cid(99))); + } + + #[test] + fn many_inserts_trigger_grow() { + let mut table = HashTable::with_capacity(64); + let initial_cap = table.capacity(); + + (0..50u8).for_each(|i| { + table + .insert_or_increment(&test_cid(i), test_loc(0, i as u64 * 100, 50)) + .unwrap(); + }); + + assert!(table.capacity() > initial_cap); + assert_eq!(table.len(), 50); + + (0..50u8).for_each(|i| { + assert!(table.contains(&test_cid(i)), "missing {i} after grow"); + }); + } + + #[test] + fn collect_dead_blocks_respects_grace() { + let mut table = HashTable::with_capacity(64); + let cid = test_cid(1); + table + .insert_or_increment(&cid, test_loc(0, 0, 100)) + .unwrap(); + let _ = table.decrement(&cid, CommitEpoch::new(1), WallClockMs::new(1_000)); + + let result = table.collect_dead_blocks(CommitEpoch::new(2), WallClockMs::new(1_500), 1_000); + assert!(result.candidates.is_empty()); + + let result = table.collect_dead_blocks(CommitEpoch::new(2), WallClockMs::new(2_001), 1_000); + assert_eq!(result.candidates.len(), 1); + } + + #[test] + fn collect_dead_blocks_respects_epoch() { + let mut table = HashTable::with_capacity(64); + let cid = test_cid(1); + table + .insert_or_increment(&cid, test_loc(0, 0, 100)) + .unwrap(); + let _ = table.decrement(&cid, CommitEpoch::new(3), WallClockMs::new(1_000)); + + let result = table.collect_dead_blocks(CommitEpoch::new(3), WallClockMs::new(999_999), 0); + assert!(result.candidates.is_empty()); + + let result = table.collect_dead_blocks(CommitEpoch::new(4), WallClockMs::new(999_999), 0); + assert_eq!(result.candidates.len(), 1); + } + + #[test] + fn apply_compaction_relocates_and_removes() { + let mut table = HashTable::with_capacity(64); + let cid_live = test_cid(1); + let cid_dead = test_cid(2); + table + .insert_or_increment(&cid_live, test_loc(0, 0, 50)) + .unwrap(); + table + .insert_or_increment(&cid_dead, test_loc(0, 100, 50)) + .unwrap(); + + let new_loc = test_loc(1, 0, 50); + table.apply_compaction(&[(cid_live, new_loc)], &[cid_dead]); + + let slot = table.get(&cid_live).unwrap(); + assert_eq!(slot.file_id, DataFileId::new(1)); + assert_eq!(slot.offset, BlockOffset::new(0)); + assert!(!table.contains(&cid_dead)); + } + + #[test] + fn write_cursor_round_trip() { + let mut table = HashTable::with_capacity(64); + assert!(table.write_cursor().is_none()); + + let cursor = WriteCursor { + file_id: DataFileId::new(3), + offset: BlockOffset::new(65536), + }; + table.set_write_cursor(cursor); + assert_eq!(table.write_cursor(), Some(cursor)); + } + + #[test] + fn liveness_info_by_file() { + let mut table = HashTable::with_capacity(64); + table + .insert_or_increment(&test_cid(1), test_loc(0, 0, 100)) + .unwrap(); + table + .insert_or_increment(&test_cid(2), test_loc(0, 200, 50)) + .unwrap(); + table + .insert_or_increment(&test_cid(3), test_loc(1, 0, 75)) + .unwrap(); + let _ = table.decrement(&test_cid(1), CommitEpoch::zero(), WallClockMs::new(1000)); + + let info = table.liveness_info(DataFileId::new(0)); + assert_eq!(info.total_blocks, 2); + assert_eq!(info.live_blocks, 1); + } + + #[test] + fn cleanup_stale_gc() { + let mut table = HashTable::with_capacity(64); + let cid = test_cid(1); + table.insert_or_increment(&cid, test_loc(0, 0, 10)).unwrap(); + let _ = table.decrement(&cid, CommitEpoch::new(1), WallClockMs::new(1000)); + table.insert_or_increment(&cid, test_loc(0, 0, 10)).unwrap(); + + let slot = table.get(&cid).unwrap(); + assert_eq!(slot.gc_since_ms, WallClockMs::new(0)); + + table + .slots + .iter_mut() + .filter(|s| s.cid == cid) + .for_each(|s| { + s.gc_since_ms = WallClockMs::new(999); + s.gc_epoch = CommitEpoch::new(1); + }); + + let cleaned = table.cleanup_stale_gc(); + assert_eq!(cleaned, 1); + assert_eq!(table.get(&cid).unwrap().gc_since_ms, WallClockMs::new(0)); + } + + #[test] + fn iter_skips_empty_and_removed() { + let mut table = HashTable::with_capacity(64); + table + .insert_or_increment(&test_cid(1), test_loc(0, 0, 10)) + .unwrap(); + table + .insert_or_increment(&test_cid(2), test_loc(0, 100, 10)) + .unwrap(); + table + .insert_or_increment(&test_cid(3), test_loc(0, 200, 10)) + .unwrap(); + let _ = table.remove(&test_cid(2)); + + let count = table.iter().count(); + assert_eq!(count, 2); + } + + #[test] + fn reinsert_after_remove() { + let mut table = HashTable::with_capacity(64); + let cid = test_cid(1); + table.insert_or_increment(&cid, test_loc(0, 0, 10)).unwrap(); + let _ = table.remove(&cid); + + table + .insert_or_increment(&cid, test_loc(1, 100, 20)) + .unwrap(); + assert_eq!(table.len(), 1); + + let slot = table.get(&cid).unwrap(); + assert_eq!(slot.file_id, DataFileId::new(1)); + assert_eq!(slot.offset, BlockOffset::new(100)); + } + + #[test] + fn stress_insert_remove_cycle() { + let mut table = HashTable::with_capacity(64); + + (0..200u8).for_each(|i| { + let mut cid = [0u8; CID_SIZE]; + cid[0] = 0x01; + cid[1] = 0x71; + cid[2] = 0x12; + cid[3] = 0x20; + cid[4] = i; + cid[5] = (i as u16 * 7 % 256) as u8; + table + .insert_or_increment(&cid, test_loc(0, i as u64 * 100, 50)) + .unwrap(); + }); + + assert_eq!(table.len(), 200); + + (0..100u8).for_each(|i| { + let mut cid = [0u8; CID_SIZE]; + cid[0] = 0x01; + cid[1] = 0x71; + cid[2] = 0x12; + cid[3] = 0x20; + cid[4] = i; + cid[5] = (i as u16 * 7 % 256) as u8; + let _ = table.remove(&cid); + }); + + assert_eq!(table.len(), 100); + + (100..200u8).for_each(|i| { + let mut cid = [0u8; CID_SIZE]; + cid[0] = 0x01; + cid[1] = 0x71; + cid[2] = 0x12; + cid[3] = 0x20; + cid[4] = i; + cid[5] = (i as u16 * 7 % 256) as u8; + assert!(table.contains(&cid), "entry {i} missing after remove cycle"); + }); + } + + #[test] + fn checkpoint_round_trip() { + let dir = tempfile::TempDir::new().unwrap(); + let mut table = HashTable::with_capacity(64); + + (0..10u8).for_each(|i| { + table + .insert_or_increment(&test_cid(i), test_loc(0, i as u64 * 100, 50)) + .unwrap(); + }); + let _ = table.decrement(&test_cid(0), CommitEpoch::new(3), WallClockMs::new(5000)); + table.set_write_cursor(WriteCursor { + file_id: DataFileId::new(2), + offset: BlockOffset::new(9999), + }); + + let path = dir.path().join("test.tqc"); + let epoch = CommitEpoch::new(42); + let positions = CheckpointPositions::single(DataFileId::new(5), HintOffset::new(12345)); + + write_checkpoint(&table, &path, epoch, &positions).unwrap(); + let (restored, restored_epoch, restored_pos) = read_checkpoint(&path).unwrap(); + + assert_eq!(restored.len(), 10); + assert_eq!(restored_epoch.raw(), 42); + assert_eq!(restored_pos.0.len(), 1); + assert_eq!(restored_pos.0[0].0.raw(), 5); + assert_eq!(restored_pos.0[0].1.raw(), 12345); + + (0..10u8).for_each(|i| { + let slot = restored.get(&test_cid(i)).unwrap(); + assert_eq!(slot.offset, BlockOffset::new(i as u64 * 100)); + }); + + let slot0 = restored.get(&test_cid(0)).unwrap(); + assert_eq!(slot0.refcount, RefCount::new(0)); + assert_eq!(slot0.gc_since_ms, WallClockMs::new(5000)); + assert_eq!(slot0.gc_epoch, CommitEpoch::new(3)); + + let cursor = restored.write_cursor().unwrap(); + assert_eq!(cursor.file_id, DataFileId::new(2)); + assert_eq!(cursor.offset, BlockOffset::new(9999)); + } + + #[test] + fn checkpoint_ab_alternates() { + let dir = tempfile::TempDir::new().unwrap(); + let pos = CheckpointPositions::single(DataFileId::new(0), HintOffset::new(0)); + + let mut table = HashTable::with_capacity(64); + table + .insert_or_increment(&test_cid(1), test_loc(0, 0, 10)) + .unwrap(); + write_checkpoint_ab(&table, dir.path(), CommitEpoch::new(1), &pos).unwrap(); + + table + .insert_or_increment(&test_cid(2), test_loc(0, 100, 10)) + .unwrap(); + write_checkpoint_ab(&table, dir.path(), CommitEpoch::new(2), &pos).unwrap(); + + let (best, epoch, _) = load_best_checkpoint(dir.path()).unwrap(); + assert_eq!(epoch.raw(), 2); + assert_eq!(best.len(), 2); + } + + #[test] + fn checkpoint_corrupt_falls_back() { + let dir = tempfile::TempDir::new().unwrap(); + let pos = CheckpointPositions::single(DataFileId::new(0), HintOffset::new(0)); + + let mut table = HashTable::with_capacity(64); + table + .insert_or_increment(&test_cid(1), test_loc(0, 0, 10)) + .unwrap(); + write_checkpoint_ab(&table, dir.path(), CommitEpoch::new(1), &pos).unwrap(); + + table + .insert_or_increment(&test_cid(2), test_loc(0, 100, 10)) + .unwrap(); + write_checkpoint_ab(&table, dir.path(), CommitEpoch::new(2), &pos).unwrap(); + + std::fs::write(dir.path().join("checkpoint_b.tqc"), b"corrupt").unwrap(); + + let (best, epoch, _) = load_best_checkpoint(dir.path()).unwrap(); + assert_eq!(epoch.raw(), 1); + assert_eq!(best.len(), 1); + } + + #[test] + fn both_checkpoints_corrupt_returns_none() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join("checkpoint_a.tqc"), b"corrupt").unwrap(); + std::fs::write(dir.path().join("checkpoint_b.tqc"), b"corrupt").unwrap(); + assert!(load_best_checkpoint(dir.path()).is_none()); + } + + #[test] + fn no_checkpoints_returns_none() { + let dir = tempfile::TempDir::new().unwrap(); + assert!(load_best_checkpoint(dir.path()).is_none()); + } +} diff --git a/crates/tranquil-store/src/blockstore/hint.rs b/crates/tranquil-store/src/blockstore/hint.rs index 6173904..4ec098a 100644 --- a/crates/tranquil-store/src/blockstore/hint.rs +++ b/crates/tranquil-store/src/blockstore/hint.rs @@ -1,27 +1,60 @@ +use std::collections::HashMap; use std::io; use std::path::{Path, PathBuf}; use crate::io::{FileId, OpenOptions, StorageIO}; -use super::data_file::{BLOCK_RECORD_OVERHEAD, CID_SIZE, DataFileReader}; -use super::key_index::{KeyIndex, KeyIndexError}; +use super::data_file::{BLOCK_RECORD_OVERHEAD, CID_SIZE}; use super::list_files_by_extension; -use super::manager::DATA_FILE_EXTENSION; use super::types::{ - BlockLength, BlockLocation, BlockOffset, DataFileId, HintOffset, MAX_BLOCK_SIZE, WriteCursor, + BlockLength, BlockLocation, BlockOffset, CidBytes, CommitEpoch, DataFileId, HintOffset, + MAX_BLOCK_SIZE, WallClockMs, WriteCursor, }; -pub const HINT_RECORD_SIZE: usize = CID_SIZE + 4 + 8 + 4 + 4; +pub const HINT_RECORD_SIZE: usize = 1 + 3 + CID_SIZE + 8 + 8 + 8; pub const HINT_FILE_EXTENSION: &str = "tqh"; -fn hint_checksum(buf: &[u8; CID_SIZE + 4 + 8 + 4]) -> u32 { - xxhash_rust::xxh3::xxh3_64(buf) as u32 +const _: () = assert!(HINT_RECORD_SIZE == 64); + +const HINT_PAYLOAD_SIZE: usize = HINT_RECORD_SIZE - 8; + +const RECORD_TYPE_PUT: u8 = 0x01; +const RECORD_TYPE_DECREMENT: u8 = 0x02; +const RECORD_TYPE_RELOCATE: u8 = 0x03; +const RECORD_TYPE_REMOVE: u8 = 0x04; + +const HINT_FORMAT_VERSION: u8 = 1; + +fn hint_checksum(payload: &[u8]) -> u64 { + xxhash_rust::xxh3::xxh3_64(payload) } pub fn hint_file_path(data_dir: &Path, file_id: DataFileId) -> PathBuf { data_dir.join(format!("{file_id}.{HINT_FILE_EXTENSION}")) } +const TYPE_OFFSET: usize = 0; +const VERSION_OFFSET: usize = 1; +const CID_OFFSET: usize = 4; +const FIELD_A_OFFSET: usize = CID_OFFSET + CID_SIZE; +const FIELD_B_OFFSET: usize = FIELD_A_OFFSET + 8; +const CHECKSUM_OFFSET: usize = FIELD_B_OFFSET + 8; + +fn write_hint_record( + io: &S, + fd: FileId, + write_offset: HintOffset, + record: &[u8; HINT_RECORD_SIZE], +) -> io::Result<()> { + assert!( + write_offset.raw().is_multiple_of(HINT_RECORD_SIZE as u64), + "hint write_offset {} not aligned to HINT_RECORD_SIZE {}", + write_offset.raw(), + HINT_RECORD_SIZE, + ); + io.write_all_at(fd, write_offset.raw(), record) +} + pub(crate) fn encode_hint_record( io: &S, fd: FileId, @@ -31,35 +64,110 @@ pub(crate) fn encode_hint_record( block_offset: BlockOffset, length: BlockLength, ) -> io::Result<()> { - debug_assert!( - write_offset.raw().is_multiple_of(HINT_RECORD_SIZE as u64), - "hint write_offset {} not aligned to HINT_RECORD_SIZE {}", - write_offset.raw(), - HINT_RECORD_SIZE, - ); - let mut record = [0u8; HINT_RECORD_SIZE]; - record[..CID_SIZE].copy_from_slice(cid_bytes); - record[CID_SIZE..CID_SIZE + 4].copy_from_slice(&file_id.raw().to_le_bytes()); - record[CID_SIZE + 4..CID_SIZE + 12].copy_from_slice(&block_offset.raw().to_le_bytes()); - record[CID_SIZE + 12..CID_SIZE + 16].copy_from_slice(&length.raw().to_le_bytes()); + record[TYPE_OFFSET] = RECORD_TYPE_PUT; + record[VERSION_OFFSET] = HINT_FORMAT_VERSION; + record[CID_OFFSET..CID_OFFSET + CID_SIZE].copy_from_slice(cid_bytes); + record[FIELD_A_OFFSET..FIELD_A_OFFSET + 4].copy_from_slice(&file_id.raw().to_le_bytes()); + record[FIELD_A_OFFSET + 4..FIELD_A_OFFSET + 8].copy_from_slice(&length.raw().to_le_bytes()); + record[FIELD_B_OFFSET..FIELD_B_OFFSET + 8].copy_from_slice(&block_offset.raw().to_le_bytes()); - let checksum = - hint_checksum(<&[u8; CID_SIZE + 4 + 8 + 4]>::try_from(&record[..CID_SIZE + 16]).unwrap()); - record[CID_SIZE + 16..].copy_from_slice(&checksum.to_le_bytes()); + let checksum = hint_checksum(&record[..HINT_PAYLOAD_SIZE]); + record[CHECKSUM_OFFSET..].copy_from_slice(&checksum.to_le_bytes()); - io.write_all_at(fd, write_offset.raw(), &record) + write_hint_record(io, fd, write_offset, &record) +} + +pub(crate) fn encode_relocate_record( + io: &S, + fd: FileId, + write_offset: HintOffset, + cid_bytes: &[u8; CID_SIZE], + file_id: DataFileId, + block_offset: BlockOffset, + length: BlockLength, +) -> io::Result<()> { + let mut record = [0u8; HINT_RECORD_SIZE]; + record[TYPE_OFFSET] = RECORD_TYPE_RELOCATE; + record[VERSION_OFFSET] = HINT_FORMAT_VERSION; + record[CID_OFFSET..CID_OFFSET + CID_SIZE].copy_from_slice(cid_bytes); + record[FIELD_A_OFFSET..FIELD_A_OFFSET + 4].copy_from_slice(&file_id.raw().to_le_bytes()); + record[FIELD_A_OFFSET + 4..FIELD_A_OFFSET + 8].copy_from_slice(&length.raw().to_le_bytes()); + record[FIELD_B_OFFSET..FIELD_B_OFFSET + 8].copy_from_slice(&block_offset.raw().to_le_bytes()); + + let checksum = hint_checksum(&record[..HINT_PAYLOAD_SIZE]); + record[CHECKSUM_OFFSET..].copy_from_slice(&checksum.to_le_bytes()); + + write_hint_record(io, fd, write_offset, &record) +} + +pub(crate) fn encode_remove_record( + io: &S, + fd: FileId, + write_offset: HintOffset, + cid_bytes: &[u8; CID_SIZE], +) -> io::Result<()> { + let mut record = [0u8; HINT_RECORD_SIZE]; + record[TYPE_OFFSET] = RECORD_TYPE_REMOVE; + record[VERSION_OFFSET] = HINT_FORMAT_VERSION; + record[CID_OFFSET..CID_OFFSET + CID_SIZE].copy_from_slice(cid_bytes); + + let checksum = hint_checksum(&record[..HINT_PAYLOAD_SIZE]); + record[CHECKSUM_OFFSET..].copy_from_slice(&checksum.to_le_bytes()); + + write_hint_record(io, fd, write_offset, &record) +} + +pub(crate) fn encode_decrement_record( + io: &S, + fd: FileId, + write_offset: HintOffset, + cid_bytes: &[u8; CID_SIZE], + epoch: CommitEpoch, + timestamp: WallClockMs, +) -> io::Result<()> { + let mut record = [0u8; HINT_RECORD_SIZE]; + record[TYPE_OFFSET] = RECORD_TYPE_DECREMENT; + record[VERSION_OFFSET] = HINT_FORMAT_VERSION; + record[CID_OFFSET..CID_OFFSET + CID_SIZE].copy_from_slice(cid_bytes); + record[FIELD_A_OFFSET..FIELD_A_OFFSET + 8].copy_from_slice(&epoch.raw().to_le_bytes()); + record[FIELD_B_OFFSET..FIELD_B_OFFSET + 8].copy_from_slice(×tamp.raw().to_le_bytes()); + + let checksum = hint_checksum(&record[..HINT_PAYLOAD_SIZE]); + record[CHECKSUM_OFFSET..].copy_from_slice(&checksum.to_le_bytes()); + + write_hint_record(io, fd, write_offset, &record) } #[must_use] #[derive(Debug)] pub enum ReadHintRecord { - Valid { + Put { cid_bytes: [u8; CID_SIZE], file_id: DataFileId, offset: BlockOffset, length: BlockLength, }, + Decrement { + cid_bytes: [u8; CID_SIZE], + epoch: CommitEpoch, + timestamp: WallClockMs, + }, + Relocate { + cid_bytes: [u8; CID_SIZE], + file_id: DataFileId, + offset: BlockOffset, + length: BlockLength, + }, + Remove { + cid_bytes: [u8; CID_SIZE], + }, + UnknownVersion { + version: u8, + }, + UnknownType { + record_type: u8, + }, Corrupted, Truncated, } @@ -86,34 +194,95 @@ pub fn decode_hint_record( let mut record = [0u8; HINT_RECORD_SIZE]; io.read_exact_at(fd, raw, &mut record)?; - let payload: &[u8; CID_SIZE + 4 + 8 + 4] = record[..CID_SIZE + 16].try_into().unwrap(); - let stored = u32::from_le_bytes(record[CID_SIZE + 16..].try_into().unwrap()); - let computed = hint_checksum(payload); + let stored = u64::from_le_bytes(record[CHECKSUM_OFFSET..].try_into().unwrap()); + let computed = hint_checksum(&record[..HINT_PAYLOAD_SIZE]); if stored != computed { return Ok(Some(ReadHintRecord::Corrupted)); } - let mut cid_bytes = [0u8; CID_SIZE]; - cid_bytes.copy_from_slice(&record[..CID_SIZE]); - - let file_id = DataFileId::new(u32::from_le_bytes( - record[CID_SIZE..CID_SIZE + 4].try_into().unwrap(), - )); - let block_offset = BlockOffset::new(u64::from_le_bytes( - record[CID_SIZE + 4..CID_SIZE + 12].try_into().unwrap(), - )); - let raw_length = u32::from_le_bytes(record[CID_SIZE + 12..CID_SIZE + 16].try_into().unwrap()); - if raw_length > MAX_BLOCK_SIZE { - return Ok(Some(ReadHintRecord::Corrupted)); + let version = record[VERSION_OFFSET]; + if version != HINT_FORMAT_VERSION { + return Ok(Some(ReadHintRecord::UnknownVersion { version })); } - let length = BlockLength::new(raw_length); - Ok(Some(ReadHintRecord::Valid { - cid_bytes, - file_id, - offset: block_offset, - length, - })) + let record_type = record[TYPE_OFFSET]; + + let mut cid_bytes = [0u8; CID_SIZE]; + cid_bytes.copy_from_slice(&record[CID_OFFSET..CID_OFFSET + CID_SIZE]); + + match record_type { + RECORD_TYPE_PUT => { + let file_id = DataFileId::new(u32::from_le_bytes( + record[FIELD_A_OFFSET..FIELD_A_OFFSET + 4] + .try_into() + .unwrap(), + )); + let raw_length = u32::from_le_bytes( + record[FIELD_A_OFFSET + 4..FIELD_A_OFFSET + 8] + .try_into() + .unwrap(), + ); + let block_offset = BlockOffset::new(u64::from_le_bytes( + record[FIELD_B_OFFSET..FIELD_B_OFFSET + 8] + .try_into() + .unwrap(), + )); + if raw_length > MAX_BLOCK_SIZE { + return Ok(Some(ReadHintRecord::Corrupted)); + } + Ok(Some(ReadHintRecord::Put { + cid_bytes, + file_id, + offset: block_offset, + length: BlockLength::new(raw_length), + })) + } + RECORD_TYPE_DECREMENT => { + let epoch = CommitEpoch::new(u64::from_le_bytes( + record[FIELD_A_OFFSET..FIELD_A_OFFSET + 8] + .try_into() + .unwrap(), + )); + let timestamp = WallClockMs::new(u64::from_le_bytes( + record[FIELD_B_OFFSET..FIELD_B_OFFSET + 8] + .try_into() + .unwrap(), + )); + Ok(Some(ReadHintRecord::Decrement { + cid_bytes, + epoch, + timestamp, + })) + } + RECORD_TYPE_RELOCATE => { + let file_id = DataFileId::new(u32::from_le_bytes( + record[FIELD_A_OFFSET..FIELD_A_OFFSET + 4] + .try_into() + .unwrap(), + )); + let raw_length = u32::from_le_bytes( + record[FIELD_A_OFFSET + 4..FIELD_A_OFFSET + 8] + .try_into() + .unwrap(), + ); + let block_offset = BlockOffset::new(u64::from_le_bytes( + record[FIELD_B_OFFSET..FIELD_B_OFFSET + 8] + .try_into() + .unwrap(), + )); + if raw_length > MAX_BLOCK_SIZE { + return Ok(Some(ReadHintRecord::Corrupted)); + } + Ok(Some(ReadHintRecord::Relocate { + cid_bytes, + file_id, + offset: block_offset, + length: BlockLength::new(raw_length), + })) + } + RECORD_TYPE_REMOVE => Ok(Some(ReadHintRecord::Remove { cid_bytes })), + other => Ok(Some(ReadHintRecord::UnknownType { record_type: other })), + } } pub struct HintFileWriter<'a, S: StorageIO> { @@ -155,6 +324,43 @@ impl<'a, S: StorageIO> HintFileWriter<'a, S> { Ok(()) } + pub fn append_decrement( + &mut self, + cid_bytes: &[u8; CID_SIZE], + epoch: CommitEpoch, + timestamp: WallClockMs, + ) -> io::Result<()> { + encode_decrement_record(self.io, self.fd, self.position, cid_bytes, epoch, timestamp)?; + self.position = self.position.advance(HINT_RECORD_SIZE as u64); + Ok(()) + } + + pub fn append_relocate( + &mut self, + cid_bytes: &[u8; CID_SIZE], + file_id: DataFileId, + offset: BlockOffset, + length: BlockLength, + ) -> io::Result<()> { + encode_relocate_record( + self.io, + self.fd, + self.position, + cid_bytes, + file_id, + offset, + length, + )?; + self.position = self.position.advance(HINT_RECORD_SIZE as u64); + Ok(()) + } + + pub fn append_remove(&mut self, cid_bytes: &[u8; CID_SIZE]) -> io::Result<()> { + encode_remove_record(self.io, self.fd, self.position, cid_bytes)?; + self.position = self.position.advance(HINT_RECORD_SIZE as u64); + Ok(()) + } + pub fn sync(&self) -> io::Result<()> { self.io.sync(self.fd) } @@ -181,6 +387,22 @@ impl<'a, S: StorageIO> HintFileReader<'a, S> { file_size, }) } + + pub fn resume(io: &'a S, fd: FileId, position: HintOffset) -> io::Result { + assert!( + position.raw().is_multiple_of(HINT_RECORD_SIZE as u64), + "hint resume position {} not aligned to HINT_RECORD_SIZE {}", + position.raw(), + HINT_RECORD_SIZE, + ); + let file_size = io.file_size(fd)?; + Ok(Self { + io, + fd, + position, + file_size, + }) + } } impl Iterator for HintFileReader<'_, S> { @@ -195,10 +417,16 @@ impl Iterator for HintFileReader<'_, S> { Ok(None) => None, Ok(Some(record)) => { match &record { - ReadHintRecord::Valid { .. } => { + ReadHintRecord::Put { .. } + | ReadHintRecord::Decrement { .. } + | ReadHintRecord::Relocate { .. } + | ReadHintRecord::Remove { .. } + | ReadHintRecord::UnknownType { .. } => { self.position = self.position.advance(HINT_RECORD_SIZE as u64); } - ReadHintRecord::Corrupted | ReadHintRecord::Truncated => { + ReadHintRecord::UnknownVersion { .. } + | ReadHintRecord::Corrupted + | ReadHintRecord::Truncated => { self.position = HintOffset::new(self.file_size); } } @@ -211,14 +439,14 @@ impl Iterator for HintFileReader<'_, S> { #[derive(Debug)] pub enum RebuildError { Io(io::Error), - Index(KeyIndexError), + BlockIndex(super::hash_index::BlockIndexError), } impl std::fmt::Display for RebuildError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Io(e) => write!(f, "io: {e}"), - Self::Index(e) => write!(f, "index: {e}"), + Self::BlockIndex(e) => write!(f, "block index: {e}"), } } } @@ -227,7 +455,7 @@ impl std::error::Error for RebuildError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(e) => Some(e), - Self::Index(e) => Some(e), + Self::BlockIndex(e) => Some(e), } } } @@ -238,14 +466,12 @@ impl From for RebuildError { } } -impl From for RebuildError { - fn from(e: KeyIndexError) -> Self { - Self::Index(e) +impl From for RebuildError { + fn from(e: super::hash_index::BlockIndexError) -> Self { + Self::BlockIndex(e) } } -const REBUILD_BATCH_SIZE: usize = 10_000; - struct RebuildState { entries: Vec<([u8; CID_SIZE], BlockLocation)>, cursor_file: DataFileId, @@ -272,129 +498,329 @@ impl RebuildState { self.entries.push((cid_bytes, location)); } - fn flush_if_full(&mut self, index: &KeyIndex) -> Result<(), RebuildError> { - if self.entries.len() >= REBUILD_BATCH_SIZE { - self.flush(index)?; + fn cursor(&self) -> WriteCursor { + WriteCursor { + file_id: self.cursor_file, + offset: self.cursor_offset, } - Ok(()) - } - - fn flush(&mut self, index: &KeyIndex) -> Result<(), RebuildError> { - if self.entries.is_empty() { - return Ok(()); - } - index.batch_put( - &self.entries, - &[], - WriteCursor { - file_id: self.cursor_file, - offset: self.cursor_offset, - }, - )?; - self.entries.clear(); - Ok(()) } } -pub fn rebuild_index_from_hints( +fn scan_single_hint_file( io: &S, data_dir: &Path, - index: &KeyIndex, -) -> Result<(), RebuildError> { - let hint_files = list_files_by_extension(io, data_dir, HINT_FILE_EXTENSION)?; - let mut state = RebuildState::new(); + hf_id: DataFileId, +) -> Result, RebuildError> { + let path = hint_file_path(data_dir, hf_id); + let fd = io.open(&path, OpenOptions::read_only_existing())?; + let reader = HintFileReader::open(io, fd)?; - hint_files.iter().try_for_each(|&hf_id| { - let path = hint_file_path(data_dir, hf_id); - let fd = io.open(&path, OpenOptions::read_only_existing())?; - let reader = HintFileReader::open(io, fd)?; - - let result: Result<(), RebuildError> = reader - .filter_map(|r| match r { - Ok(ReadHintRecord::Valid { - cid_bytes, + let entries: Result, RebuildError> = reader + .filter_map(|r| match r { + Ok(ReadHintRecord::Put { + cid_bytes, + file_id, + offset, + length, + }) => Some(Ok(( + cid_bytes, + BlockLocation { file_id, offset, length, - }) => Some(Ok((cid_bytes, file_id, offset, length))), - Ok(_) => None, - Err(e) => Some(Err(RebuildError::Io(e))), - }) - .try_for_each(|r| { - let (cid_bytes, file_id, offset, length) = r?; - state.push( - cid_bytes, - BlockLocation { - file_id, - offset, - length, - }, - ); - state.flush_if_full(index) - }); + }, + ))), + Ok( + ReadHintRecord::Decrement { .. } + | ReadHintRecord::Relocate { .. } + | ReadHintRecord::Remove { .. } + | ReadHintRecord::UnknownVersion { .. } + | ReadHintRecord::UnknownType { .. } + | ReadHintRecord::Corrupted + | ReadHintRecord::Truncated, + ) => None, + Err(e) => Some(Err(RebuildError::Io(e))), + }) + .collect(); - let _ = io.close(fd); - result - })?; - - state.flush(index) + let _ = io.close(fd); + entries } -pub fn rebuild_index_from_data_files( +const REPLAY_BATCH_SIZE: usize = 10_000; + +pub fn replay_hints_into_block_index( io: &S, data_dir: &Path, - index: &KeyIndex, -) -> Result<(), RebuildError> { - let data_files = list_files_by_extension(io, data_dir, DATA_FILE_EXTENSION)?; - let mut state = RebuildState::new(); + index: &super::hash_index::BlockIndex, + from: Option<&super::hash_index::CheckpointPositions>, +) -> Result<(u64, HashMap), RebuildError> { + let hint_files = list_files_by_extension(io, data_dir, HINT_FILE_EXTENSION)?; + if hint_files.is_empty() { + return Ok((0, HashMap::new())); + } - data_files.iter().try_for_each(|&file_id| { - let path = data_dir.join(format!("{file_id}.{DATA_FILE_EXTENSION}")); - let fd = io.open(&path, OpenOptions::read_only_existing())?; - let reader = DataFileReader::open(io, fd)?; + let checkpointed_files: HashMap = from + .map(|cp| cp.0.iter().copied().collect()) + .unwrap_or_default(); - let result: Result<(), RebuildError> = reader - .filter_map(|r| match r { - Ok(super::data_file::ReadBlockRecord::Valid { - offset, - cid_bytes, - data, - }) => { - let length = BlockLength::new( - u32::try_from(data.len()).expect("block size validated by reader"), - ); - Some(Ok((cid_bytes, offset, length))) - } - Ok(_) => None, - Err(e) => Some(Err(RebuildError::Io(e))), - }) - .try_for_each(|r| { - let (cid_bytes, offset, length) = r?; - state.push( - cid_bytes, - BlockLocation { + let max_checkpointed_fid = checkpointed_files + .keys() + .max() + .copied() + .unwrap_or(DataFileId::new(0)); + + let mut max_cursor: Option = None; + let mut file_cursors: HashMap = HashMap::new(); + let mut replayed: u64 = 0; + let mut put_buffer: Vec<([u8; CID_SIZE], BlockLocation)> = + Vec::with_capacity(REPLAY_BATCH_SIZE); + let mut relocate_buffer: Vec<([u8; CID_SIZE], BlockLocation)> = + Vec::with_capacity(REPLAY_BATCH_SIZE); + let mut remove_buffer: Vec<[u8; CID_SIZE]> = Vec::with_capacity(REPLAY_BATCH_SIZE); + + hint_files + .iter() + .filter_map(|&fid| match checkpointed_files.get(&fid) { + Some(&offset) => Some((fid, offset)), + None if fid > max_checkpointed_fid => Some((fid, HintOffset::new(0))), + None => None, + }) + .try_for_each(|(fid, start_pos)| { + let path = hint_file_path(data_dir, fid); + let fd = match io.open(&path, OpenOptions::read_only_existing()) { + Ok(fd) => fd, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(RebuildError::Io(e)), + }; + + let mut reader = HintFileReader::resume(io, fd, start_pos)?; + + reader.try_for_each(|record_result| { + match record_result? { + ReadHintRecord::Put { + cid_bytes, file_id, offset, length, - }, - ); - state.flush_if_full(index) - }); + } => { + let loc = BlockLocation { + file_id, + offset, + length, + }; + put_buffer.push((cid_bytes, loc)); - let _ = io.close(fd); - result + let record_end = + offset.advance(BLOCK_RECORD_OVERHEAD as u64 + length.as_u64()); + let candidate = WriteCursor { + file_id, + offset: record_end, + }; + max_cursor = Some(match max_cursor { + Some(c) => { + std::cmp::max_by_key(c, candidate, |w| (w.file_id, w.offset)) + } + None => candidate, + }); + file_cursors + .entry(file_id) + .and_modify(|existing| { + if record_end > *existing { + *existing = record_end; + } + }) + .or_insert(record_end); + + replayed = replayed.saturating_add(1); + if put_buffer.len() >= REPLAY_BATCH_SIZE { + index.batch_insert_buffered(&put_buffer)?; + put_buffer.clear(); + } + } + ReadHintRecord::Decrement { + cid_bytes, + epoch, + timestamp, + } => { + if !put_buffer.is_empty() { + index.batch_insert_buffered(&put_buffer)?; + put_buffer.clear(); + } + if !relocate_buffer.is_empty() { + index.batch_relocate(&relocate_buffer)?; + relocate_buffer.clear(); + } + if !remove_buffer.is_empty() { + index.batch_remove(&remove_buffer); + remove_buffer.clear(); + } + index.batch_decrement(&[cid_bytes], epoch, timestamp)?; + replayed = replayed.saturating_add(1); + } + ReadHintRecord::Relocate { + cid_bytes, + file_id, + offset, + length, + } => { + let loc = BlockLocation { + file_id, + offset, + length, + }; + relocate_buffer.push((cid_bytes, loc)); + + let record_end = + offset.advance(BLOCK_RECORD_OVERHEAD as u64 + length.as_u64()); + file_cursors + .entry(file_id) + .and_modify(|existing| { + if record_end > *existing { + *existing = record_end; + } + }) + .or_insert(record_end); + + replayed = replayed.saturating_add(1); + if relocate_buffer.len() >= REPLAY_BATCH_SIZE { + if !put_buffer.is_empty() { + index.batch_insert_buffered(&put_buffer)?; + put_buffer.clear(); + } + index.batch_relocate(&relocate_buffer)?; + relocate_buffer.clear(); + } + } + ReadHintRecord::Remove { cid_bytes } => { + remove_buffer.push(cid_bytes); + replayed = replayed.saturating_add(1); + if remove_buffer.len() >= REPLAY_BATCH_SIZE { + if !put_buffer.is_empty() { + index.batch_insert_buffered(&put_buffer)?; + put_buffer.clear(); + } + if !relocate_buffer.is_empty() { + index.batch_relocate(&relocate_buffer)?; + relocate_buffer.clear(); + } + index.batch_remove(&remove_buffer); + remove_buffer.clear(); + } + } + ReadHintRecord::Corrupted => { + tracing::warn!( + file_id = %fid, + "corrupted hint record during replay, skipping" + ); + } + ReadHintRecord::UnknownVersion { .. } + | ReadHintRecord::UnknownType { .. } + | ReadHintRecord::Truncated => {} + } + Ok::<_, RebuildError>(()) + })?; + + if !put_buffer.is_empty() { + index.batch_insert_buffered(&put_buffer)?; + put_buffer.clear(); + } + if !relocate_buffer.is_empty() { + index.batch_relocate(&relocate_buffer)?; + relocate_buffer.clear(); + } + if !remove_buffer.is_empty() { + index.batch_remove(&remove_buffer); + remove_buffer.clear(); + } + + let _ = io.close(fd); + Ok(()) + })?; + + if let Some(cursor) = max_cursor { + index.set_write_cursor(cursor)?; + } + + Ok((replayed, file_cursors)) +} + +#[derive(Debug)] +pub struct HintIndex { + entries: HashMap, +} + +impl HintIndex { + pub fn from_scanned(scanned: Vec<(CidBytes, BlockLocation)>) -> Self { + let mut entries = HashMap::with_capacity(scanned.len()); + scanned.into_iter().for_each(|(cid, loc)| { + entries.entry(cid).or_insert(loc); + }); + Self { entries } + } + + pub fn get(&self, cid: &[u8; CID_SIZE]) -> Option { + self.entries.get(cid).copied() + } + + pub fn contains(&self, cid: &[u8; CID_SIZE]) -> bool { + self.entries.contains_key(cid) + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +pub fn scan_hints_to_memory( + io: &S, + data_dir: &Path, +) -> Result<(HintIndex, WriteCursor), RebuildError> { + use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; + + let hint_files = list_files_by_extension(io, data_dir, HINT_FILE_EXTENSION)?; + if hint_files.is_empty() { + return Err(RebuildError::Io(io::Error::new( + io::ErrorKind::NotFound, + "no hint files found for instant recovery", + ))); + } + + let file_results: Vec, RebuildError>> = hint_files + .par_iter() + .map(|&hf_id| scan_single_hint_file(io, data_dir, hf_id)) + .collect(); + + let mut state = RebuildState::new(); + file_results.into_iter().try_for_each(|result| { + result?.into_iter().for_each(|(cid_bytes, location)| { + state.push(cid_bytes, location); + }); + Ok::<_, RebuildError>(()) })?; - state.flush(index) + if state.entries.is_empty() { + return Err(RebuildError::Io(io::Error::new( + io::ErrorKind::InvalidData, + "hint files contained no valid entries", + ))); + } + + let cursor = state.cursor(); + let hint_index = HintIndex::from_scanned(state.entries); + + Ok((hint_index, cursor)) } #[cfg(test)] mod tests { use super::*; use crate::OpenOptions; - use crate::blockstore::data_file::{DataFileWriter, ReadBlockRecord, decode_block_record}; use crate::blockstore::test_cid; - use crate::blockstore::types::RefCount; use crate::sim::SimulatedIO; use std::path::Path; @@ -425,7 +851,7 @@ mod tests { .unwrap(); match record { - ReadHintRecord::Valid { + ReadHintRecord::Put { cid_bytes, file_id: fid, offset: off, @@ -440,6 +866,34 @@ mod tests { } } + #[test] + fn decrement_record_round_trip() { + let (sim, fd) = setup(); + let cid = test_cid(42); + let epoch = CommitEpoch::new(7); + let timestamp = WallClockMs::new(1_700_000_000_000); + + encode_decrement_record(&sim, fd, HintOffset::new(0), &cid, epoch, timestamp).unwrap(); + + let file_size = sim.file_size(fd).unwrap(); + let record = decode_hint_record(&sim, fd, HintOffset::new(0), file_size) + .unwrap() + .unwrap(); + + match record { + ReadHintRecord::Decrement { + cid_bytes, + epoch: decoded_epoch, + timestamp: decoded_ts, + } => { + assert_eq!(cid_bytes, cid); + assert_eq!(decoded_epoch, epoch); + assert_eq!(decoded_ts, timestamp); + } + other => panic!("expected Decrement, got {other:?}"), + } + } + #[test] fn multiple_hint_records() { let (sim, fd) = setup(); @@ -472,7 +926,7 @@ mod tests { .collect(); records.iter().enumerate().for_each(|(i, r)| match r { - ReadHintRecord::Valid { + ReadHintRecord::Put { file_id, length, .. } => { assert_eq!(file_id.raw(), i as u32); @@ -544,15 +998,14 @@ mod tests { ) .unwrap(); - let length_offset = CID_SIZE as u64 + 4 + 8; + let length_offset = FIELD_A_OFFSET as u64 + 4; let oversized = (MAX_BLOCK_SIZE + 1).to_le_bytes(); sim.write_all_at(fd, length_offset, &oversized).unwrap(); - let checksum_offset = (CID_SIZE + 4 + 8 + 4) as u64; - let mut buf = [0u8; CID_SIZE + 4 + 8 + 4]; + let mut buf = [0u8; HINT_PAYLOAD_SIZE]; sim.read_exact_at(fd, 0, &mut buf).unwrap(); let fixed_checksum = hint_checksum(&buf); - sim.write_all_at(fd, checksum_offset, &fixed_checksum.to_le_bytes()) + sim.write_all_at(fd, CHECKSUM_OFFSET as u64, &fixed_checksum.to_le_bytes()) .unwrap(); let file_size = sim.file_size(fd).unwrap(); @@ -588,7 +1041,7 @@ mod tests { assert_eq!(records.len(), 5); records.iter().enumerate().for_each(|(i, r)| match r { - ReadHintRecord::Valid { + ReadHintRecord::Put { file_id, length, .. } => { assert_eq!(file_id.raw(), 0); @@ -625,7 +1078,7 @@ mod tests { let reader = HintFileReader::open(&sim, fd).unwrap(); let valid_count = reader .filter_map(|r| match r.ok()? { - ReadHintRecord::Valid { .. } => Some(()), + ReadHintRecord::Put { .. } => Some(()), _ => None, }) .count(); @@ -658,7 +1111,7 @@ mod tests { let reader = HintFileReader::open(&sim, fd).unwrap(); let records: Vec<_> = reader.map(|r| r.unwrap()).collect(); assert_eq!(records.len(), 2); - assert!(matches!(records[0], ReadHintRecord::Valid { .. })); + assert!(matches!(records[0], ReadHintRecord::Put { .. })); assert!(matches!(records[1], ReadHintRecord::Truncated)); } @@ -684,338 +1137,10 @@ mod tests { let reader = HintFileReader::open(&sim, fd).unwrap(); let records: Vec<_> = reader.map(|r| r.unwrap()).collect(); assert_eq!(records.len(), 2); - assert!(matches!(records[0], ReadHintRecord::Valid { .. })); + assert!(matches!(records[0], ReadHintRecord::Put { .. })); assert!(matches!(records[1], ReadHintRecord::Corrupted)); } - fn setup_data_dir(sim: &SimulatedIO) -> &'static Path { - let dir = Path::new("/data"); - sim.mkdir(dir).unwrap(); - sim.sync_dir(dir).unwrap(); - dir - } - - fn write_test_blocks( - sim: &SimulatedIO, - dir: &Path, - file_id: DataFileId, - count: u8, - ) -> (Vec, BlockOffset) { - let data_path = dir.join(format!("{file_id}.tqb")); - let data_fd = sim.open(&data_path, OpenOptions::read_write()).unwrap(); - let mut data_writer = DataFileWriter::new(sim, data_fd, file_id).unwrap(); - - let hint_fd = sim - .open(&hint_file_path(dir, file_id), OpenOptions::read_write()) - .unwrap(); - let mut hint_writer = HintFileWriter::new(sim, hint_fd); - - let locations: Vec = (0..count) - .map(|i| { - let cid = test_cid(i); - let data = vec![i; (i as usize + 1) * 10]; - let loc = data_writer.append_block(&cid, &data).unwrap(); - hint_writer - .append_hint(&cid, loc.file_id, loc.offset, loc.length) - .unwrap(); - loc - }) - .collect(); - - data_writer.sync().unwrap(); - hint_writer.sync().unwrap(); - sim.sync_dir(dir).unwrap(); - - let final_pos = data_writer.position(); - (locations, final_pos) - } - - fn write_test_blocks_no_hints( - sim: &SimulatedIO, - dir: &Path, - file_id: DataFileId, - count: u8, - ) -> (Vec, BlockOffset) { - let data_path = dir.join(format!("{file_id}.tqb")); - let data_fd = sim.open(&data_path, OpenOptions::read_write()).unwrap(); - let mut data_writer = DataFileWriter::new(sim, data_fd, file_id).unwrap(); - - let locations: Vec = (0..count) - .map(|i| { - let cid = test_cid(i); - let data = vec![i; (i as usize + 1) * 10]; - data_writer.append_block(&cid, &data).unwrap() - }) - .collect(); - - data_writer.sync().unwrap(); - sim.sync_dir(dir).unwrap(); - - let final_pos = data_writer.position(); - (locations, final_pos) - } - - #[test] - fn rebuild_from_hints_restores_index() { - let sim = SimulatedIO::pristine(42); - let dir = setup_data_dir(&sim); - let block_count = 10u8; - let (locations, final_pos) = write_test_blocks(&sim, dir, DataFileId::new(0), block_count); - - let index_dir = tempfile::TempDir::new().unwrap(); - let index = KeyIndex::open(index_dir.path()).unwrap().into_inner(); - - rebuild_index_from_hints(&sim, dir, &index).unwrap(); - - (0..block_count).for_each(|i| { - let entry = index.get(&test_cid(i)).unwrap().unwrap(); - assert_eq!(entry.location, locations[i as usize]); - assert_eq!(entry.refcount, RefCount::one()); - }); - - let cursor = index.read_write_cursor().unwrap().unwrap(); - assert_eq!(cursor.file_id, DataFileId::new(0)); - assert_eq!(cursor.offset, final_pos); - } - - #[test] - fn rebuild_from_data_files_restores_index() { - let sim = SimulatedIO::pristine(42); - let dir = setup_data_dir(&sim); - let block_count = 10u8; - let (locations, final_pos) = - write_test_blocks_no_hints(&sim, dir, DataFileId::new(0), block_count); - - let index_dir = tempfile::TempDir::new().unwrap(); - let index = KeyIndex::open(index_dir.path()).unwrap().into_inner(); - - rebuild_index_from_data_files(&sim, dir, &index).unwrap(); - - (0..block_count).for_each(|i| { - let entry = index.get(&test_cid(i)).unwrap().unwrap(); - assert_eq!(entry.location, locations[i as usize]); - assert_eq!(entry.refcount, RefCount::one()); - }); - - let cursor = index.read_write_cursor().unwrap().unwrap(); - assert_eq!(cursor.file_id, DataFileId::new(0)); - assert_eq!(cursor.offset, final_pos); - } - - #[test] - fn rebuild_from_hints_handles_empty_dir() { - let sim = SimulatedIO::pristine(42); - let dir = setup_data_dir(&sim); - - let index_dir = tempfile::TempDir::new().unwrap(); - let index = KeyIndex::open(index_dir.path()).unwrap().into_inner(); - - rebuild_index_from_hints(&sim, dir, &index).unwrap(); - assert!(index.read_write_cursor().unwrap().is_none()); - } - - #[test] - fn rebuild_from_data_files_handles_empty_dir() { - let sim = SimulatedIO::pristine(42); - let dir = setup_data_dir(&sim); - - let index_dir = tempfile::TempDir::new().unwrap(); - let index = KeyIndex::open(index_dir.path()).unwrap().into_inner(); - - rebuild_index_from_data_files(&sim, dir, &index).unwrap(); - assert!(index.read_write_cursor().unwrap().is_none()); - } - - #[test] - fn rebuild_from_hints_handles_duplicate_cids() { - let sim = SimulatedIO::pristine(42); - let dir = setup_data_dir(&sim); - - let data_fd = sim - .open(Path::new("/data/000000.tqb"), OpenOptions::read_write()) - .unwrap(); - let mut data_writer = DataFileWriter::new(&sim, data_fd, DataFileId::new(0)).unwrap(); - - let hint_fd = sim - .open( - &hint_file_path(dir, DataFileId::new(0)), - OpenOptions::read_write(), - ) - .unwrap(); - let mut hint_writer = HintFileWriter::new(&sim, hint_fd); - - let cid = test_cid(1); - let data = vec![0xAA; 64]; - - let loc1 = data_writer.append_block(&cid, &data).unwrap(); - hint_writer - .append_hint(&cid, loc1.file_id, loc1.offset, loc1.length) - .unwrap(); - - let loc2 = data_writer.append_block(&cid, &data).unwrap(); - hint_writer - .append_hint(&cid, loc2.file_id, loc2.offset, loc2.length) - .unwrap(); - - data_writer.sync().unwrap(); - hint_writer.sync().unwrap(); - sim.sync_dir(dir).unwrap(); - - let index_dir = tempfile::TempDir::new().unwrap(); - let index = KeyIndex::open(index_dir.path()).unwrap().into_inner(); - - rebuild_index_from_hints(&sim, dir, &index).unwrap(); - - let entry = index.get(&cid).unwrap().unwrap(); - assert_eq!(entry.refcount, RefCount::new(2)); - assert_eq!(entry.location, loc1); - } - - #[test] - fn sim_hints_survive_crash_and_enable_rebuild() { - let sim = SimulatedIO::pristine(42); - let dir = setup_data_dir(&sim); - let block_count = 15u8; - let (locations, _) = write_test_blocks(&sim, dir, DataFileId::new(0), block_count); - - sim.crash(); - - let hint_fd = sim - .open( - &hint_file_path(dir, DataFileId::new(0)), - OpenOptions::read_only_existing(), - ) - .unwrap(); - let hint_size = sim.file_size(hint_fd).unwrap(); - assert_eq!(hint_size, block_count as u64 * HINT_RECORD_SIZE as u64); - let _ = sim.close(hint_fd); - - let index_dir = tempfile::TempDir::new().unwrap(); - let index = KeyIndex::open(index_dir.path()).unwrap().into_inner(); - - rebuild_index_from_hints(&sim, dir, &index).unwrap(); - - let data_fd = sim - .open( - Path::new("/data/000000.tqb"), - OpenOptions::read_only_existing(), - ) - .unwrap(); - let data_size = sim.file_size(data_fd).unwrap(); - - (0..block_count).for_each(|i| { - let entry = index.get(&test_cid(i)).unwrap().unwrap(); - assert_eq!(entry.location, locations[i as usize]); - - let record = decode_block_record(&sim, data_fd, entry.location.offset, data_size) - .unwrap() - .unwrap(); - match record { - ReadBlockRecord::Valid { - cid_bytes, data, .. - } => { - assert_eq!(cid_bytes, test_cid(i)); - assert_eq!(data, vec![i; (i as usize + 1) * 10]); - } - other => panic!("expected Valid for block {i}, got {other:?}"), - } - }); - } - - #[test] - fn sim_rebuild_from_data_files_without_hints() { - let sim = SimulatedIO::pristine(42); - let dir = setup_data_dir(&sim); - let block_count = 15u8; - let (locations, _) = write_test_blocks_no_hints(&sim, dir, DataFileId::new(0), block_count); - - sim.crash(); - - let index_dir = tempfile::TempDir::new().unwrap(); - let index = KeyIndex::open(index_dir.path()).unwrap().into_inner(); - - rebuild_index_from_data_files(&sim, dir, &index).unwrap(); - - let data_fd = sim - .open( - Path::new("/data/000000.tqb"), - OpenOptions::read_only_existing(), - ) - .unwrap(); - let data_size = sim.file_size(data_fd).unwrap(); - - (0..block_count).for_each(|i| { - let entry = index.get(&test_cid(i)).unwrap().unwrap(); - assert_eq!(entry.location, locations[i as usize]); - - let record = decode_block_record(&sim, data_fd, entry.location.offset, data_size) - .unwrap() - .unwrap(); - match record { - ReadBlockRecord::Valid { - cid_bytes, data, .. - } => { - assert_eq!(cid_bytes, test_cid(i)); - assert_eq!(data, vec![i; (i as usize + 1) * 10]); - } - other => panic!("expected Valid for block {i}, got {other:?}"), - } - }); - } - - #[test] - fn rebuild_across_multiple_data_files() { - let sim = SimulatedIO::pristine(42); - let dir = setup_data_dir(&sim); - - let (locs0, _) = write_test_blocks(&sim, dir, DataFileId::new(0), 5); - - let data_fd1 = sim - .open(Path::new("/data/000001.tqb"), OpenOptions::read_write()) - .unwrap(); - let mut data_writer1 = DataFileWriter::new(&sim, data_fd1, DataFileId::new(1)).unwrap(); - let hint_fd1 = sim - .open( - &hint_file_path(dir, DataFileId::new(1)), - OpenOptions::read_write(), - ) - .unwrap(); - let mut hint_writer1 = HintFileWriter::new(&sim, hint_fd1); - - let locs1: Vec = (5u8..10) - .map(|i| { - let cid = test_cid(i); - let data = vec![i; (i as usize + 1) * 10]; - let loc = data_writer1.append_block(&cid, &data).unwrap(); - hint_writer1 - .append_hint(&cid, loc.file_id, loc.offset, loc.length) - .unwrap(); - loc - }) - .collect(); - - data_writer1.sync().unwrap(); - hint_writer1.sync().unwrap(); - sim.sync_dir(dir).unwrap(); - - let index_dir = tempfile::TempDir::new().unwrap(); - let index = KeyIndex::open(index_dir.path()).unwrap().into_inner(); - - rebuild_index_from_hints(&sim, dir, &index).unwrap(); - - (0u8..5).for_each(|i| { - let entry = index.get(&test_cid(i)).unwrap().unwrap(); - assert_eq!(entry.location, locs0[i as usize]); - }); - (5u8..10).for_each(|i| { - let entry = index.get(&test_cid(i)).unwrap().unwrap(); - assert_eq!(entry.location, locs1[(i - 5) as usize]); - }); - - let cursor = index.read_write_cursor().unwrap().unwrap(); - assert_eq!(cursor.file_id, DataFileId::new(1)); - } - #[test] fn hint_file_path_format() { let path = hint_file_path(Path::new("/data"), DataFileId::new(0)); diff --git a/crates/tranquil-store/src/blockstore/key_index.rs b/crates/tranquil-store/src/blockstore/key_index.rs deleted file mode 100644 index a9c2442..0000000 --- a/crates/tranquil-store/src/blockstore/key_index.rs +++ /dev/null @@ -1,539 +0,0 @@ -use std::collections::HashMap; -use std::path::Path; - -use fjall::{ - Database, Keyspace, KeyspaceCreateOptions, PersistMode, - config::{BloomConstructionPolicy, FilterPolicy, FilterPolicyEntry}, -}; - -use super::data_file::CID_SIZE; -use super::types::{BlockLocation, IndexEntry, RefCount, WriteCursor}; - -const WRITE_CURSOR_KEY: &[u8] = b"\x00write_cursor"; - -const KEYSPACE_NAME: &str = "blocks"; - -fn bloom_options() -> KeyspaceCreateOptions { - KeyspaceCreateOptions::default().filter_policy(FilterPolicy::new([ - FilterPolicyEntry::Bloom(BloomConstructionPolicy::FalsePositiveRate(0.01)), - FilterPolicyEntry::Bloom(BloomConstructionPolicy::FalsePositiveRate(0.01)), - ])) -} - -fn is_corruption_error(e: &fjall::Error) -> bool { - match e { - fjall::Error::Io(io_err) => matches!( - io_err.kind(), - std::io::ErrorKind::InvalidData | std::io::ErrorKind::UnexpectedEof - ), - fjall::Error::Locked | fjall::Error::KeyspaceDeleted => false, - _ => true, - } -} - -fn serialize_entry(entry: &IndexEntry) -> Vec { - postcard::to_allocvec(entry) - .expect("IndexEntry serialization is infallible for fixed-layout types") -} - -fn deserialize_entry(bytes: &[u8]) -> Result { - postcard::from_bytes(bytes).map_err(KeyIndexError::Deserialize) -} - -fn serialize_cursor(cursor: &WriteCursor) -> Vec { - postcard::to_allocvec(cursor) - .expect("WriteCursor serialization is infallible for fixed-layout types") -} - -fn deserialize_cursor(bytes: &[u8]) -> Result { - postcard::from_bytes(bytes).map_err(KeyIndexError::Deserialize) -} - -#[derive(Debug)] -pub enum KeyIndexError { - Fjall(fjall::Error), - Deserialize(postcard::Error), - MissingEntry, -} - -impl std::fmt::Display for KeyIndexError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Fjall(e) => write!(f, "fjall: {e}"), - Self::Deserialize(e) => write!(f, "deserialize: {e}"), - Self::MissingEntry => write!(f, "entry not found"), - } - } -} - -impl std::error::Error for KeyIndexError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Fjall(e) => Some(e), - Self::Deserialize(e) => Some(e), - Self::MissingEntry => None, - } - } -} - -impl From for KeyIndexError { - fn from(e: fjall::Error) -> Self { - Self::Fjall(e) - } -} - -pub enum KeyIndexOpenOutcome { - Opened(KeyIndex), - NeedsRebuild(KeyIndex), -} - -impl KeyIndexOpenOutcome { - pub fn into_inner(self) -> KeyIndex { - match self { - Self::Opened(idx) | Self::NeedsRebuild(idx) => idx, - } - } - - pub fn needs_rebuild(&self) -> bool { - matches!(self, Self::NeedsRebuild(_)) - } -} - -pub struct KeyIndex { - db: Database, - blocks: Keyspace, -} - -impl KeyIndex { - pub fn open(path: &Path) -> Result { - match Self::try_open(path) { - Ok(idx) => Ok(KeyIndexOpenOutcome::Opened(idx)), - Err(KeyIndexError::Fjall(ref e)) if is_corruption_error(e) => { - let _ = std::fs::remove_dir_all(path); - let idx = Self::try_open(path)?; - Ok(KeyIndexOpenOutcome::NeedsRebuild(idx)) - } - Err(e) => Err(e), - } - } - - fn try_open(path: &Path) -> Result { - let db = Database::builder(path).open()?; - let blocks = db.keyspace(KEYSPACE_NAME, bloom_options)?; - Ok(Self { db, blocks }) - } - - pub fn get(&self, cid_bytes: &[u8; CID_SIZE]) -> Result, KeyIndexError> { - self.blocks - .get(cid_bytes)? - .map(|v| deserialize_entry(&v)) - .transpose() - } - - pub fn has(&self, cid_bytes: &[u8; CID_SIZE]) -> Result { - self.blocks.contains_key(cid_bytes).map_err(Into::into) - } - - pub fn put( - &self, - cid_bytes: &[u8; CID_SIZE], - location: BlockLocation, - ) -> Result<(), KeyIndexError> { - let entry = match self.get(cid_bytes)? { - Some(existing) => IndexEntry { - location: existing.location, - refcount: existing.refcount.increment(), - }, - None => IndexEntry { - location, - refcount: RefCount::one(), - }, - }; - self.blocks - .insert(cid_bytes, serialize_entry(&entry)) - .map_err(Into::into) - } - - pub fn decrement_refcount( - &self, - cid_bytes: &[u8; CID_SIZE], - ) -> Result { - let existing = self.get(cid_bytes)?.ok_or(KeyIndexError::MissingEntry)?; - let new_refcount = match existing.refcount.is_zero() { - true => { - tracing::warn!(?cid_bytes, "decrement on zero-refcount entry, skipping"); - existing.refcount - } - false => existing.refcount.decrement(), - }; - let updated = IndexEntry { - location: existing.location, - refcount: new_refcount, - }; - self.blocks.insert(cid_bytes, serialize_entry(&updated))?; - Ok(new_refcount) - } - - pub fn batch_put( - &self, - entries: &[([u8; CID_SIZE], BlockLocation)], - decrements: &[[u8; CID_SIZE]], - cursor: WriteCursor, - ) -> Result<(), KeyIndexError> { - let mut batch = self.db.batch().durability(Some(PersistMode::SyncData)); - let mut pending: HashMap<[u8; CID_SIZE], IndexEntry> = HashMap::new(); - - entries.iter().try_for_each(|(cid_bytes, location)| { - let entry = match pending.get(cid_bytes).copied().or(self.get(cid_bytes)?) { - Some(existing) => IndexEntry { - location: existing.location, - refcount: existing.refcount.increment(), - }, - None => IndexEntry { - location: *location, - refcount: RefCount::one(), - }, - }; - pending.insert(*cid_bytes, entry); - batch.insert(&self.blocks, cid_bytes.as_slice(), serialize_entry(&entry)); - Ok::<_, KeyIndexError>(()) - })?; - - decrements.iter().try_for_each(|cid_bytes| { - let existing = pending - .get(cid_bytes) - .copied() - .or(self.get(cid_bytes)?) - .ok_or(KeyIndexError::MissingEntry)?; - let new_refcount = match existing.refcount.is_zero() { - true => { - tracing::warn!(?cid_bytes, "decrement on zero-refcount entry, skipping"); - existing.refcount - } - false => existing.refcount.decrement(), - }; - let updated = IndexEntry { - location: existing.location, - refcount: new_refcount, - }; - pending.insert(*cid_bytes, updated); - batch.insert( - &self.blocks, - cid_bytes.as_slice(), - serialize_entry(&updated), - ); - Ok::<_, KeyIndexError>(()) - })?; - - batch.insert(&self.blocks, WRITE_CURSOR_KEY, serialize_cursor(&cursor)); - - batch.commit().map_err(Into::into) - } - - pub fn read_write_cursor(&self) -> Result, KeyIndexError> { - self.blocks - .get(WRITE_CURSOR_KEY)? - .map(|v| deserialize_cursor(&v)) - .transpose() - } - - pub fn persist(&self) -> Result<(), KeyIndexError> { - self.db.persist(PersistMode::SyncData).map_err(Into::into) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::blockstore::test_cid; - use crate::blockstore::types::{BlockLength, BlockOffset, DataFileId}; - - fn test_location(file_id: u32, offset: u64, length: u32) -> BlockLocation { - BlockLocation { - file_id: DataFileId::new(file_id), - offset: BlockOffset::new(offset), - length: BlockLength::new(length), - } - } - - fn open_temp() -> (tempfile::TempDir, KeyIndex) { - let dir = tempfile::TempDir::new().unwrap(); - let outcome = KeyIndex::open(dir.path()).unwrap(); - assert!(!outcome.needs_rebuild()); - (dir, outcome.into_inner()) - } - - #[test] - fn put_then_get_round_trips() { - let (_dir, idx) = open_temp(); - let cid = test_cid(1); - let loc = test_location(0, 100, 256); - - idx.put(&cid, loc).unwrap(); - let entry = idx.get(&cid).unwrap().unwrap(); - assert_eq!(entry.location, loc); - assert_eq!(entry.refcount, RefCount::one()); - } - - #[test] - fn get_missing_returns_none() { - let (_dir, idx) = open_temp(); - assert!(idx.get(&test_cid(42)).unwrap().is_none()); - } - - #[test] - fn has_missing_returns_false() { - let (_dir, idx) = open_temp(); - assert!(!idx.has(&test_cid(42)).unwrap()); - } - - #[test] - fn has_existing_returns_true() { - let (_dir, idx) = open_temp(); - let cid = test_cid(1); - idx.put(&cid, test_location(0, 0, 10)).unwrap(); - assert!(idx.has(&cid).unwrap()); - } - - #[test] - fn duplicate_put_increments_refcount() { - let (_dir, idx) = open_temp(); - let cid = test_cid(1); - let loc = test_location(0, 100, 256); - - idx.put(&cid, loc).unwrap(); - idx.put(&cid, test_location(1, 200, 512)).unwrap(); - - let entry = idx.get(&cid).unwrap().unwrap(); - assert_eq!(entry.refcount, RefCount::new(2)); - assert_eq!(entry.location, loc); - } - - #[test] - fn decrement_refcount_from_two_to_one() { - let (_dir, idx) = open_temp(); - let cid = test_cid(1); - idx.put(&cid, test_location(0, 0, 10)).unwrap(); - idx.put(&cid, test_location(0, 0, 10)).unwrap(); - - let rc = idx.decrement_refcount(&cid).unwrap(); - assert_eq!(rc, RefCount::one()); - - let entry = idx.get(&cid).unwrap().unwrap(); - assert_eq!(entry.refcount, RefCount::one()); - } - - #[test] - fn decrement_refcount_to_zero_keeps_entry() { - let (_dir, idx) = open_temp(); - let cid = test_cid(1); - idx.put(&cid, test_location(0, 0, 10)).unwrap(); - - let rc = idx.decrement_refcount(&cid).unwrap(); - assert!(rc.is_zero()); - - let entry = idx.get(&cid).unwrap().unwrap(); - assert!(entry.refcount.is_zero()); - } - - #[test] - fn decrement_missing_entry_errors() { - let (_dir, idx) = open_temp(); - let result = idx.decrement_refcount(&test_cid(99)); - assert!(matches!(result, Err(KeyIndexError::MissingEntry))); - } - - #[test] - fn batch_put_new_entries() { - let (_dir, idx) = open_temp(); - let entries: Vec<_> = (0u8..3) - .map(|i| (test_cid(i), test_location(0, i as u64 * 100, 50))) - .collect(); - let cursor = WriteCursor { - file_id: DataFileId::new(0), - offset: BlockOffset::new(300), - }; - - idx.batch_put(&entries, &[], cursor).unwrap(); - - entries.iter().for_each(|(cid, loc)| { - let entry = idx.get(cid).unwrap().unwrap(); - assert_eq!(entry.location, *loc); - assert_eq!(entry.refcount, RefCount::one()); - }); - } - - #[test] - fn batch_put_increments_existing() { - let (_dir, idx) = open_temp(); - let cid = test_cid(1); - let original_loc = test_location(0, 100, 50); - idx.put(&cid, original_loc).unwrap(); - - let entries = vec![(cid, test_location(1, 200, 60))]; - let cursor = WriteCursor { - file_id: DataFileId::new(1), - offset: BlockOffset::new(260), - }; - idx.batch_put(&entries, &[], cursor).unwrap(); - - let entry = idx.get(&cid).unwrap().unwrap(); - assert_eq!(entry.refcount, RefCount::new(2)); - assert_eq!(entry.location, original_loc); - } - - #[test] - fn batch_put_with_decrements() { - let (_dir, idx) = open_temp(); - let cid_a = test_cid(1); - let cid_b = test_cid(2); - idx.put(&cid_b, test_location(0, 0, 10)).unwrap(); - idx.put(&cid_b, test_location(0, 0, 10)).unwrap(); - - let entries = vec![(cid_a, test_location(0, 100, 50))]; - let decrements = vec![cid_b]; - let cursor = WriteCursor { - file_id: DataFileId::new(0), - offset: BlockOffset::new(150), - }; - idx.batch_put(&entries, &decrements, cursor).unwrap(); - - let a = idx.get(&cid_a).unwrap().unwrap(); - assert_eq!(a.refcount, RefCount::one()); - - let b = idx.get(&cid_b).unwrap().unwrap(); - assert_eq!(b.refcount, RefCount::one()); - } - - #[test] - fn batch_put_mixed_new_and_duplicate() { - let (_dir, idx) = open_temp(); - let existing_cid = test_cid(1); - let existing_loc = test_location(0, 0, 10); - idx.put(&existing_cid, existing_loc).unwrap(); - - let entries: Vec<_> = (1u8..=4) - .map(|i| (test_cid(i), test_location(0, i as u64 * 100, 50))) - .collect(); - let cursor = WriteCursor { - file_id: DataFileId::new(0), - offset: BlockOffset::new(500), - }; - idx.batch_put(&entries, &[], cursor).unwrap(); - - let existing = idx.get(&existing_cid).unwrap().unwrap(); - assert_eq!(existing.refcount, RefCount::new(2)); - assert_eq!(existing.location, existing_loc); - - (2u8..=4).for_each(|i| { - let entry = idx.get(&test_cid(i)).unwrap().unwrap(); - assert_eq!(entry.refcount, RefCount::one()); - }); - } - - #[test] - fn batch_put_duplicate_cid_in_same_batch() { - let (_dir, idx) = open_temp(); - let cid = test_cid(1); - let loc = test_location(0, 100, 50); - - let entries = vec![(cid, loc), (cid, test_location(0, 200, 60))]; - let cursor = WriteCursor { - file_id: DataFileId::new(0), - offset: BlockOffset::new(260), - }; - idx.batch_put(&entries, &[], cursor).unwrap(); - - let entry = idx.get(&cid).unwrap().unwrap(); - assert_eq!(entry.refcount, RefCount::new(2)); - assert_eq!(entry.location, loc); - } - - #[test] - fn batch_put_entry_then_decrement_same_cid() { - let (_dir, idx) = open_temp(); - let cid = test_cid(1); - let loc = test_location(0, 100, 50); - - let entries = vec![(cid, loc)]; - let decrements = vec![cid]; - let cursor = WriteCursor { - file_id: DataFileId::new(0), - offset: BlockOffset::new(150), - }; - idx.batch_put(&entries, &decrements, cursor).unwrap(); - - let entry = idx.get(&cid).unwrap().unwrap(); - assert!(entry.refcount.is_zero()); - } - - #[test] - fn write_cursor_round_trip() { - let (_dir, idx) = open_temp(); - assert!(idx.read_write_cursor().unwrap().is_none()); - - let cursor = WriteCursor { - file_id: DataFileId::new(3), - offset: BlockOffset::new(65536), - }; - let entries = vec![(test_cid(1), test_location(3, 0, 100))]; - idx.batch_put(&entries, &[], cursor).unwrap(); - - let read_back = idx.read_write_cursor().unwrap().unwrap(); - assert_eq!(read_back, cursor); - } - - #[test] - fn write_cursor_persists_across_reopen() { - let dir = tempfile::TempDir::new().unwrap(); - - let cursor = WriteCursor { - file_id: DataFileId::new(7), - offset: BlockOffset::new(99999), - }; - - { - let idx = KeyIndex::open(dir.path()).unwrap().into_inner(); - let entries = vec![(test_cid(1), test_location(7, 0, 100))]; - idx.batch_put(&entries, &[], cursor).unwrap(); - idx.persist().unwrap(); - } - - { - let idx = KeyIndex::open(dir.path()).unwrap().into_inner(); - let read_back = idx.read_write_cursor().unwrap().unwrap(); - assert_eq!(read_back, cursor); - - let entry = idx.get(&test_cid(1)).unwrap().unwrap(); - assert_eq!(entry.refcount, RefCount::one()); - } - } - - #[test] - fn corrupt_index_triggers_needs_rebuild() { - let dir = tempfile::TempDir::new().unwrap(); - - { - let idx = KeyIndex::open(dir.path()).unwrap().into_inner(); - idx.put(&test_cid(1), test_location(0, 0, 10)).unwrap(); - idx.persist().unwrap(); - } - - std::fs::read_dir(dir.path()) - .unwrap() - .filter_map(|e| e.ok()) - .for_each(|entry| { - let path = entry.path(); - if path.is_file() { - std::fs::write(&path, b"corrupted").unwrap(); - } - }); - - let outcome = KeyIndex::open(dir.path()).unwrap(); - assert!(outcome.needs_rebuild()); - - let idx = outcome.into_inner(); - assert!(idx.get(&test_cid(1)).unwrap().is_none()); - assert!(idx.read_write_cursor().unwrap().is_none()); - } -} diff --git a/crates/tranquil-store/src/blockstore/manager.rs b/crates/tranquil-store/src/blockstore/manager.rs index 91427c6..3e1e9e0 100644 --- a/crates/tranquil-store/src/blockstore/manager.rs +++ b/crates/tranquil-store/src/blockstore/manager.rs @@ -136,6 +136,27 @@ impl DataFileManager { pub fn list_files(&self) -> io::Result> { list_files_by_extension(&self.io, &self.data_dir, DATA_FILE_EXTENSION) } + + pub fn evict_handle(&self, file_id: DataFileId) { + let removed = self.handles.write().remove(&file_id); + if let Some(entry) = removed { + let _ = self.io.close(entry.fd); + } + } + + pub fn delete_data_file(&self, file_id: DataFileId) -> io::Result<()> { + self.evict_handle(file_id); + let path = self.data_file_path(file_id); + self.io.delete(&path) + } +} + +impl Drop for DataFileManager { + fn drop(&mut self) { + self.handles.write().drain().for_each(|(_, entry)| { + let _ = self.io.close(entry.fd); + }); + } } #[cfg(test)] diff --git a/crates/tranquil-store/src/blockstore/mod.rs b/crates/tranquil-store/src/blockstore/mod.rs index a1b918f..40f4cc4 100644 --- a/crates/tranquil-store/src/blockstore/mod.rs +++ b/crates/tranquil-store/src/blockstore/mod.rs @@ -1,30 +1,35 @@ +mod compaction; mod data_file; mod group_commit; +pub mod hash_index; mod hint; -mod key_index; mod manager; mod reader; mod store; mod types; +pub use compaction::CompactionError; pub use data_file::{ BLOCK_FORMAT_VERSION, BLOCK_HEADER_SIZE, BLOCK_MAGIC, BLOCK_RECORD_OVERHEAD, CID_SIZE, DataFileReader, DataFileWriter, ReadBlockRecord, ValidBlock, decode_block_record, encode_block_record, }; -pub use group_commit::{CommitError, CommitRequest, GroupCommitConfig, GroupCommitWriter}; +pub use group_commit::{ + ActiveFileSet, CommitError, CommitRequest, FileIdAllocator, GroupCommitConfig, + GroupCommitWriter, ShardHintPositions, +}; pub use hint::{ - HINT_FILE_EXTENSION, HINT_RECORD_SIZE, HintFileReader, HintFileWriter, ReadHintRecord, - RebuildError, decode_hint_record, hint_file_path, rebuild_index_from_data_files, - rebuild_index_from_hints, + HINT_FILE_EXTENSION, HINT_RECORD_SIZE, HintFileReader, HintFileWriter, HintIndex, + ReadHintRecord, RebuildError, decode_hint_record, hint_file_path, scan_hints_to_memory, }; -pub use key_index::{KeyIndex, KeyIndexError, KeyIndexOpenOutcome}; pub use manager::{DEFAULT_MAX_FILE_SIZE, DataFileManager}; pub use reader::{BlockStoreReader, ReadError}; -pub use store::{BlockStoreConfig, TranquilBlockStore}; +pub use store::QuiesceGuard; +pub use store::{BlockStoreConfig, DEFAULT_SHARD_COUNT, TranquilBlockStore}; pub use types::{ - BlockLength, BlockLocation, BlockOffset, DataFileId, HintOffset, IndexEntry, MAX_BLOCK_SIZE, - RefCount, WriteCursor, + BlockLength, BlockLocation, BlockOffset, BlockstoreSnapshot, CidBytes, CollectionResult, + CommitEpoch, CompactionResult, DataFileId, EpochCounter, HintOffset, IndexEntry, LivenessInfo, + MAX_BLOCK_SIZE, RefCount, ShardId, WallClockMs, WriteCursor, }; use std::io; @@ -40,7 +45,7 @@ impl BlocksSynced { } } -pub(crate) fn list_files_by_extension( +pub fn list_files_by_extension( io: &S, dir: &Path, extension: &str, diff --git a/crates/tranquil-store/src/blockstore/reader.rs b/crates/tranquil-store/src/blockstore/reader.rs index a8b1b7f..de33d58 100644 --- a/crates/tranquil-store/src/blockstore/reader.rs +++ b/crates/tranquil-store/src/blockstore/reader.rs @@ -7,14 +7,13 @@ use bytes::Bytes; use crate::io::{FileId, StorageIO}; use super::data_file::{CID_SIZE, ReadBlockRecord, decode_block_record}; -use super::key_index::{KeyIndex, KeyIndexError}; +use super::hash_index::BlockIndex; use super::manager::DataFileManager; use super::types::{BlockLocation, BlockOffset, DataFileId}; #[derive(Debug, Clone)] pub enum ReadError { Io(Arc), - Index(Arc), Corrupted { file_id: DataFileId, offset: BlockOffset, @@ -25,7 +24,6 @@ impl std::fmt::Display for ReadError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Io(e) => write!(f, "io: {e}"), - Self::Index(e) => write!(f, "index: {e}"), Self::Corrupted { file_id, offset } => { write!(f, "corrupted block at {file_id}:{}", offset.raw()) } @@ -37,7 +35,6 @@ impl std::error::Error for ReadError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(e) => Some(e.as_ref()), - Self::Index(e) => Some(e.as_ref()), Self::Corrupted { .. } => None, } } @@ -49,14 +46,8 @@ impl From for ReadError { } } -impl From for ReadError { - fn from(e: KeyIndexError) -> Self { - Self::Index(Arc::new(e)) - } -} - pub struct BlockStoreReader { - index: Arc, + index: Arc, manager: Arc>, } @@ -70,54 +61,58 @@ impl Clone for BlockStoreReader { } impl BlockStoreReader { - pub fn new(index: Arc, manager: Arc>) -> Self { + pub fn new(index: Arc, manager: Arc>) -> Self { Self { index, manager } } + pub fn manager(&self) -> &DataFileManager { + &self.manager + } + pub fn get(&self, cid: &[u8; CID_SIZE]) -> Result, ReadError> { - let entry = match self.index.get(cid)? { - Some(e) => e, - None => return Ok(None), - }; - self.read_block_at(entry.location).map(Some) + match self.index.get(cid) { + Some(e) => self.read_block_at(e.location).map(Some), + None => Ok(None), + } } pub fn has(&self, cid: &[u8; CID_SIZE]) -> Result { - self.index.has(cid).map_err(ReadError::from) + Ok(self.index.has(cid)) } pub fn get_many(&self, cids: &[[u8; CID_SIZE]]) -> Result>, ReadError> { let mut results: Vec> = vec![None; cids.len()]; - let lookups: Vec<(usize, BlockLocation)> = cids + let index_lookups: Vec<(usize, BlockLocation)> = cids .iter() .enumerate() - .filter_map(|(i, cid)| match self.index.get(cid) { - Ok(Some(entry)) => Some(Ok((i, entry.location))), - Ok(None) => None, - Err(e) => Some(Err(ReadError::from(e))), - }) - .collect::, _>>()?; + .filter_map(|(i, cid)| self.index.get(cid).map(|entry| (i, entry.location))) + .collect(); + self.read_locations_into(&index_lookups, &mut results)?; + Ok(results) + } + + fn read_locations_into( + &self, + lookups: &[(usize, BlockLocation)], + results: &mut [Option], + ) -> Result<(), ReadError> { let mut by_file: HashMap> = HashMap::new(); - lookups.into_iter().for_each(|(idx, loc)| { + lookups.iter().for_each(|&(idx, loc)| { by_file.entry(loc.file_id).or_default().push((idx, loc)); }); by_file.into_iter().try_for_each(|(file_id, mut entries)| { let fd = self.manager.open_for_read(file_id)?; let file_size = self.manager.io().file_size(fd)?; - entries.sort_by_key(|(_, loc)| loc.offset); - entries.into_iter().try_for_each(|(orig_idx, loc)| { let data = self.decode_and_validate(fd, file_size, loc)?; results[orig_idx] = Some(data); Ok::<_, ReadError>(()) }) - })?; - - Ok(results) + }) } fn read_block_at(&self, location: BlockLocation) -> Result { @@ -155,457 +150,3 @@ impl BlockStoreReader { } } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::RealIO; - use crate::blockstore::data_file::CID_SIZE; - use crate::blockstore::group_commit::{CommitRequest, GroupCommitConfig, GroupCommitWriter}; - use crate::blockstore::key_index::KeyIndex; - use crate::blockstore::manager::DataFileManager; - use crate::blockstore::test_cid; - use futures::StreamExt; - - struct TestHarness { - _dir: tempfile::TempDir, - index: Arc, - manager: Arc>, - writer: Option, - sender: flume::Sender, - } - - impl TestHarness { - fn new() -> Self { - let dir = tempfile::TempDir::new().unwrap(); - let data_dir = dir.path().join("data"); - std::fs::create_dir_all(&data_dir).unwrap(); - let index_dir = dir.path().join("index"); - let manager = Arc::new(DataFileManager::with_default_max_size( - RealIO::new(), - data_dir, - )); - let index = Arc::new(KeyIndex::open(&index_dir).unwrap().into_inner()); - let writer = GroupCommitWriter::spawn( - DataFileManager::with_default_max_size(RealIO::new(), dir.path().join("data")), - Arc::clone(&index), - GroupCommitConfig::default(), - ) - .unwrap(); - let sender = writer.sender().clone(); - - Self { - _dir: dir, - index, - manager, - writer: Some(writer), - sender, - } - } - - fn reader(&self) -> BlockStoreReader { - BlockStoreReader::new(Arc::clone(&self.index), Arc::clone(&self.manager)) - } - - async fn put_blocks( - &self, - blocks: Vec<([u8; CID_SIZE], Vec)>, - ) -> Result, super::super::group_commit::CommitError> - { - let (tx, rx) = tokio::sync::oneshot::channel(); - self.sender - .send_async(CommitRequest::PutBlocks { - blocks, - response: tx, - }) - .await - .map_err(|_| super::super::group_commit::CommitError::ChannelClosed)?; - rx.await - .map_err(|_| super::super::group_commit::CommitError::ChannelClosed)? - } - - fn shutdown(&mut self) { - if let Some(w) = self.writer.take() { - w.shutdown(); - } - } - } - - impl Drop for TestHarness { - fn drop(&mut self) { - self.shutdown(); - } - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn get_existing_block() { - let mut harness = TestHarness::new(); - let cid = test_cid(1); - let data = vec![0xAB; 256]; - harness.put_blocks(vec![(cid, data.clone())]).await.unwrap(); - harness.shutdown(); - - let reader = harness.reader(); - let result = reader.get(&cid).unwrap().unwrap(); - assert_eq!(&result[..], &data[..]); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn get_missing_block_returns_none() { - let mut harness = TestHarness::new(); - harness.shutdown(); - - let reader = harness.reader(); - assert!(reader.get(&test_cid(99)).unwrap().is_none()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn get_many_mixed_hits_and_misses() { - let mut harness = TestHarness::new(); - let blocks: Vec<_> = (0u8..5) - .map(|i| (test_cid(i), vec![i; (i as usize + 1) * 32])) - .collect(); - harness.put_blocks(blocks.clone()).await.unwrap(); - harness.shutdown(); - - let reader = harness.reader(); - let query: Vec<[u8; CID_SIZE]> = vec![ - test_cid(0), - test_cid(99), - test_cid(2), - test_cid(100), - test_cid(4), - ]; - let results = reader.get_many(&query).unwrap(); - - assert_eq!(results.len(), 5); - assert_eq!(&results[0].as_ref().unwrap()[..], &blocks[0].1[..]); - assert!(results[1].is_none()); - assert_eq!(&results[2].as_ref().unwrap()[..], &blocks[2].1[..]); - assert!(results[3].is_none()); - assert_eq!(&results[4].as_ref().unwrap()[..], &blocks[4].1[..]); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn has_returns_true_for_existing() { - let mut harness = TestHarness::new(); - let cid = test_cid(1); - harness - .put_blocks(vec![(cid, vec![0xFF; 64])]) - .await - .unwrap(); - harness.shutdown(); - - let reader = harness.reader(); - assert!(reader.has(&cid).unwrap()); - assert!(!reader.has(&test_cid(99)).unwrap()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn checksum_mismatch_returns_error() { - let mut harness = TestHarness::new(); - let cid = test_cid(1); - let data = vec![0xAA; 256]; - harness.put_blocks(vec![(cid, data)]).await.unwrap(); - harness.shutdown(); - - let entry = harness.index.get(&cid).unwrap().unwrap(); - let loc = entry.location; - let data_file_path = harness.manager.data_file_path(loc.file_id); - - let corrupt_offset = loc.offset.raw() + super::super::data_file::CID_SIZE as u64 + 4 + 128; - let file_bytes = std::fs::read(&data_file_path).unwrap(); - let mut corrupted = file_bytes; - corrupted[corrupt_offset as usize] ^= 0xFF; - std::fs::write(&data_file_path, &corrupted).unwrap(); - - let fresh_manager = Arc::new(DataFileManager::with_default_max_size( - RealIO::new(), - harness.manager.data_dir().to_path_buf(), - )); - let reader = BlockStoreReader::new(Arc::clone(&harness.index), fresh_manager); - let result = reader.get(&cid); - assert!( - matches!(result, Err(ReadError::Corrupted { .. })), - "expected Corrupted error, got {result:?}" - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn spawn_blocking_does_not_block_tokio_workers() { - let mut harness = TestHarness::new(); - let blocks: Vec<_> = (0u8..200).map(|i| (test_cid(i), vec![i; 1024])).collect(); - harness.put_blocks(blocks).await.unwrap(); - harness.shutdown(); - - let reader = harness.reader(); - let reader = Arc::new(reader); - - let timer_handle = tokio::spawn(futures::stream::iter(0..100).fold( - std::time::Duration::ZERO, - |max_drift, _| async move { - let start = std::time::Instant::now(); - tokio::time::sleep(std::time::Duration::from_millis(1)).await; - let drift = start - .elapsed() - .saturating_sub(std::time::Duration::from_millis(1)); - max_drift.max(drift) - }, - )); - - let read_handles: Vec<_> = (0..8) - .map(|_| { - let reader = Arc::clone(&reader); - tokio::spawn(futures::stream::iter(0u8..200).fold( - (0u64, 200u64), - move |(total_us, count), i| { - let reader = Arc::clone(&reader); - async move { - let cid = test_cid(i); - let start = std::time::Instant::now(); - let result = tokio::task::spawn_blocking(move || reader.get(&cid)) - .await - .unwrap(); - let elapsed_us = start.elapsed().as_micros() as u64; - assert!(result.unwrap().is_some()); - (total_us.saturating_add(elapsed_us), count) - } - }, - )) - }) - .collect(); - - let timer_drift = timer_handle.await.unwrap(); - assert!( - timer_drift < std::time::Duration::from_millis(5), - "timer drift {timer_drift:?} exceeds 5ms, reads may be blocking tokio workers" - ); - - let stats: Vec<(u64, u64)> = futures::future::join_all(read_handles) - .await - .into_iter() - .map(|r| r.unwrap()) - .collect(); - let total_us: u64 = stats.iter().map(|(us, _)| us).sum(); - let total_count: u64 = stats.iter().map(|(_, c)| c).sum(); - let avg_us = total_us / total_count.max(1); - eprintln!("avg read latency: {avg_us}us across {total_count} reads"); - } - - use crate::blockstore::test_cid_u16 as stress_cid; - - #[tokio::test(flavor = "multi_thread", worker_threads = 8)] - async fn stress_50_writers_20_readers() { - let dir = tempfile::TempDir::new().unwrap(); - let data_dir = dir.path().join("data"); - std::fs::create_dir_all(&data_dir).unwrap(); - let index_dir = dir.path().join("index"); - let index = Arc::new(KeyIndex::open(&index_dir).unwrap().into_inner()); - let manager_for_writer = - DataFileManager::with_default_max_size(RealIO::new(), data_dir.clone()); - let writer = GroupCommitWriter::spawn( - manager_for_writer, - Arc::clone(&index), - GroupCommitConfig::default(), - ) - .unwrap(); - let sender = writer.sender().clone(); - let manager_for_reader = Arc::new(DataFileManager::with_default_max_size( - RealIO::new(), - data_dir, - )); - let reader = BlockStoreReader::new(Arc::clone(&index), manager_for_reader); - - let committed = Arc::new(std::sync::Mutex::new(Vec::<(u16, Vec)>::new())); - let writer_done = Arc::new(std::sync::atomic::AtomicBool::new(false)); - - let writer_handles: Vec<_> = (0u16..50) - .map(|writer_id| { - let sender = sender.clone(); - let committed = Arc::clone(&committed); - tokio::spawn(async move { - futures::stream::iter(0u16..200) - .fold((), |(), block_id| { - let sender = sender.clone(); - let committed = Arc::clone(&committed); - async move { - let seed = writer_id * 200 + block_id; - let cid = stress_cid(seed); - let size = ((seed as usize % 256) + 1) * 4; - let data = vec![seed as u8; size]; - let (tx, rx) = tokio::sync::oneshot::channel(); - sender - .send_async(CommitRequest::PutBlocks { - blocks: vec![(cid, data.clone())], - response: tx, - }) - .await - .unwrap(); - rx.await.unwrap().unwrap(); - committed.lock().unwrap().push((seed, data)); - } - }) - .await; - }) - }) - .collect(); - - let reader_handles: Vec<_> = (0..20) - .map(|_| { - let reader = reader.clone(); - let committed = Arc::clone(&committed); - let done = Arc::clone(&writer_done); - tokio::spawn(async move { - let reads = std::sync::atomic::AtomicU64::new(0); - (0..5000) - .take_while(|_| { - let is_done = done.load(std::sync::atomic::Ordering::Relaxed); - let has_reads = reads.load(std::sync::atomic::Ordering::Relaxed) > 100; - !(is_done && has_reads) - }) - .for_each(|_| { - let snapshot = committed.lock().unwrap().clone(); - if let Some((seed, expected)) = snapshot.last() { - let cid = stress_cid(*seed); - match reader.get(&cid) { - Ok(Some(actual)) => { - assert_eq!(&actual[..], &expected[..]); - reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - } - Ok(None) => {} - Err(e) => panic!("read error: {e}"), - } - } - std::thread::yield_now(); - }); - reads.load(std::sync::atomic::Ordering::Relaxed) - }) - }) - .collect(); - - futures::future::join_all(writer_handles) - .await - .into_iter() - .for_each(|r| r.unwrap()); - writer_done.store(true, std::sync::atomic::Ordering::Relaxed); - - let read_counts: Vec = futures::future::join_all(reader_handles) - .await - .into_iter() - .map(|r| r.unwrap()) - .collect(); - - let total_reads: u64 = read_counts.iter().sum(); - eprintln!("total reader reads: {total_reads}"); - assert!(total_reads > 0); - - writer.shutdown(); - - let final_committed = committed.lock().unwrap(); - assert_eq!(final_committed.len(), 10_000); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn concurrent_read_write() { - let mut harness = TestHarness::new(); - let sender = harness.sender.clone(); - let reader = harness.reader(); - - let written_cids = Arc::new(std::sync::Mutex::new(Vec::<(u8, Vec)>::new())); - let writer_done = Arc::new(std::sync::atomic::AtomicBool::new(false)); - - let writer_handle = { - let written = Arc::clone(&written_cids); - tokio::spawn(async move { - futures::stream::iter(0u8..50) - .fold((), |(), i| { - let sender = sender.clone(); - let written = Arc::clone(&written); - async move { - let cid = test_cid(i); - let data = vec![i; (i as usize + 1) * 16]; - let (tx, rx) = tokio::sync::oneshot::channel(); - sender - .send_async(CommitRequest::PutBlocks { - blocks: vec![(cid, data.clone())], - response: tx, - }) - .await - .unwrap(); - rx.await.unwrap().unwrap(); - written.lock().unwrap().push((i, data)); - } - }) - .await; - }) - }; - - let reader_handles: Vec<_> = (0..4) - .map(|_| { - let reader = reader.clone(); - let written = Arc::clone(&written_cids); - let done = Arc::clone(&writer_done); - tokio::spawn(async move { - let reads = std::sync::atomic::AtomicU64::new(0); - - (0..2000) - .take_while(|_| { - let is_done = done.load(std::sync::atomic::Ordering::Relaxed); - let has_reads = reads.load(std::sync::atomic::Ordering::Relaxed) > 0; - !(is_done && has_reads) - }) - .for_each(|_| { - let snapshot = written.lock().unwrap().clone(); - snapshot.iter().for_each(|(seed, expected_data)| { - let cid = test_cid(*seed); - match reader.get(&cid) { - Ok(Some(actual)) => { - assert_eq!( - &actual[..], - &expected_data[..], - "data mismatch for block {seed}" - ); - reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - } - Ok(None) => {} - Err(e) => panic!("read error for block {seed}: {e}"), - } - }); - std::thread::yield_now(); - }); - reads.load(std::sync::atomic::Ordering::Relaxed) - }) - }) - .collect(); - - writer_handle.await.unwrap(); - writer_done.store(true, std::sync::atomic::Ordering::Relaxed); - - let read_counts: Vec = futures::future::join_all(reader_handles) - .await - .into_iter() - .map(|r| r.unwrap()) - .collect(); - - let total_reads: u64 = read_counts.iter().sum(); - assert!( - total_reads > 0, - "readers should have completed at least some reads" - ); - - let final_snapshot = written_cids.lock().unwrap().clone(); - assert_eq!(final_snapshot.len(), 50); - - final_snapshot.iter().for_each(|(seed, expected_data)| { - let cid = test_cid(*seed); - let actual = reader.get(&cid).unwrap().unwrap(); - assert_eq!( - &actual[..], - &expected_data[..], - "final verification failed for block {seed}" - ); - }); - - harness.shutdown(); - } -} diff --git a/crates/tranquil-store/src/blockstore/store.rs b/crates/tranquil-store/src/blockstore/store.rs index cdc294b..4f18ad1 100644 --- a/crates/tranquil-store/src/blockstore/store.rs +++ b/crates/tranquil-store/src/blockstore/store.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -13,13 +14,16 @@ use sha2::{Digest, Sha256}; use crate::fsync_order::PostBlockstoreHook; use crate::io::{OpenOptions, RealIO, StorageIO}; +use super::compaction::CompactionError; use super::data_file::{BLOCK_RECORD_OVERHEAD, CID_SIZE, ReadBlockRecord}; use super::group_commit::{CommitError, CommitRequest, GroupCommitConfig, GroupCommitWriter}; -use super::hint::{rebuild_index_from_data_files, rebuild_index_from_hints}; -use super::key_index::KeyIndex; +use super::hash_index::BlockIndex; use super::manager::DataFileManager; use super::reader::{BlockStoreReader, ReadError}; -use super::types::{BlockLength, BlockLocation, BlockOffset, DataFileId, WriteCursor}; +use super::types::{ + BlockLength, BlockLocation, BlockOffset, CollectionResult, CompactionResult, DataFileId, + EpochCounter, LivenessInfo, WallClockMs, WriteCursor, +}; const DAG_CBOR_CODEC: u64 = 0x71; const SHA2_256_CODE: u64 = 0x12; @@ -47,6 +51,10 @@ fn hash_and_cid(data: &[u8]) -> Result { Ok(Cid::new_v1(DAG_CBOR_CODEC, multihash)) } +fn block_index_err_to_repo(e: super::hash_index::BlockIndexError) -> RepoError { + RepoError::storage(io::Error::other(e.to_string())) +} + fn commit_error_to_repo(e: CommitError) -> RepoError { match e { CommitError::Io(io_err) => { @@ -65,7 +73,6 @@ fn read_error_to_repo(e: ReadError) -> RepoError { ReadError::Io(io_err) => { RepoError::storage(io::Error::new(io_err.kind(), io_err.to_string())) } - ReadError::Index(idx_err) => RepoError::storage(io::Error::other(idx_err.to_string())), ReadError::Corrupted { file_id, offset } => RepoError::storage(io::Error::new( io::ErrorKind::InvalidData, format!("corrupted block at {file_id}:{}", offset.raw()), @@ -73,28 +80,74 @@ fn read_error_to_repo(e: ReadError) -> RepoError { } } +pub const DEFAULT_SHARD_COUNT: u8 = 1; + #[derive(Debug, Clone)] pub struct BlockStoreConfig { pub data_dir: PathBuf, pub index_dir: PathBuf, pub max_file_size: u64, pub group_commit: GroupCommitConfig, + pub shard_count: u8, +} + +impl BlockStoreConfig { + pub fn new(data_dir: PathBuf, index_dir: PathBuf) -> Self { + Self { + data_dir, + index_dir, + max_file_size: super::manager::DEFAULT_MAX_FILE_SIZE, + group_commit: GroupCommitConfig::default(), + shard_count: DEFAULT_SHARD_COUNT, + } + } +} + +pub struct QuiesceGuard { + resume_txs: Vec>, +} + +impl QuiesceGuard { + pub fn resume(mut self) { + self.resume_txs.drain(..).for_each(|tx| { + let _ = tx.send(()); + }); + } +} + +impl Drop for QuiesceGuard { + fn drop(&mut self) { + self.resume_txs.drain(..).for_each(|tx| { + let _ = tx.send(()); + }); + } } #[derive(Clone)] pub struct TranquilBlockStore { - sender: flume::Sender, + writer: Arc, reader: Arc>, - _writer_handle: Arc, + index: Arc, + epoch: EpochCounter, + data_dir: PathBuf, } struct WriterHandle { - writer: parking_lot::Mutex>, + inner: parking_lot::Mutex>, +} + +impl WriterHandle { + fn with(&self, f: impl FnOnce(&GroupCommitWriter) -> R) -> Result { + match self.inner.lock().as_ref() { + Some(w) => Ok(f(w)), + None => Err(CommitError::ChannelClosed), + } + } } impl Drop for WriterHandle { fn drop(&mut self) { - if let Some(w) = self.writer.lock().take() { + if let Some(w) = self.inner.lock().take() { w.shutdown(); } } @@ -118,35 +171,52 @@ impl TranquilBlockStore { std::fs::create_dir_all(&config.data_dir).map_err(RepoError::storage)?; std::fs::create_dir_all(&config.index_dir).map_err(RepoError::storage)?; + let index = BlockIndex::open(&config.index_dir).map_err(RepoError::storage)?; + let io = RealIO::new(); - let outcome = KeyIndex::open(&config.index_dir) - .map_err(|e| RepoError::storage(io::Error::other(e.to_string())))?; - let needs_full_rebuild = outcome.needs_rebuild(); - let index = Arc::new(outcome.into_inner()); + let (replayed, file_cursors) = super::hint::replay_hints_into_block_index( + &io, + &config.data_dir, + &index, + index.loaded_checkpoint_positions(), + ) + .map_err(|e| RepoError::storage(io::Error::other(e.to_string())))?; - if needs_full_rebuild { - tracing::warn!("fjall index corrupt or missing, rebuilding from hints/data files"); - Self::rebuild_index(&io, &config.data_dir, &index)?; - } else { - Self::recover_from_cursor(&io, &config.data_dir, &index)?; + if replayed > 0 { + tracing::info!(replayed, "replayed hint records after checkpoint"); } - let manager_for_writer = - DataFileManager::new(RealIO::new(), config.data_dir.clone(), config.max_file_size); - let writer = GroupCommitWriter::spawn_with_hook( - manager_for_writer, + Self::recover_from_file_cursors(&io, &config.data_dir, &index, &file_cursors)?; + + let index = Arc::new(index); + + let data_dir = config.data_dir; + let max_file_size = config.max_file_size; + let shard_count = config.shard_count; + let data_dir_for_closure = data_dir.clone(); + let make_manager = move || { + DataFileManager::new(RealIO::new(), data_dir_for_closure.clone(), max_file_size) + }; + + let checkpoint_epoch = index.loaded_checkpoint_epoch(); + let checkpoint_positions = index.loaded_checkpoint_positions(); + let writer = GroupCommitWriter::spawn_sharded( + make_manager, Arc::clone(&index), config.group_commit, post_sync_hook, + checkpoint_epoch, + shard_count, + checkpoint_positions, ) .map_err(commit_error_to_repo)?; - let sender = writer.sender().clone(); + let epoch = writer.epoch().clone(); let manager_for_reader = Arc::new(DataFileManager::new( RealIO::new(), - config.data_dir, - config.max_file_size, + data_dir.clone(), + max_file_size, )); let reader = Arc::new(BlockStoreReader::new( Arc::clone(&index), @@ -154,98 +224,42 @@ impl TranquilBlockStore { )); Ok(Self { - sender, - reader, - _writer_handle: Arc::new(WriterHandle { - writer: parking_lot::Mutex::new(Some(writer)), + writer: Arc::new(WriterHandle { + inner: parking_lot::Mutex::new(Some(writer)), }), + reader, + index, + epoch, + data_dir, }) } - fn rebuild_index( + fn recover_from_file_cursors( io: &S, data_dir: &Path, - index: &KeyIndex, + index: &BlockIndex, + file_cursors: &HashMap, ) -> Result<(), RepoError> { - match rebuild_index_from_hints(io, data_dir, index) { - Ok(()) => { - tracing::info!("index rebuilt from hint files"); - Ok(()) - } - Err(hint_err) => { - tracing::warn!( - error = %hint_err, - "hint-based rebuild failed, falling back to data file scan" - ); - rebuild_index_from_data_files(io, data_dir, index) - .map_err(|e| RepoError::storage(io::Error::other(e.to_string())))?; - tracing::info!("index rebuilt from data files"); - Ok(()) - } - } - } - - fn recover_from_cursor( - io: &S, - data_dir: &Path, - index: &KeyIndex, - ) -> Result<(), RepoError> { - let map_idx = |e: super::key_index::KeyIndexError| { - RepoError::storage(io::Error::other(e.to_string())) - }; - - let cursor = index.read_write_cursor().map_err(map_idx)?; - let all_data_files = super::list_files_by_extension(io, data_dir, super::manager::DATA_FILE_EXTENSION) .map_err(RepoError::storage)?; - match cursor { - None if !all_data_files.is_empty() => { - tracing::warn!("no write cursor but data files exist, rebuilding index"); - Self::rebuild_index(io, data_dir, index) - } - None => Ok(()), - Some(wc) => { - tracing::info!( - cursor_file = %wc.file_id, - cursor_offset = wc.offset.raw(), - "starting recovery from write cursor" - ); - Self::replay_single_file(io, data_dir, index, wc.file_id, wc.offset)?; - - let orphan_count = all_data_files - .iter() - .filter(|&&fid| fid > wc.file_id) - .count(); - if orphan_count > 0 { - tracing::info!( - orphan_files = orphan_count, - "scanning data files past cursor for un-indexed blocks" - ); - } - - all_data_files - .iter() - .copied() - .filter(|&fid| fid > wc.file_id) - .try_for_each(|fid| { - Self::replay_single_file( - io, - data_dir, - index, - fid, - BlockOffset::new(super::data_file::BLOCK_HEADER_SIZE as u64), - ) - }) - } + if all_data_files.is_empty() { + return Ok(()); } + + let header_start = BlockOffset::new(super::data_file::BLOCK_HEADER_SIZE as u64); + + all_data_files.iter().try_for_each(|&fid| { + let start_offset = file_cursors.get(&fid).copied().unwrap_or(header_start); + Self::replay_single_file(io, data_dir, index, fid, start_offset) + }) } fn replay_single_file( io: &S, data_dir: &Path, - index: &KeyIndex, + index: &BlockIndex, file_id: DataFileId, start_offset: BlockOffset, ) -> Result<(), RepoError> { @@ -272,15 +286,11 @@ impl TranquilBlockStore { fn scan_and_index( io: &S, - index: &KeyIndex, + index: &BlockIndex, fd: crate::io::FileId, file_id: DataFileId, start_offset: BlockOffset, ) -> Result<(), RepoError> { - let map_idx = |e: super::key_index::KeyIndexError| { - RepoError::storage(io::Error::other(e.to_string())) - }; - let file_size = io.file_size(fd).map_err(RepoError::storage)?; if file_size <= start_offset.raw() { @@ -354,20 +364,145 @@ impl TranquilBlockStore { file_id, offset: last_valid_end, }; + let inserted = index + .batch_put_if_absent(&recovered_entries, new_cursor) + .map_err(block_index_err_to_repo)?; tracing::info!( file_id = %file_id, - recovered = recovered_entries.len(), + scanned = recovered_entries.len(), + inserted, new_cursor_offset = last_valid_end.raw(), - "replayed un-indexed blocks past write cursor" + "recovery data file scan" ); - index - .batch_put(&recovered_entries, &[], new_cursor) - .map_err(map_idx)?; } Ok(()) } + pub fn epoch(&self) -> &EpochCounter { + &self.epoch + } + + pub fn data_dir(&self) -> &Path { + &self.data_dir + } + + pub fn data_file_path(&self, file_id: DataFileId) -> PathBuf { + self.reader.manager().data_file_path(file_id) + } + + pub fn quiesce(&self) -> Result<(super::types::BlockstoreSnapshot, QuiesceGuard), CommitError> { + let (snapshot, resumes) = self.writer.with(|w| w.quiesce_all())??; + Ok(( + snapshot, + QuiesceGuard { + resume_txs: resumes, + }, + )) + } + + pub fn collect_dead_blocks(&self, grace_period_ms: u64) -> Result { + let current_epoch = self.epoch.current(); + let now = WallClockMs::now(); + Ok(self + .index + .collect_dead_blocks(current_epoch, now, grace_period_ms)) + } + + pub fn compact_file( + &self, + file_id: DataFileId, + grace_period_ms: u64, + ) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel(); + let sender = self + .writer + .with(|w| w.sender_round_robin().clone()) + .map_err(|_| CompactionError::ChannelClosed)?; + sender + .send(CommitRequest::Compact { + file_id, + grace_period_ms, + response: tx, + }) + .map_err(|_| CompactionError::ChannelClosed)?; + let result = rx + .blocking_recv() + .map_err(|_| CompactionError::ChannelClosed)?; + if result.is_ok() { + self.reader.manager().evict_handle(file_id); + } + result + } + + pub fn compaction_liveness( + &self, + grace_period_ms: u64, + ) -> Result, RepoError> { + let current_epoch = self.epoch.current(); + let now = WallClockMs::now(); + Ok(self + .index + .liveness_by_file(current_epoch, now, grace_period_ms)) + } + + pub fn cleanup_gc_meta(&self) -> Result { + Ok(self.index.cleanup_stale_gc_meta()) + } + + pub fn liveness_info(&self, file_id: DataFileId) -> Result { + Ok(self.index.liveness_info(file_id)) + } + + pub fn approximate_block_count(&self) -> u64 { + self.index.approximate_block_count() + } + + pub fn block_index(&self) -> &Arc { + &self.index + } + + pub fn find_leaked_refcounts( + &self, + is_reachable: impl Fn(&super::types::CidBytes) -> bool, + ) -> Result<(Vec<(super::types::CidBytes, super::types::RefCount)>, u64), RepoError> { + Ok(self.index.find_leaked_refcounts(is_reachable)) + } + + pub fn repair_leaked_refcounts( + &self, + leaked_cids: &[(super::types::CidBytes, super::types::RefCount)], + ) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel(); + let sender = self + .writer + .with(|w| w.sender_round_robin().clone()) + .map_err(commit_error_to_repo)?; + sender + .send(CommitRequest::RepairLeaked { + leaked_cids: leaked_cids.to_vec(), + response: tx, + }) + .map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))?; + rx.blocking_recv() + .map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))? + .map_err(commit_error_to_repo) + } + + pub fn get_block_sync( + &self, + cid_bytes: &[u8; CID_SIZE], + ) -> Result, RepoError> { + self.reader.get(cid_bytes).map_err(read_error_to_repo) + } + + pub fn list_data_files(&self) -> Result, RepoError> { + self.reader + .manager() + .list_files() + .map_err(RepoError::storage) + } + pub fn put_blocks_blocking( &self, blocks: Vec<([u8; CID_SIZE], Vec)>, @@ -375,8 +510,12 @@ impl TranquilBlockStore { if blocks.is_empty() { return Ok(()); } + let sender = self + .writer + .with(|w| w.sender_for_blocks(&blocks).clone()) + .map_err(commit_error_to_repo)?; let (tx, rx) = tokio::sync::oneshot::channel(); - self.sender + sender .send(CommitRequest::PutBlocks { blocks, response: tx, @@ -388,12 +527,38 @@ impl TranquilBlockStore { Ok(()) } + pub fn apply_commit_blocking( + &self, + blocks: Vec<([u8; CID_SIZE], Vec)>, + deleted_cids: Vec<[u8; CID_SIZE]>, + ) -> Result<(), RepoError> { + let sender = self + .writer + .with(|w| w.sender_for_apply(&blocks, &deleted_cids).clone()) + .map_err(commit_error_to_repo)?; + let (tx, rx) = tokio::sync::oneshot::channel(); + sender + .send(CommitRequest::ApplyCommit { + blocks, + deleted_cids, + response: tx, + }) + .map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))?; + rx.blocking_recv() + .map_err(|_| commit_error_to_repo(CommitError::ChannelClosed))? + .map_err(commit_error_to_repo) + } + async fn send_put_blocks( &self, blocks: Vec<([u8; CID_SIZE], Vec)>, ) -> Result, RepoError> { + let sender = self + .writer + .with(|w| w.sender_for_blocks(&blocks).clone()) + .map_err(commit_error_to_repo)?; let (tx, rx) = tokio::sync::oneshot::channel(); - self.sender + sender .send_async(CommitRequest::PutBlocks { blocks, response: tx, @@ -410,8 +575,12 @@ impl TranquilBlockStore { blocks: Vec<([u8; CID_SIZE], Vec)>, deleted_cids: Vec<[u8; CID_SIZE]>, ) -> Result<(), RepoError> { + let sender = self + .writer + .with(|w| w.sender_for_apply(&blocks, &deleted_cids).clone()) + .map_err(commit_error_to_repo)?; let (tx, rx) = tokio::sync::oneshot::channel(); - self.sender + sender .send_async(CommitRequest::ApplyCommit { blocks, deleted_cids, @@ -497,701 +666,20 @@ impl BlockStore for TranquilBlockStore { } } -#[cfg(test)] -mod tests { - use super::super::manager::DEFAULT_MAX_FILE_SIZE; - use super::*; - - fn test_config(dir: &Path) -> BlockStoreConfig { - BlockStoreConfig { - data_dir: dir.join("data"), - index_dir: dir.join("index"), - max_file_size: DEFAULT_MAX_FILE_SIZE, - group_commit: GroupCommitConfig::default(), +impl TranquilBlockStore { + pub async fn decrement_refs(&self, cids: &[Cid]) -> Result<(), RepoError> { + if cids.is_empty() { + return Ok(()); } - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn put_and_get_round_trips() { - let dir = tempfile::TempDir::new().unwrap(); - let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); - - let data = b"hello blockstore"; - let cid = store.put(data).await.unwrap(); - - let retrieved = store.get(&cid).await.unwrap().unwrap(); - assert_eq!(&retrieved[..], data); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn get_missing_returns_none() { - let dir = tempfile::TempDir::new().unwrap(); - let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); - - let fake_cid = hash_and_cid(b"nonexistent").unwrap(); - assert!(store.get(&fake_cid).await.unwrap().is_none()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn has_returns_correct_values() { - let dir = tempfile::TempDir::new().unwrap(); - let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); - - let data = b"existence check"; - let cid = store.put(data).await.unwrap(); - - assert!(store.has(&cid).await.unwrap()); - - let fake_cid = hash_and_cid(b"does not exist").unwrap(); - assert!(!store.has(&fake_cid).await.unwrap()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn put_many_and_get_many() { - let dir = tempfile::TempDir::new().unwrap(); - let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); - - let items: Vec<(Cid, Bytes)> = (0u8..10) - .map(|i| { - let data = vec![i; (i as usize + 1) * 32]; - let cid = hash_and_cid(&data).unwrap(); - (cid, Bytes::from(data)) - }) - .collect(); - - let cids: Vec = items.iter().map(|(c, _)| *c).collect(); - let expected: Vec = items.iter().map(|(_, d)| d.clone()).collect(); - - store.put_many(items).await.unwrap(); - - let results = store.get_many(&cids).await.unwrap(); - assert_eq!(results.len(), 10); - results + let deleted_cids: Vec<[u8; CID_SIZE]> = cids .iter() - .zip(expected.iter()) - .for_each(|(result, exp)| { - assert_eq!(result.as_ref().unwrap().as_ref(), exp.as_ref()); - }); + .map(cid_to_bytes) + .collect::, _>>()?; + self.send_apply_commit(Vec::new(), deleted_cids).await } - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn get_many_with_mixed_hits_and_misses() { - let dir = tempfile::TempDir::new().unwrap(); - let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); - - let data_a = b"block a"; - let data_b = b"block b"; - let cid_a = store.put(data_a).await.unwrap(); - let cid_b = store.put(data_b).await.unwrap(); - let cid_missing = hash_and_cid(b"missing").unwrap(); - - let results = store.get_many(&[cid_a, cid_missing, cid_b]).await.unwrap(); - assert_eq!(results.len(), 3); - assert_eq!(results[0].as_ref().unwrap().as_ref(), data_a); - assert!(results[1].is_none()); - assert_eq!(results[2].as_ref().unwrap().as_ref(), data_b); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn put_many_empty_is_noop() { - let dir = tempfile::TempDir::new().unwrap(); - let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); - store - .put_many(std::iter::empty::<(Cid, Bytes)>()) - .await - .unwrap(); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn apply_commit_with_blocks_and_deletes() { - use jacquard_common::types::integer::LimitedU32; - use jacquard_common::types::string::Tid; - use std::collections::BTreeMap; - - let dir = tempfile::TempDir::new().unwrap(); - let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); - - let data_keep = b"keep this block"; - let data_delete = b"delete this block"; - let cid_keep = store.put(data_keep).await.unwrap(); - let cid_delete = store.put(data_delete).await.unwrap(); - - assert!(store.has(&cid_keep).await.unwrap()); - assert!(store.has(&cid_delete).await.unwrap()); - - let new_data = b"new block from commit"; - let new_cid = hash_and_cid(new_data).unwrap(); - - let mut blocks = BTreeMap::new(); - blocks.insert(new_cid, Bytes::from(new_data.as_slice())); - - let commit = CommitData { - cid: new_cid, - rev: Tid::now(LimitedU32::MIN), - since: None, - prev: None, - data: new_cid, - prev_data: None, - blocks, - relevant_blocks: BTreeMap::new(), - deleted_cids: vec![cid_delete], - }; - - store.apply_commit(commit).await.unwrap(); - - assert!(store.has(&cid_keep).await.unwrap()); - assert!(store.has(&new_cid).await.unwrap()); - let new_retrieved = store.get(&new_cid).await.unwrap().unwrap(); - assert_eq!(&new_retrieved[..], new_data); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn duplicate_put_returns_same_cid() { - let dir = tempfile::TempDir::new().unwrap(); - let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); - - let data = b"identical content"; - let cid1 = store.put(data).await.unwrap(); - let cid2 = store.put(data).await.unwrap(); - - assert_eq!(cid1, cid2); - - let retrieved = store.get(&cid1).await.unwrap().unwrap(); - assert_eq!(&retrieved[..], data); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn reopen_preserves_data() { - let dir = tempfile::TempDir::new().unwrap(); - let config = test_config(dir.path()); - - let cid = { - let store = TranquilBlockStore::open(config.clone()).unwrap(); - let data = b"persistent data"; - let cid = store.put(data).await.unwrap(); - assert!(store.has(&cid).await.unwrap()); - drop(store); - cid - }; - - { - let store = TranquilBlockStore::open(config).unwrap(); - let retrieved = store.get(&cid).await.unwrap().unwrap(); - assert_eq!(&retrieved[..], b"persistent data"); - } - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn concurrent_puts_from_multiple_tasks() { - let dir = tempfile::TempDir::new().unwrap(); - let store = TranquilBlockStore::open(test_config(dir.path())).unwrap(); - - let handles: Vec<_> = (0u8..50) - .map(|i| { - let store = store.clone(); - tokio::spawn(async move { - let data = vec![i; (i as usize + 1) * 16]; - let cid = store.put(&data).await.unwrap(); - (cid, data) - }) - }) - .collect(); - - let results: Vec<(Cid, Vec)> = futures::future::join_all(handles) - .await - .into_iter() - .map(|r| r.unwrap()) - .collect(); - - let verify_handles: Vec<_> = results - .into_iter() - .map(|(cid, expected)| { - let store = store.clone(); - tokio::spawn(async move { - let retrieved = store.get(&cid).await.unwrap().unwrap(); - assert_eq!(&retrieved[..], &expected[..]); - }) - }) - .collect(); - - futures::future::join_all(verify_handles) - .await - .into_iter() - .for_each(|r| r.unwrap()); - } - - mod sim { - use super::*; - use crate::SimulatedIO; - use crate::blockstore::data_file::{BLOCK_RECORD_OVERHEAD, CID_SIZE, DataFileWriter}; - use crate::blockstore::hint::{HintFileWriter, hint_file_path}; - use crate::blockstore::key_index::KeyIndex; - use crate::blockstore::manager::DataFileManager; - use crate::blockstore::reader::BlockStoreReader; - use crate::blockstore::types::{BlockOffset, DataFileId, WriteCursor}; - use futures::StreamExt; - use std::path::Path; - use std::sync::Arc; - - use crate::blockstore::test_cid_u16 as sim_test_cid; - - struct SimHarness { - sim: Arc, - data_dir: &'static Path, - index_dir: tempfile::TempDir, - } - - impl SimHarness { - fn new(seed: u64) -> Self { - let sim = Arc::new(SimulatedIO::pristine(seed)); - let data_dir = Path::new("/data"); - sim.mkdir(data_dir).unwrap(); - sim.sync_dir(data_dir).unwrap(); - Self { - sim, - data_dir, - index_dir: tempfile::TempDir::new().unwrap(), - } - } - - fn fresh_index_dir(&mut self) { - self.index_dir = tempfile::TempDir::new().unwrap(); - } - - fn open_index(&self) -> KeyIndex { - KeyIndex::open(self.index_dir.path()).unwrap().into_inner() - } - - fn ensure_data_file(&self, file_id: DataFileId) -> BlockOffset { - let manager = DataFileManager::with_default_max_size( - Arc::clone(&self.sim), - self.data_dir.to_path_buf(), - ); - let fd = manager.open_for_append(file_id).unwrap(); - let file_size = self.sim.file_size(fd).unwrap(); - match file_size { - 0 => { - let w = DataFileWriter::new(&*self.sim, fd, file_id).unwrap(); - w.sync().unwrap(); - self.sim.sync_dir(self.data_dir).unwrap(); - w.position() - } - n => BlockOffset::new(n), - } - } - - fn write_blocks( - &self, - file_id: DataFileId, - start_pos: BlockOffset, - seeds: std::ops::Range, - data_size: usize, - sync: bool, - ) -> (BlockOffset, Vec<([u8; CID_SIZE], BlockLocation)>) { - let path = self.data_dir.join(format!( - "{file_id}.{}", - crate::blockstore::manager::DATA_FILE_EXTENSION - )); - let fd = self - .sim - .open(&path, crate::io::OpenOptions::read_write()) - .unwrap(); - let mut writer = DataFileWriter::resume(&*self.sim, fd, file_id, start_pos); - - let hint_path = hint_file_path(self.data_dir, file_id); - let hint_fd = self - .sim - .open(&hint_path, crate::io::OpenOptions::read_write()) - .unwrap(); - let hint_size = self.sim.file_size(hint_fd).unwrap(); - let mut hint_writer = HintFileWriter::resume( - &*self.sim, - hint_fd, - crate::blockstore::types::HintOffset::new(hint_size), - ); - - let entries: Vec<_> = seeds - .map(|seed| { - let cid = sim_test_cid(seed); - let data = vec![seed as u8; data_size]; - let loc = writer.append_block(&cid, &data).unwrap(); - hint_writer - .append_hint(&cid, loc.file_id, loc.offset, loc.length) - .unwrap(); - (cid, loc) - }) - .collect(); - - if sync { - writer.sync().unwrap(); - hint_writer.sync().unwrap(); - self.sim.sync_dir(self.data_dir).unwrap(); - } - - let pos = writer.position(); - let _ = self.sim.close(hint_fd); - let _ = self.sim.close(fd); - (pos, entries) - } - - fn index_entries( - &self, - index: &KeyIndex, - entries: &[([u8; CID_SIZE], BlockLocation)], - cursor: WriteCursor, - ) { - index.batch_put(entries, &[], cursor).unwrap(); - index.persist().unwrap(); - } - - fn make_reader(&self, index: Arc) -> BlockStoreReader> { - let manager = Arc::new(DataFileManager::with_default_max_size( - Arc::clone(&self.sim), - self.data_dir.to_path_buf(), - )); - BlockStoreReader::new(index, manager) - } - - fn recover(&self, index: &KeyIndex) { - TranquilBlockStore::recover_from_cursor(&*self.sim, self.data_dir, index).unwrap(); - } - - fn rebuild(&self, index: &KeyIndex) { - TranquilBlockStore::rebuild_index(&*self.sim, self.data_dir, index).unwrap(); - } - } - - #[test] - fn sim_crash_and_recover_blocks() { - (0u64..200).for_each(|seed| { - let h = SimHarness::new(seed); - let file_id = DataFileId::new(0); - - let total_blocks = ((seed % 47) + 10) as u16; - let indexed_count = ((seed % total_blocks as u64) + 1) as u16; - let unsynced_start = total_blocks; - let unsynced_count = ((seed % 5) + 1) as u16; - - let start_pos = h.ensure_data_file(file_id); - let (synced_end, entries) = - h.write_blocks(file_id, start_pos, 0..total_blocks, 64, true); - - let index = h.open_index(); - let indexed = &entries[..indexed_count as usize]; - let cursor_end = indexed - .last() - .map(|(_, loc)| { - loc.offset - .advance(BLOCK_RECORD_OVERHEAD as u64 + loc.length.as_u64()) - }) - .unwrap_or(start_pos); - h.index_entries( - &index, - indexed, - WriteCursor { - file_id, - offset: cursor_end, - }, - ); - index.persist().unwrap(); - drop(index); - - let _ = h.write_blocks( - file_id, - synced_end, - unsynced_start..unsynced_start + unsynced_count, - 64, - false, - ); - - h.sim.crash(); - - let recovered_index = h.open_index(); - h.recover(&recovered_index); - - let idx = Arc::new(recovered_index); - let reader = h.make_reader(Arc::clone(&idx)); - - (0..total_blocks).for_each(|i| { - let cid = sim_test_cid(i); - let entry = idx.get(&cid).unwrap(); - assert!( - entry.is_some(), - "seed={seed} synced block {i}/{total_blocks} missing, indexed={indexed_count}" - ); - match reader.get(&cid) { - Ok(Some(actual)) => { - assert_eq!( - actual.len(), - 64, - "seed={seed} block {i} wrong length" - ); - assert_eq!( - actual[0], - i as u8, - "seed={seed} block {i} data mismatch" - ); - } - other => panic!( - "seed={seed} block {i} expected readable, got {other:?}" - ), - } - }); - - (unsynced_start..unsynced_start + unsynced_count).for_each(|i| { - let cid = sim_test_cid(i); - assert!( - idx.get(&cid).unwrap().is_none(), - "seed={seed} unsynced block {i} should not appear in index" - ); - }); - }); - } - - #[test] - fn sim_refcounts_and_deletes() { - (0u64..100).for_each(|seed| { - let h = SimHarness::new(seed); - let file_id = DataFileId::new(0); - let start_pos = h.ensure_data_file(file_id); - - let dup_count = (seed % 5) as u32 + 2; - let data_size = ((seed % 7) as usize + 1) * 32; - - let dup_cid = sim_test_cid(0); - let dup_data = vec![0u8; data_size]; - let unique_cid = sim_test_cid(1); - let unique_data = vec![1u8; data_size]; - - let path = h.data_dir.join(format!( - "{file_id}.{}", - crate::blockstore::manager::DATA_FILE_EXTENSION - )); - let fd = h - .sim - .open(&path, crate::io::OpenOptions::read_write()) - .unwrap(); - let mut writer = DataFileWriter::resume(&*h.sim, fd, file_id, start_pos); - - let loc_dup = writer.append_block(&dup_cid, &dup_data).unwrap(); - let loc_unique = writer.append_block(&unique_cid, &unique_data).unwrap(); - writer.sync().unwrap(); - h.sim.sync_dir(h.data_dir).unwrap(); - let end_pos = writer.position(); - let _ = h.sim.close(fd); - - let index = h.open_index(); - - let mut entries: Vec<_> = (0..dup_count).map(|_| (dup_cid, loc_dup)).collect(); - entries.push((unique_cid, loc_unique)); - h.index_entries( - &index, - &entries, - WriteCursor { - file_id, - offset: end_pos, - }, - ); - - let dup_entry = index.get(&dup_cid).unwrap().unwrap(); - assert_eq!( - dup_entry.refcount.raw(), - dup_count, - "seed={seed} expected refcount {dup_count}" - ); - let unique_entry = index.get(&unique_cid).unwrap().unwrap(); - assert_eq!(unique_entry.refcount.raw(), 1); - - let dec_count = (seed % dup_count as u64) as u32 + 1; - let decrements: Vec<_> = (0..dec_count).map(|_| dup_cid).collect(); - index - .batch_put( - &[], - &decrements, - WriteCursor { - file_id, - offset: end_pos, - }, - ) - .unwrap(); - - let dup_after = index.get(&dup_cid).unwrap().unwrap(); - assert_eq!( - dup_after.refcount.raw(), - dup_count - dec_count, - "seed={seed} refcount after {dec_count} decrements" - ); - - let idx = Arc::new(index); - let reader = h.make_reader(Arc::clone(&idx)); - - let dup_read = reader.get(&dup_cid).unwrap().unwrap(); - assert_eq!(&dup_read[..], &dup_data[..], "seed={seed}"); - let unique_read = reader.get(&unique_cid).unwrap().unwrap(); - assert_eq!(&unique_read[..], &unique_data[..], "seed={seed}"); - - drop(reader); - let index = Arc::into_inner(idx).unwrap(); - - let remaining = dup_count - dec_count; - let final_decrements: Vec<_> = (0..remaining).map(|_| dup_cid).collect(); - index - .batch_put( - &[], - &final_decrements, - WriteCursor { - file_id, - offset: end_pos, - }, - ) - .unwrap(); - - let dup_zero = index.get(&dup_cid).unwrap().unwrap(); - assert!( - dup_zero.refcount.is_zero(), - "seed={seed} expected zero refcount" - ); - - let idx = Arc::new(index); - let reader = h.make_reader(idx); - let still_readable = reader.get(&dup_cid).unwrap(); - assert!( - still_readable.is_some(), - "seed={seed} zero-refcount block should still be readable, GC not implemented" - ); - }); - } - - #[test] - fn sim_repeated_crash_recover_cycles() { - (0u64..150).for_each(|seed| { - let mut h = SimHarness::new(seed); - let file_id = DataFileId::new(0); - let mut next_seed: u16 = 0; - let mut all_committed: Vec = Vec::new(); - - let cycles = (seed % 4) + 2; - (0..cycles).for_each(|cycle| { - let start_pos = h.ensure_data_file(file_id); - - let count = ((seed.wrapping_add(cycle)) % 15 + 3) as u16; - let range = next_seed..next_seed + count; - let (end_pos, entries) = h.write_blocks(file_id, start_pos, range, 48, true); - next_seed += count; - - let index = h.open_index(); - h.index_entries( - &index, - &entries, - WriteCursor { - file_id, - offset: end_pos, - }, - ); - drop(index); - - all_committed.extend( - entries - .iter() - .map(|(cid, _)| u16::from_le_bytes([cid[4], cid[5]])), - ); - - let unsynced = ((seed.wrapping_add(cycle)) % 3 + 1) as u16; - let _ = h.write_blocks( - file_id, - end_pos, - next_seed + 1000..next_seed + 1000 + unsynced, - 48, - false, - ); - - h.sim.crash(); - h.fresh_index_dir(); - - let rebuilt = h.open_index(); - h.rebuild(&rebuilt); - - all_committed.iter().for_each(|&s| { - let cid = sim_test_cid(s); - assert!( - rebuilt.has(&cid).unwrap(), - "seed={seed} cycle={cycle} block {s} lost after rebuild" - ); - }); - - let idx = Arc::new(rebuilt); - let reader = h.make_reader(Arc::clone(&idx)); - all_committed.iter().for_each(|&s| { - let cid = sim_test_cid(s); - match reader.get(&cid) { - Ok(Some(data)) => { - assert_eq!(data.len(), 48); - assert_eq!(data[0], s as u8); - } - other => panic!("seed={seed} cycle={cycle} block {s}: {other:?}"), - } - }); - - drop(reader); - drop(idx); - }); - }); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn corrupt_fjall_triggers_rebuild_from_hints() { - let dir = tempfile::TempDir::new().unwrap(); - let config = test_config(dir.path()); - - let cids: Vec = { - let store = TranquilBlockStore::open(config.clone()).unwrap(); - let cids = futures::stream::iter(0u8..20) - .fold(Vec::new(), |mut acc, i| { - let store = store.clone(); - async move { - let data = vec![i; (i as usize + 1) * 16]; - acc.push(store.put(&data).await.unwrap()); - acc - } - }) - .await; - drop(store); - cids - }; - - fn corrupt_dir_recursive(dir: &Path) { - std::fs::read_dir(dir) - .unwrap() - .filter_map(|e| e.ok()) - .for_each(|entry| { - let path = entry.path(); - if path.is_file() { - std::fs::write(&path, b"corrupted").unwrap(); - } else if path.is_dir() { - corrupt_dir_recursive(&path); - } - }); - } - corrupt_dir_recursive(&config.index_dir); - - let store = TranquilBlockStore::open(config).unwrap(); - - futures::stream::iter(cids.iter()) - .fold((), |(), cid| { - let store = store.clone(); - let cid = *cid; - async move { - assert!( - store.has(&cid).await.unwrap(), - "block {cid} should be accessible after fjall rebuild" - ); - assert!( - store.get(&cid).await.unwrap().is_some(), - "block {cid} should be readable after fjall rebuild" - ); - } - }) - .await; - } + pub fn refcount_of(&self, cid: &Cid) -> Result, RepoError> { + let cid_bytes = cid_to_bytes(cid)?; + Ok(self.index.get(&cid_bytes).map(|entry| entry.refcount.raw())) } } diff --git a/crates/tranquil-store/src/blockstore/types.rs b/crates/tranquil-store/src/blockstore/types.rs index 63feef6..8e6178f 100644 --- a/crates/tranquil-store/src/blockstore/types.rs +++ b/crates/tranquil-store/src/blockstore/types.rs @@ -1,10 +1,104 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + use serde::{Deserialize, Serialize}; +use super::data_file::CID_SIZE; + +pub type CidBytes = [u8; CID_SIZE]; + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[repr(transparent)] +pub struct CommitEpoch(u64); + +impl CommitEpoch { + pub const fn new(value: u64) -> Self { + Self(value) + } + + pub const fn zero() -> Self { + Self(0) + } + + pub fn raw(self) -> u64 { + self.0 + } + + pub fn next(self) -> Self { + Self(self.0.saturating_add(1)) + } +} + +#[derive(Debug, Clone)] +pub struct EpochCounter(Arc); + +impl Default for EpochCounter { + fn default() -> Self { + Self(Arc::new(AtomicU64::new(0))) + } +} + +impl EpochCounter { + pub fn new() -> Self { + Self::default() + } + + pub fn from_raw(value: u64) -> Self { + Self(Arc::new(AtomicU64::new(value))) + } + + pub fn current(&self) -> CommitEpoch { + CommitEpoch(self.0.load(Ordering::Acquire)) + } + + pub fn advance(&self) -> CommitEpoch { + let prev = self + .0 + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |v| { + Some(v.saturating_add(1)) + }) + .unwrap_or(u64::MAX); + CommitEpoch(prev.saturating_add(1)) + } +} + +pub struct CollectionResult { + pub candidates: HashMap>, + pub total_bytes: u64, +} + +pub struct CompactionResult { + pub file_id: DataFileId, + pub old_size: u64, + pub new_size: u64, + pub live_blocks: u64, + pub dead_blocks: u64, + pub reclaimed_bytes: u64, +} + +pub struct LivenessInfo { + pub live_bytes: u64, + pub total_bytes: u64, + pub live_blocks: u64, + pub total_blocks: u64, +} + +impl LivenessInfo { + pub fn ratio(&self) -> f64 { + match self.total_bytes { + 0 => 1.0, + total => self.live_bytes as f64 / total as f64, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[repr(transparent)] pub struct DataFileId(u32); impl DataFileId { - pub fn new(id: u32) -> Self { + pub const fn new(id: u32) -> Self { Self(id) } @@ -24,10 +118,11 @@ impl std::fmt::Display for DataFileId { } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[repr(transparent)] pub struct BlockOffset(u64); impl BlockOffset { - pub fn new(offset: u64) -> Self { + pub const fn new(offset: u64) -> Self { Self(offset) } @@ -43,6 +138,7 @@ impl BlockOffset { pub const MAX_BLOCK_SIZE: u32 = 4 * 1024 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[repr(transparent)] pub struct BlockLength(u32); impl BlockLength { @@ -54,6 +150,10 @@ impl BlockLength { Self(length) } + pub const fn from_raw(length: u32) -> Self { + Self(length) + } + pub fn raw(self) -> u32 { self.0 } @@ -64,10 +164,11 @@ impl BlockLength { } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[repr(transparent)] pub struct RefCount(u32); impl RefCount { - pub fn new(count: u32) -> Self { + pub const fn new(count: u32) -> Self { Self(count) } @@ -75,7 +176,7 @@ impl RefCount { self.0 } - pub fn one() -> Self { + pub const fn one() -> Self { Self(1) } @@ -87,6 +188,10 @@ impl RefCount { Self(self.0.checked_add(1).expect("RefCount overflow")) } + pub fn saturating_increment(self) -> Self { + Self(self.0.saturating_add(1)) + } + pub fn decrement(self) -> Self { Self(self.0.saturating_sub(1)) } @@ -113,6 +218,7 @@ pub struct WriteCursor { } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(transparent)] pub struct HintOffset(u64); impl HintOffset { @@ -129,10 +235,86 @@ impl HintOffset { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[repr(transparent)] +pub struct WallClockMs(u64); + +impl WallClockMs { + pub const fn new(ms: u64) -> Self { + Self(ms) + } + + pub fn now() -> Self { + let millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + Self(u64::try_from(millis).unwrap_or(u64::MAX)) + } + + pub fn raw(self) -> u64 { + self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct ShardId(u8); + +impl ShardId { + pub const fn new(id: u8) -> Self { + Self(id) + } + + pub fn raw(self) -> u8 { + self.0 + } + + pub fn as_usize(self) -> usize { + self.0 as usize + } +} + +impl std::fmt::Display for ShardId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "shard_{}", self.0) + } +} + +pub struct BlockstoreSnapshot { + pub shard_cursors: Vec, + pub epoch: CommitEpoch, + pub data_files: Vec, +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn commit_epoch_advances() { + let e = CommitEpoch::zero(); + assert_eq!(e.raw(), 0); + assert_eq!(e.next().raw(), 1); + } + + #[test] + fn commit_epoch_saturates() { + let e = CommitEpoch::new(u64::MAX); + assert_eq!(e.next().raw(), u64::MAX); + } + + #[test] + fn epoch_counter_advance_returns_new_value() { + let counter = EpochCounter::new(); + assert_eq!(counter.current().raw(), 0); + let epoch1 = counter.advance(); + assert_eq!(epoch1.raw(), 1); + assert_eq!(counter.current().raw(), 1); + let epoch2 = counter.advance(); + assert_eq!(epoch2.raw(), 2); + } + #[test] fn index_entry_postcard_round_trip() { let entry = IndexEntry { diff --git a/crates/tranquil-store/src/bloom.rs b/crates/tranquil-store/src/bloom.rs new file mode 100644 index 0000000..504915a --- /dev/null +++ b/crates/tranquil-store/src/bloom.rs @@ -0,0 +1,134 @@ +use xxhash_rust::xxh3::xxh3_64_with_seed; + +pub struct BloomFilter { + bits: Vec, + num_bits: u64, + num_hashes: u32, +} + +impl BloomFilter { + const MAX_BITS: u64 = 1 << 34; + + pub fn with_capacity_and_fpr(expected_items: u64, false_positive_rate: f64) -> Self { + debug_assert!( + false_positive_rate > 0.0 && false_positive_rate < 1.0, + "false_positive_rate must be in (0, 1), got {false_positive_rate}" + ); + let expected = expected_items.max(1) as f64; + let ln2 = std::f64::consts::LN_2; + + let num_bits_f = -(expected * false_positive_rate.ln()) / (ln2 * ln2); + let num_bits = num_bits_f.ceil().clamp(64.0, Self::MAX_BITS as f64) as u64; + let num_bits = num_bits.next_power_of_two(); + + let optimal_k = ((num_bits as f64 / expected) * ln2).ceil(); + let num_hashes = (optimal_k as u32).clamp(1, 16); + + let words = (num_bits / 64) as usize; + + Self { + bits: vec![0u64; words], + num_bits, + num_hashes, + } + } + + pub fn insert(&mut self, key: &[u8]) { + let mask = self.num_bits - 1; + (0..self.num_hashes).for_each(|i| { + let h = xxh3_64_with_seed(key, u64::from(i)) & mask; + let word = (h / 64) as usize; + let bit = h % 64; + self.bits[word] |= 1u64 << bit; + }); + } + + pub fn contains(&self, key: &[u8]) -> bool { + let mask = self.num_bits - 1; + (0..self.num_hashes).all(|i| { + let h = xxh3_64_with_seed(key, u64::from(i)) & mask; + let word = (h / 64) as usize; + let bit = h % 64; + (self.bits[word] >> bit) & 1 == 1 + }) + } + + pub fn heap_bytes(&self) -> usize { + self.bits.len() * 8 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insert_and_contains() { + let mut bf = BloomFilter::with_capacity_and_fpr(1000, 0.01); + bf.insert(b"hello"); + bf.insert(b"world"); + + assert!(bf.contains(b"hello")); + assert!(bf.contains(b"world")); + } + + #[test] + fn missing_key_usually_absent() { + let mut bf = BloomFilter::with_capacity_and_fpr(1000, 0.01); + (0u32..500).for_each(|i| bf.insert(&i.to_le_bytes())); + + let false_positives = (1000u32..2000) + .filter(|i| bf.contains(&i.to_le_bytes())) + .count(); + + assert!( + false_positives < 50, + "expected <5% FPR, got {false_positives}/1000" + ); + } + + #[test] + fn no_false_negatives() { + let mut bf = BloomFilter::with_capacity_and_fpr(10_000, 0.01); + let keys: Vec<[u8; 4]> = (0u32..10_000).map(|i| i.to_le_bytes()).collect(); + keys.iter().for_each(|k| bf.insert(k)); + assert!(keys.iter().all(|k| bf.contains(k))); + } + + #[test] + fn empty_filter_contains_nothing() { + let bf = BloomFilter::with_capacity_and_fpr(1000, 0.01); + assert!(!bf.contains(b"anything")); + } + + #[test] + fn heap_bytes_reasonable() { + let bf = BloomFilter::with_capacity_and_fpr(100_000_000, 0.01); + let mb = bf.heap_bytes() / (1024 * 1024); + assert!( + mb < 256, + "100M items at 1% FPR should be <256MB, got {mb}MB" + ); + assert!(mb > 64, "100M items at 1% FPR should be >64MB, got {mb}MB"); + } + + #[test] + fn fpr_empirical() { + let n = 50_000u32; + let target_fpr = 0.01; + let mut bf = BloomFilter::with_capacity_and_fpr(n as u64, target_fpr); + + (0..n).for_each(|i| bf.insert(&i.to_le_bytes())); + + let test_range = 100_000u32; + let false_positives = (n..n + test_range) + .filter(|i| bf.contains(&i.to_le_bytes())) + .count(); + let measured_fpr = false_positives as f64 / test_range as f64; + + assert!( + measured_fpr < target_fpr * 3.0, + "measured FPR {measured_fpr:.4} exceeds 3x target {target_fpr}" + ); + } +} diff --git a/crates/tranquil-store/src/consistency.rs b/crates/tranquil-store/src/consistency.rs new file mode 100644 index 0000000..eb3d2c6 --- /dev/null +++ b/crates/tranquil-store/src/consistency.rs @@ -0,0 +1,657 @@ +use std::collections::HashSet; +use std::fmt; +use std::path::Path; + +use crate::blockstore::CID_SIZE; +use crate::blockstore::hash_index::BlockIndex; +use crate::blockstore::{DataFileId, TranquilBlockStore}; +use crate::eventlog::{EventLog, EventSequence, SequenceContiguityResult}; +use crate::io::StorageIO; +use crate::metastore::Metastore; +use crate::metastore::encoding::KeyBuilder; +use crate::metastore::event_keys::metastore_cursor_key; +use crate::metastore::keys::{KeyTag, UserHash}; +use crate::metastore::partitions::Partition; +use crate::metastore::records::RecordValue; +use crate::metastore::repo_meta::RepoMetaValue; + +const CLEAN_SHUTDOWN_MARKER: &str = ".clean_shutdown"; + +#[derive(Debug, Default)] +pub struct ConsistencyReport { + pub repos_checked: u64, + pub records_checked: u64, + pub user_blocks_checked: u64, + pub handles_checked: u64, + pub dangling_record_cids: Vec, + pub dangling_root_cids: Vec, + pub orphaned_user_repos: Vec, + pub inconsistent_handles: Vec, + pub orphan_data_files: Vec, + pub deserialization_failures: u64, + pub eventlog_contiguity: Option, + pub cursor_ahead_of_eventlog: bool, + pub metastore_cursor: Option, + pub eventlog_max_seq: Option, +} + +#[derive(Debug, Clone)] +pub struct DanglingCid { + pub user_hash: UserHash, + pub collection: String, + pub rkey: String, + pub cid_bytes: Vec, +} + +#[derive(Debug, Clone)] +pub struct DanglingRootCid { + pub user_hash: UserHash, + pub root_cid_bytes: Vec, +} + +#[derive(Debug, Clone)] +pub struct OrphanedUserRepo { + pub user_hash: UserHash, +} + +#[derive(Debug, Clone)] +pub struct InconsistentHandle { + pub handle: String, + pub mapped_user_hash: UserHash, + pub problem: HandleProblem, +} + +#[derive(Debug, Clone)] +pub enum HandleProblem { + NoRepoMeta, + HandleMismatch { repo_handle: String }, +} + +impl ConsistencyReport { + pub fn is_consistent(&self) -> bool { + self.dangling_record_cids.is_empty() + && self.dangling_root_cids.is_empty() + && self.orphaned_user_repos.is_empty() + && self.inconsistent_handles.is_empty() + && self.orphan_data_files.is_empty() + && self.deserialization_failures == 0 + && self + .eventlog_contiguity + .as_ref() + .is_none_or(|c| c.is_contiguous()) + && !self.cursor_ahead_of_eventlog + } + + pub fn has_repairable_issues(&self) -> bool { + !self.orphan_data_files.is_empty() + } + + pub fn has_unrecoverable_issues(&self) -> bool { + !self.dangling_root_cids.is_empty() + || !self.dangling_record_cids.is_empty() + || self.deserialization_failures > 0 + || self.cursor_ahead_of_eventlog + } + + pub fn log_findings(&self) { + if self.is_consistent() { + tracing::info!( + repos = self.repos_checked, + records = self.records_checked, + user_blocks = self.user_blocks_checked, + handles = self.handles_checked, + "consistency check passed" + ); + return; + } + + if !self.dangling_record_cids.is_empty() { + tracing::warn!( + count = self.dangling_record_cids.len(), + "records reference missing blocks" + ); + } + if !self.dangling_root_cids.is_empty() { + tracing::warn!( + count = self.dangling_root_cids.len(), + "repo roots reference missing blocks" + ); + } + if !self.orphaned_user_repos.is_empty() { + tracing::warn!( + count = self.orphaned_user_repos.len(), + "repos with user_blocks but no repo_meta" + ); + } + if !self.inconsistent_handles.is_empty() { + tracing::warn!( + count = self.inconsistent_handles.len(), + "handle index inconsistencies" + ); + } + if !self.orphan_data_files.is_empty() { + tracing::warn!( + count = self.orphan_data_files.len(), + files = ?self.orphan_data_files, + "orphan data files with no index references" + ); + } + if self.deserialization_failures > 0 { + tracing::error!( + count = self.deserialization_failures, + "metastore values failed to deserialize" + ); + } + if let Some(c) = &self.eventlog_contiguity + && !c.is_contiguous() + { + tracing::warn!(gaps = c.gaps.len(), "eventlog sequence gaps detected"); + c.gaps.iter().take(5).for_each(|gap| { + tracing::warn!( + after_segment = %gap.after_segment, + expected = ?gap.expected_seq, + actual = ?gap.actual_seq, + "eventlog gap" + ); + }); + } + if self.cursor_ahead_of_eventlog { + tracing::error!( + cursor = ?self.metastore_cursor, + eventlog_max = ?self.eventlog_max_seq, + "metastore cursor is ahead of eventlog max sequence" + ); + } + } +} + +impl fmt::Display for ConsistencyReport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.is_consistent() { + return write!( + f, + "consistent (repos={}, records={}, user_blocks={}, handles={})", + self.repos_checked, + self.records_checked, + self.user_blocks_checked, + self.handles_checked, + ); + } + + write!( + f, + "INCONSISTENT: dangling_roots={}, dangling_records={}, orphaned_repos={}, \ + inconsistent_handles={}, orphan_files={}, deserialize_failures={}, \ + eventlog_gaps={}, cursor_ahead={}", + self.dangling_root_cids.len(), + self.dangling_record_cids.len(), + self.orphaned_user_repos.len(), + self.inconsistent_handles.len(), + self.orphan_data_files.len(), + self.deserialization_failures, + self.eventlog_contiguity + .as_ref() + .map_or(0, |c| c.gaps.len()), + self.cursor_ahead_of_eventlog, + ) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct ConsistencyCheckOptions { + pub check_block_references: bool, + pub check_handles: bool, + pub check_user_blocks: bool, + pub check_eventlog: bool, + pub check_orphan_files: bool, +} + +impl Default for ConsistencyCheckOptions { + fn default() -> Self { + Self { + check_block_references: true, + check_handles: true, + check_user_blocks: true, + check_eventlog: true, + check_orphan_files: true, + } + } +} + +pub fn verify_store_consistency( + blockstore: &TranquilBlockStore, + metastore: &Metastore, + eventlog: &EventLog, +) -> ConsistencyReport { + verify_store_consistency_with_options( + blockstore, + metastore, + eventlog, + ConsistencyCheckOptions::default(), + ) +} + +pub fn verify_store_consistency_with_options( + blockstore: &TranquilBlockStore, + metastore: &Metastore, + eventlog: &EventLog, + options: ConsistencyCheckOptions, +) -> ConsistencyReport { + let mut report = ConsistencyReport::default(); + + let block_index = blockstore.block_index(); + let repo_data = metastore.partition(Partition::RepoData); + + let known_user_hashes = if options.check_block_references { + let hashes = check_repo_root_cids(repo_data, block_index, &mut report); + check_record_cids(repo_data, block_index, &mut report); + hashes + } else if options.check_user_blocks { + collect_known_user_hashes(repo_data) + } else { + HashSet::new() + }; + + if options.check_user_blocks { + check_user_blocks(repo_data, &known_user_hashes, &mut report); + } + + if options.check_handles { + check_handle_consistency(repo_data, &mut report); + } + + if options.check_eventlog { + check_eventlog_contiguity(eventlog, &mut report); + check_cursor_vs_eventlog(repo_data, eventlog, &mut report); + } + + if options.check_orphan_files { + check_orphan_data_files(blockstore, block_index, &mut report); + } + + report +} + +fn check_repo_root_cids( + repo_data: &fjall::Keyspace, + block_index: &BlockIndex, + report: &mut ConsistencyReport, +) -> HashSet { + let prefix = KeyBuilder::new().tag(KeyTag::REPO_META).build(); + let mut known_user_hashes = HashSet::new(); + + repo_data.prefix(prefix.as_slice()).for_each(|guard| { + let Ok((key_bytes, value_bytes)) = guard.into_inner() else { + return; + }; + + report.repos_checked = report.repos_checked.saturating_add(1); + + if let Some(h) = extract_user_hash(&key_bytes) { + known_user_hashes.insert(h); + } + + let Some(meta) = RepoMetaValue::deserialize(&value_bytes) else { + tracing::warn!( + user_hash = ?extract_user_hash(&key_bytes), + "repo_meta value failed to deserialize" + ); + report.deserialization_failures = report.deserialization_failures.saturating_add(1); + return; + }; + + if meta.repo_root_cid.is_empty() { + return; + } + + let Some(cid_fixed) = try_cid_bytes_to_fixed(&meta.repo_root_cid) else { + let Some(user_hash) = extract_user_hash(&key_bytes) else { + return; + }; + tracing::warn!( + %user_hash, + cid_len = meta.repo_root_cid.len(), + "repo_meta has non-standard CID length" + ); + report.dangling_root_cids.push(DanglingRootCid { + user_hash, + root_cid_bytes: meta.repo_root_cid, + }); + return; + }; + + if !block_index.has(&cid_fixed) { + let Some(user_hash) = extract_user_hash(&key_bytes) else { + return; + }; + report.dangling_root_cids.push(DanglingRootCid { + user_hash, + root_cid_bytes: meta.repo_root_cid, + }); + } + }); + + known_user_hashes +} + +fn collect_known_user_hashes(repo_data: &fjall::Keyspace) -> HashSet { + let prefix = KeyBuilder::new().tag(KeyTag::REPO_META).build(); + let mut hashes = HashSet::new(); + + repo_data.prefix(prefix.as_slice()).for_each(|guard| { + if let Ok((key_bytes, _)) = guard.into_inner() + && let Some(h) = extract_user_hash(&key_bytes) + { + hashes.insert(h); + } + }); + + hashes +} + +fn check_record_cids( + repo_data: &fjall::Keyspace, + block_index: &BlockIndex, + report: &mut ConsistencyReport, +) { + let prefix = KeyBuilder::new().tag(KeyTag::RECORDS).build(); + + repo_data.prefix(prefix.as_slice()).for_each(|guard| { + let Ok((key_bytes, value_bytes)) = guard.into_inner() else { + return; + }; + + report.records_checked = report.records_checked.saturating_add(1); + + let Some(record) = RecordValue::deserialize(&value_bytes) else { + tracing::warn!( + user_hash = ?extract_user_hash(&key_bytes), + "record value failed to deserialize" + ); + report.deserialization_failures = report.deserialization_failures.saturating_add(1); + return; + }; + + let Some(cid_fixed) = try_cid_bytes_to_fixed(&record.record_cid) else { + let (user_hash, collection, rkey) = parse_record_key(&key_bytes); + let Some(user_hash) = user_hash else { + return; + }; + tracing::warn!( + %user_hash, + collection, + rkey, + cid_len = record.record_cid.len(), + "record has non-standard CID length" + ); + report.dangling_record_cids.push(DanglingCid { + user_hash, + collection, + rkey, + cid_bytes: record.record_cid, + }); + return; + }; + + if !block_index.has(&cid_fixed) { + let (user_hash, collection, rkey) = parse_record_key(&key_bytes); + let Some(user_hash) = user_hash else { + return; + }; + report.dangling_record_cids.push(DanglingCid { + user_hash, + collection, + rkey, + cid_bytes: record.record_cid, + }); + } + }); +} + +fn check_user_blocks( + repo_data: &fjall::Keyspace, + known_user_hashes: &HashSet, + report: &mut ConsistencyReport, +) { + let prefix = KeyBuilder::new().tag(KeyTag::USER_BLOCKS).build(); + let mut seen_orphan_hashes: HashSet = HashSet::new(); + + repo_data.prefix(prefix.as_slice()).for_each(|guard| { + let Ok((key_bytes, _)) = guard.into_inner() else { + return; + }; + + report.user_blocks_checked = report.user_blocks_checked.saturating_add(1); + + let Some(user_hash) = extract_user_hash(&key_bytes) else { + return; + }; + + if !known_user_hashes.contains(&user_hash) && seen_orphan_hashes.insert(user_hash) { + report + .orphaned_user_repos + .push(OrphanedUserRepo { user_hash }); + } + }); +} + +fn check_handle_consistency(repo_data: &fjall::Keyspace, report: &mut ConsistencyReport) { + let prefix = KeyBuilder::new().tag(KeyTag::HANDLES).build(); + + repo_data.prefix(prefix.as_slice()).for_each(|guard| { + let Ok((key_bytes, value_bytes)) = guard.into_inner() else { + return; + }; + + report.handles_checked = report.handles_checked.saturating_add(1); + + let handle = parse_handle_from_key(&key_bytes); + let Some(mapped_hash) = parse_user_hash_from_value(&value_bytes) else { + return; + }; + + let meta_key = crate::metastore::repo_meta::repo_meta_key(mapped_hash); + match repo_data.get(meta_key.as_slice()) { + Ok(Some(meta_bytes)) => { + let Some(meta) = RepoMetaValue::deserialize(&meta_bytes) else { + tracing::warn!( + %mapped_hash, + handle, + "repo_meta value failed to deserialize during handle check" + ); + report.deserialization_failures = + report.deserialization_failures.saturating_add(1); + return; + }; + let meta_handle_lower = meta.handle.to_lowercase(); + let handle_lower = handle.to_lowercase(); + if meta_handle_lower != handle_lower { + report.inconsistent_handles.push(InconsistentHandle { + handle, + mapped_user_hash: mapped_hash, + problem: HandleProblem::HandleMismatch { + repo_handle: meta.handle, + }, + }); + } + } + Ok(None) => { + report.inconsistent_handles.push(InconsistentHandle { + handle, + mapped_user_hash: mapped_hash, + problem: HandleProblem::NoRepoMeta, + }); + } + Err(e) => { + tracing::warn!(error = %e, handle, "repo_meta lookup failed during handle check"); + } + } + }); +} + +fn check_eventlog_contiguity( + eventlog: &EventLog, + report: &mut ConsistencyReport, +) { + let reader = eventlog.reader(); + if let Err(e) = reader.refresh_segment_ranges() { + tracing::warn!(error = %e, "failed to refresh segment ranges for contiguity check"); + return; + } + report.eventlog_contiguity = Some(reader.check_sequence_contiguity()); +} + +fn check_cursor_vs_eventlog( + repo_data: &fjall::Keyspace, + eventlog: &EventLog, + report: &mut ConsistencyReport, +) { + let cursor_key = metastore_cursor_key(); + let cursor_seq = repo_data + .get(cursor_key.as_slice()) + .ok() + .flatten() + .and_then(|bytes| { + let arr: [u8; 8] = bytes.as_ref().try_into().ok()?; + Some(match u64::from_be_bytes(arr) { + 0 => EventSequence::BEFORE_ALL, + n => EventSequence::new(n), + }) + }); + + let max_seq = eventlog.max_seq(); + + report.metastore_cursor = cursor_seq; + report.eventlog_max_seq = (max_seq != EventSequence::BEFORE_ALL).then_some(max_seq); + + if let Some(cursor) = cursor_seq + && max_seq != EventSequence::BEFORE_ALL + && cursor > max_seq + { + report.cursor_ahead_of_eventlog = true; + } +} + +fn check_orphan_data_files( + blockstore: &TranquilBlockStore, + block_index: &BlockIndex, + report: &mut ConsistencyReport, +) { + let disk_files = match blockstore.list_data_files() { + Ok(files) => files, + Err(e) => { + tracing::warn!(error = %e, "failed to list data files for orphan check"); + return; + } + }; + + let epoch = blockstore.epoch().current(); + let now = crate::wall_clock_ms(); + let indexed_files = block_index.liveness_by_file(epoch, now, 0); + + let indexed_file_ids: HashSet = indexed_files.keys().copied().collect(); + + let active_file_id = block_index.read_write_cursor().map(|c| c.file_id); + + if active_file_id.is_none() && indexed_file_ids.is_empty() { + return; + } + + disk_files.iter().for_each(|&fid| { + let is_active = active_file_id.is_some_and(|active| fid >= active); + if !is_active && !indexed_file_ids.contains(&fid) { + report.orphan_data_files.push(fid); + } + }); +} + +fn try_cid_bytes_to_fixed(bytes: &[u8]) -> Option<[u8; CID_SIZE]> { + bytes.try_into().ok() +} + +fn extract_user_hash(key_bytes: &[u8]) -> Option { + key_bytes + .get(1..9)? + .try_into() + .ok() + .map(|arr| UserHash::from_raw(u64::from_be_bytes(arr))) +} + +fn parse_record_key(key_bytes: &[u8]) -> (Option, String, String) { + let user_hash = extract_user_hash(key_bytes); + let mut reader = crate::metastore::encoding::KeyReader::new(key_bytes); + let _ = reader.tag(); + let _ = reader.u64(); + let collection = reader.string().unwrap_or_default(); + let rkey = reader.string().unwrap_or_default(); + (user_hash, collection, rkey) +} + +fn parse_handle_from_key(key_bytes: &[u8]) -> String { + let mut reader = crate::metastore::encoding::KeyReader::new(key_bytes); + let _ = reader.tag(); + reader.string().unwrap_or_default() +} + +fn parse_user_hash_from_value(value_bytes: &[u8]) -> Option { + value_bytes + .get(..8)? + .try_into() + .ok() + .map(|arr| UserHash::from_raw(u64::from_be_bytes(arr))) +} + +pub fn repair_known_issues( + blockstore: &TranquilBlockStore, + report: &ConsistencyReport, +) -> RepairResult { + let mut result = RepairResult::default(); + + report.orphan_data_files.iter().for_each(|&file_id| { + let path = blockstore.data_file_path(file_id); + match std::fs::remove_file(&path) { + Ok(()) => { + tracing::info!(%file_id, "removed orphan data file"); + result.orphan_files_removed = result.orphan_files_removed.saturating_add(1); + } + Err(e) => { + tracing::warn!(%file_id, error = %e, "failed to remove orphan data file"); + result.repair_errors = result.repair_errors.saturating_add(1); + } + } + }); + + result +} + +#[derive(Debug, Default)] +pub struct RepairResult { + pub orphan_files_removed: u64, + pub repair_errors: u64, +} + +impl RepairResult { + pub fn had_errors(&self) -> bool { + self.repair_errors > 0 + } +} + +pub fn write_clean_shutdown_marker(data_dir: &Path) -> std::io::Result<()> { + let marker_path = data_dir.join(CLEAN_SHUTDOWN_MARKER); + let f = std::fs::File::create(&marker_path)?; + f.sync_all()?; + std::fs::File::open(data_dir)?.sync_all() +} + +pub fn remove_clean_shutdown_marker(data_dir: &Path) -> std::io::Result<()> { + let marker_path = data_dir.join(CLEAN_SHUTDOWN_MARKER); + match std::fs::remove_file(&marker_path) { + Ok(()) => std::fs::File::open(data_dir)?.sync_all(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } +} + +pub fn had_clean_shutdown(data_dir: &Path) -> bool { + data_dir.join(CLEAN_SHUTDOWN_MARKER).exists() +} diff --git a/crates/tranquil-store/src/eventlog/bridge.rs b/crates/tranquil-store/src/eventlog/bridge.rs index 2712490..fdb4406 100644 --- a/crates/tranquil-store/src/eventlog/bridge.rs +++ b/crates/tranquil-store/src/eventlog/bridge.rs @@ -7,11 +7,10 @@ use tranquil_db_traits::{DbError, SequenceNumber, SequencedEvent}; use super::notifier::EventLogNotifier; use super::types::{EventSequence, TimestampMicros}; -use super::writer::SyncResult; use super::{EventLog, EventWithMutations, decode_payload, to_sequenced_event}; use crate::io::StorageIO; -pub struct DeferredBroadcast(SyncResult); +pub struct DeferredBroadcast; fn io_to_db(e: io::Error) -> DbError { DbError::Query(e.to_string()) @@ -39,7 +38,7 @@ pub struct EventLogBridge { log: Arc>, } -impl EventLogBridge { +impl EventLogBridge { pub fn new(log: Arc>) -> Self { Self { log } } @@ -156,21 +155,22 @@ impl EventLogBridge { fn scan_for_timestamp( &self, reader: &super::EventLogReader, - cursor: EventSequence, + mut cursor: EventSequence, target_ts: TimestampMicros, batch_size: usize, ) -> Result, DbError> { - let batch = reader - .read_events_from(cursor, batch_size) - .map_err(io_to_db)?; - if batch.is_empty() { - return Ok(None); - } - match batch.iter().find(|e| e.timestamp >= target_ts) { - Some(e) => Ok(Some(SequenceNumber::from_raw(e.seq.as_i64()))), - None => { - let next_cursor = batch.last().map(|e| e.seq).unwrap_or(cursor); - self.scan_for_timestamp(reader, next_cursor, target_ts, batch_size) + loop { + let batch = reader + .read_events_from(cursor, batch_size) + .map_err(io_to_db)?; + if batch.is_empty() { + return Ok(None); + } + match batch.iter().find(|e| e.timestamp >= target_ts) { + Some(e) => return Ok(Some(SequenceNumber::from_raw(e.seq.as_i64()))), + None => { + cursor = batch.last().map(|e| e.seq).unwrap_or(cursor); + } } } } @@ -264,33 +264,16 @@ impl EventLogBridge { Ok(SequenceNumber::from_raw(seq.as_i64())) } - pub fn insert_event_deferred( - &self, - event: &SequencedEvent, - ) -> Result<(SequenceNumber, DeferredBroadcast), io::Error> { - let seq = self.log.append_event(&event.did, event.event_type, event)?; - let sync_result = self.log.sync_data()?; - Ok(( - SequenceNumber::from_raw(seq.as_i64()), - DeferredBroadcast(sync_result), - )) - } - - pub fn insert_event_deferred_raw( + pub fn insert_event_group_commit_raw( &self, did: &tranquil_types::Did, event_type: tranquil_db_traits::RepoEventType, payload: Vec, ) -> Result<(SequenceNumber, DeferredBroadcast), io::Error> { let seq = self.log.append_raw_payload(did, event_type, payload)?; - let sync_result = self.log.sync_data()?; - Ok(( - SequenceNumber::from_raw(seq.as_i64()), - DeferredBroadcast(sync_result), - )) + self.log.group_sync(seq)?; + Ok((SequenceNumber::from_raw(seq.as_i64()), DeferredBroadcast)) } - pub fn complete_broadcast(&self, deferred: DeferredBroadcast) { - self.log.broadcast_result(&deferred.0); - } + pub fn complete_broadcast(&self, _deferred: DeferredBroadcast) {} } diff --git a/crates/tranquil-store/src/eventlog/commit_loop.rs b/crates/tranquil-store/src/eventlog/commit_loop.rs new file mode 100644 index 0000000..2ab0764 --- /dev/null +++ b/crates/tranquil-store/src/eventlog/commit_loop.rs @@ -0,0 +1,632 @@ +use std::collections::BTreeMap; +use std::io; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use parking_lot::{Condvar, Mutex}; +use tokio::sync::broadcast; +use tracing::warn; + +use super::reader::{EventLogReader, RawEvent}; +use super::segment_file::ValidEvent; +use super::types::{EventSequence, SegmentId, SegmentOffset}; +use super::valid_event_to_raw; +use super::writer::{EventLogWriter, SyncResult}; +use crate::io::StorageIO; + +const MAX_BATCH_SIZE: usize = 1024; +const MAX_REORDER_PENDING: usize = 65536; +const SYNC_TIMEOUT: Duration = Duration::from_secs(30); +const REORDER_TIMEOUT: Duration = Duration::from_millis(100); +const GAP_ABANDON_TIMEOUT: Duration = Duration::from_secs(5); + +pub struct FreezeResponse { + pub synced_through: EventSequence, + pub segment_id: SegmentId, + pub position: SegmentOffset, +} + +pub enum WriterRequest { + Append(ValidEvent), + SyncBarrier { + response: flume::Sender>, + }, + Freeze { + response: flume::Sender>, + resume: flume::Receiver<()>, + }, + Shutdown, +} + +pub struct WriterNotify { + synced_seq: AtomicU64, + poisoned: AtomicBool, + mutex: Mutex<()>, + cond: Condvar, +} + +pub struct PendingBytesBudget { + budget: u64, + state: Mutex, + cond: Condvar, +} + +struct BudgetState { + in_flight: u64, + closed: bool, +} + +impl PendingBytesBudget { + pub fn new(budget: u64) -> Self { + let effective = match budget { + 0 => u64::MAX, + n => n, + }; + Self { + budget: effective, + state: Mutex::new(BudgetState { + in_flight: 0, + closed: false, + }), + cond: Condvar::new(), + } + } + + pub fn budget(&self) -> u64 { + self.budget + } + + pub fn in_flight(&self) -> u64 { + self.state.lock().in_flight + } + + pub fn acquire(&self, bytes: u64) -> io::Result<()> { + let oversized = bytes > self.budget; + let mut guard = self.state.lock(); + loop { + if guard.closed { + return Err(io::Error::other("eventlog writer pending budget closed")); + } + let admit = match oversized { + true => guard.in_flight == 0, + false => guard.in_flight.saturating_add(bytes) <= self.budget, + }; + if admit { + guard.in_flight = guard.in_flight.saturating_add(bytes); + if oversized { + warn!( + bytes, + budget = self.budget, + "eventlog admitting oversized event past pending budget" + ); + } + return Ok(()); + } + self.cond.wait(&mut guard); + } + } + + pub fn release(&self, bytes: u64) { + if bytes == 0 { + return; + } + let mut guard = self.state.lock(); + guard.in_flight = guard.in_flight.saturating_sub(bytes); + self.cond.notify_all(); + } + + pub fn close(&self) { + let mut guard = self.state.lock(); + guard.closed = true; + self.cond.notify_all(); + } +} + +impl WriterNotify { + pub fn new(initial_synced: u64) -> Self { + Self { + synced_seq: AtomicU64::new(initial_synced), + poisoned: AtomicBool::new(false), + mutex: Mutex::new(()), + cond: Condvar::new(), + } + } + + pub fn wait_for_sync(&self, target: EventSequence) -> io::Result<()> { + let target_raw = target.raw(); + + if self.synced_seq.load(Ordering::Acquire) >= target_raw { + return Ok(()); + } + + if self.poisoned.load(Ordering::Acquire) { + return Err(io::Error::other("eventlog writer poisoned")); + } + + let deadline = Instant::now() + SYNC_TIMEOUT; + let mut guard = self.mutex.lock(); + + loop { + if self.synced_seq.load(Ordering::Acquire) >= target_raw { + return Ok(()); + } + if self.poisoned.load(Ordering::Acquire) { + return Err(io::Error::other("eventlog writer poisoned")); + } + + let now = Instant::now(); + if now >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "eventlog sync timed out", + )); + } + + self.cond.wait_for(&mut guard, deadline - now); + } + } + + fn update_synced(&self, synced_through: u64) { + self.synced_seq.store(synced_through, Ordering::Release); + let _guard = self.mutex.lock(); + self.cond.notify_all(); + } + + fn poison(&self) { + self.poisoned.store(true, Ordering::Release); + let _guard = self.mutex.lock(); + self.cond.notify_all(); + } +} + +struct ReorderBuffer { + pending: BTreeMap, + next_write_seq: u64, + gap_since: Option, +} + +impl ReorderBuffer { + fn new(next_write_seq: u64) -> Self { + Self { + pending: BTreeMap::new(), + next_write_seq, + gap_since: None, + } + } + + fn insert(&mut self, event: ValidEvent) { + if event.seq.raw() < self.next_write_seq { + warn!( + event_seq = event.seq.raw(), + next_write_seq = self.next_write_seq, + "dropping late-arriving event after gap skip" + ); + return; + } + self.pending.insert(event.seq.raw(), event); + } + + fn is_full(&self) -> bool { + self.pending.len() >= MAX_REORDER_PENDING + } + + fn pending_count(&self) -> usize { + self.pending.len() + } + + fn drain_contiguous(&mut self) -> Vec { + let mut batch = Vec::new(); + while let Some(event) = self.pending.remove(&self.next_write_seq) { + self.next_write_seq = event.seq.next().raw(); + batch.push(event); + } + if batch.is_empty() && !self.pending.is_empty() { + if self.gap_since.is_none() { + self.gap_since = Some(Instant::now()); + } + } else { + self.gap_since = None; + } + batch + } + + fn should_skip_gap(&self) -> bool { + self.gap_since + .is_some_and(|since| since.elapsed() >= GAP_ABANDON_TIMEOUT) + } + + fn skip_to_first_available(&mut self) -> Vec { + let first_available = match self.pending.keys().next() { + Some(&seq) => seq, + None => return Vec::new(), + }; + warn!( + expected = self.next_write_seq, + skipping_to = first_available, + "eventlog writer skipping gap after timeout" + ); + self.next_write_seq = first_available; + self.gap_since = None; + self.drain_contiguous() + } + + fn has_pending(&self) -> bool { + !self.pending.is_empty() + } +} + +struct WriterCtx<'a, S: StorageIO> { + reader: &'a EventLogReader, + broadcast_tx: &'a broadcast::Sender, + notify: &'a WriterNotify, + synced_seq: &'a AtomicU64, + pending_bytes: &'a PendingBytesBudget, +} + +fn post_sync(result: &mut SyncResult, ctx: &WriterCtx<'_, S>) { + let synced = result.synced_through.raw(); + let flushed = std::mem::take(&mut result.flushed_events); + + if let (Some(first), Some(last)) = (flushed.first(), flushed.last()) { + ctx.reader.extend_active_range(first.seq, last.seq); + } + + ctx.synced_seq.store(synced, Ordering::Release); + ctx.notify.update_synced(synced); + + let released = flushed + .iter() + .map(|e| e.payload.len() as u64) + .fold(0u64, u64::saturating_add); + + flushed.into_iter().for_each(|e| { + let _ = ctx.broadcast_tx.send(valid_event_to_raw(e)); + }); + + ctx.pending_bytes.release(released); +} + +fn flush_and_notify(writer: &mut EventLogWriter, ctx: &WriterCtx<'_, S>) -> bool { + match writer.sync() { + Ok(mut result) => { + post_sync(&mut result, ctx); + + match writer.rotate_if_needed() { + Ok(Some(sealed_id)) => { + let new_id = writer.active_segment_id(); + if let Err(e) = ctx.reader.on_segment_rotated(sealed_id, new_id) { + warn!(error = %e, "eventlog rotation notification failed"); + } + } + Ok(None) => {} + Err(e) => { + warn!(error = %e, "eventlog rotation deferred"); + } + } + true + } + Err(e) => { + warn!(error = %e, "eventlog sync failed, poisoning writer"); + ctx.notify.poison(); + false + } + } +} + +fn append_batch( + writer: &mut EventLogWriter, + events: Vec, + notify: &WriterNotify, +) -> bool { + let ok = events + .into_iter() + .try_for_each(|event| writer.append_valid_event(event)); + if let Err(e) = ok { + warn!(error = %e, "eventlog append failed, poisoning writer"); + notify.poison(); + return false; + } + true +} + +fn handle_sync_barrier( + writer: &mut EventLogWriter, + response: flume::Sender>, + ctx: &WriterCtx<'_, S>, +) { + let mut result = writer.sync(); + + if let Ok(ref mut sync_result) = result { + post_sync(sync_result, ctx); + } + + let _ = response.send(result); +} + +fn handle_freeze( + writer: &mut EventLogWriter, + response: flume::Sender>, + resume: flume::Receiver<()>, + ctx: &WriterCtx<'_, S>, +) { + let result = writer.sync().map(|mut sync_result| { + post_sync(&mut sync_result, ctx); + + FreezeResponse { + synced_through: sync_result.synced_through, + segment_id: sync_result.segment_id, + position: sync_result.position, + } + }); + + let _ = response.send(result); + let _ = resume.recv(); +} + +struct CloseOnDrop<'a>(&'a PendingBytesBudget); + +impl<'a> Drop for CloseOnDrop<'a> { + fn drop(&mut self) { + self.0.close(); + } +} + +fn writer_loop( + receiver: &flume::Receiver, + writer: &mut EventLogWriter, + ctx: &WriterCtx<'_, S>, +) { + let _close = CloseOnDrop(ctx.pending_bytes); + let mut reorder = ReorderBuffer::new(writer.current_seq().next().raw()); + + loop { + if ctx.notify.poisoned.load(Ordering::Acquire) { + let _ = writer.shutdown(); + break; + } + + let recv_result = match reorder.has_pending() { + true => receiver.recv_timeout(REORDER_TIMEOUT), + false => receiver + .recv() + .map_err(|_| flume::RecvTimeoutError::Disconnected), + }; + + match recv_result { + Err(flume::RecvTimeoutError::Disconnected) => { + if reorder.has_pending() { + let batch = reorder.skip_to_first_available(); + if !batch.is_empty() && append_batch(writer, batch, ctx.notify) { + flush_and_notify(writer, ctx); + } + } + let _ = writer.shutdown(); + break; + } + Err(flume::RecvTimeoutError::Timeout) => { + if reorder.should_skip_gap() { + let batch = reorder.skip_to_first_available(); + if !batch.is_empty() && append_batch(writer, batch, ctx.notify) { + flush_and_notify(writer, ctx); + } + } + continue; + } + Ok(WriterRequest::Shutdown) => { + if reorder.has_pending() { + let batch = reorder.skip_to_first_available(); + if !batch.is_empty() { + append_batch(writer, batch, ctx.notify); + } + } + let _ = writer.shutdown(); + break; + } + Ok(WriterRequest::SyncBarrier { response }) => { + let batch = reorder.drain_contiguous(); + if !batch.is_empty() { + append_batch(writer, batch, ctx.notify); + } + handle_sync_barrier(writer, response, ctx); + } + Ok(WriterRequest::Freeze { response, resume }) => { + if reorder.has_pending() { + let batch = reorder.skip_to_first_available(); + if !batch.is_empty() { + append_batch(writer, batch, ctx.notify); + } + } + handle_freeze(writer, response, resume, ctx); + reorder = ReorderBuffer::new(writer.current_seq().next().raw()); + } + Ok(WriterRequest::Append(event)) => { + reorder.insert(event); + + while reorder.pending_count() < MAX_BATCH_SIZE { + match receiver.try_recv() { + Ok(WriterRequest::Append(e)) => reorder.insert(e), + Ok(WriterRequest::Shutdown) => { + let batch = reorder.skip_to_first_available(); + if !batch.is_empty() { + append_batch(writer, batch, ctx.notify); + } + let _ = writer.shutdown(); + return; + } + Ok(WriterRequest::SyncBarrier { response }) => { + let batch = reorder.drain_contiguous(); + if !batch.is_empty() && append_batch(writer, batch, ctx.notify) { + flush_and_notify(writer, ctx); + } + handle_sync_barrier(writer, response, ctx); + break; + } + Ok(WriterRequest::Freeze { response, resume }) => { + let batch = reorder.drain_contiguous(); + if !batch.is_empty() && append_batch(writer, batch, ctx.notify) { + flush_and_notify(writer, ctx); + } + handle_freeze(writer, response, resume, ctx); + reorder = ReorderBuffer::new(writer.current_seq().next().raw()); + break; + } + Err(_) => break, + } + } + + let batch = reorder.drain_contiguous(); + if !batch.is_empty() && append_batch(writer, batch, ctx.notify) { + flush_and_notify(writer, ctx); + } + + if reorder.is_full() { + warn!( + pending = reorder.pending_count(), + "reorder buffer at capacity, force-skipping gap" + ); + let batch = reorder.skip_to_first_available(); + if !batch.is_empty() && append_batch(writer, batch, ctx.notify) { + flush_and_notify(writer, ctx); + } + } + } + } + } +} + +fn log_thread_panic(payload: Box) { + let msg = payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(|s| s.as_str())) + .unwrap_or("unknown panic"); + tracing::error!(panic = msg, "eventlog commit thread panicked"); +} + +pub struct CommitThread { + sender: flume::Sender, + handle: Mutex>>, +} + +impl CommitThread { + pub fn spawn( + mut writer: EventLogWriter, + reader: Arc>, + broadcast_tx: broadcast::Sender, + notify: Arc, + synced_seq: Arc, + pending_bytes: Arc, + ) -> io::Result { + let (sender, receiver) = flume::unbounded(); + + let handle = std::thread::Builder::new() + .name("eventlog-commit".into()) + .spawn(move || { + let ctx = WriterCtx { + reader: &reader, + broadcast_tx: &broadcast_tx, + notify: ¬ify, + synced_seq: &synced_seq, + pending_bytes: &pending_bytes, + }; + writer_loop(&receiver, &mut writer, &ctx); + }) + .map_err(io::Error::other)?; + + Ok(Self { + sender, + handle: Mutex::new(Some(handle)), + }) + } + + pub fn sender(&self) -> &flume::Sender { + &self.sender + } + + pub fn shutdown(&self) { + let _ = self.sender.send(WriterRequest::Shutdown); + if let Some(handle) = self.handle.lock().take() + && let Err(payload) = handle.join() + { + log_thread_panic(payload); + } + } +} + +impl Drop for CommitThread { + fn drop(&mut self) { + let _ = self.sender.try_send(WriterRequest::Shutdown); + if let Some(handle) = self.handle.lock().take() + && let Err(payload) = handle.join() + { + log_thread_panic(payload); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::time::Duration; + + #[test] + fn close_unblocks_waiter() { + let budget = Arc::new(PendingBytesBudget::new(1024)); + budget.acquire(1024).unwrap(); + assert_eq!(budget.in_flight(), 1024); + + let budget_blocked = Arc::clone(&budget); + let blocked = std::thread::spawn(move || budget_blocked.acquire(256)); + + std::thread::sleep(Duration::from_millis(100)); + assert!( + !blocked.is_finished(), + "acquire must block when budget exhausted" + ); + + budget.close(); + + let result = blocked.join().expect("blocked thread must not panic"); + assert!( + result.is_err(), + "acquire must error after close, got {result:?}" + ); + } + + #[test] + fn close_makes_subsequent_acquire_fail_immediately() { + let budget = PendingBytesBudget::new(1024); + budget.close(); + assert!(budget.acquire(1).is_err()); + } + + #[test] + fn release_after_full_unblocks_waiter() { + let budget = Arc::new(PendingBytesBudget::new(1024)); + budget.acquire(1024).unwrap(); + + let budget_blocked = Arc::clone(&budget); + let blocked = std::thread::spawn(move || budget_blocked.acquire(256)); + + std::thread::sleep(Duration::from_millis(100)); + assert!(!blocked.is_finished()); + + budget.release(1024); + + blocked + .join() + .expect("thread panic") + .expect("acquire after release must succeed"); + assert_eq!(budget.in_flight(), 256); + } + + #[test] + fn zero_budget_means_unbounded() { + let budget = PendingBytesBudget::new(0); + budget.acquire(u64::MAX / 2).unwrap(); + budget.acquire(u64::MAX / 4).unwrap(); + assert!(budget.in_flight() >= u64::MAX / 2); + } +} diff --git a/crates/tranquil-store/src/eventlog/manager.rs b/crates/tranquil-store/src/eventlog/manager.rs index 5abb833..b4cc432 100644 --- a/crates/tranquil-store/src/eventlog/manager.rs +++ b/crates/tranquil-store/src/eventlog/manager.rs @@ -11,8 +11,19 @@ use super::segment_file::SEGMENT_HEADER_SIZE; use super::segment_index::SegmentIndex; use super::types::{SegmentId, SegmentOffset}; -pub(crate) const SEGMENT_FILE_EXTENSION: &str = "tqe"; +pub const SEGMENT_FILE_EXTENSION: &str = "tqe"; pub(crate) const INDEX_FILE_EXTENSION: &str = "tqi"; +pub(crate) const SIDECAR_FILE_EXTENSION: &str = "tqs"; + +pub fn segment_path(dir: &Path, id: SegmentId) -> PathBuf { + dir.join(format!("{id}.{SEGMENT_FILE_EXTENSION}")) +} + +pub fn parse_segment_id(path: &Path) -> Option { + let stem = path.file_stem()?.to_str()?; + let ext = path.extension()?.to_str()?; + (ext == SEGMENT_FILE_EXTENSION).then(|| stem.parse::().ok().map(SegmentId::new))? +} struct CachedSegmentHandle { fd: FileId, @@ -34,6 +45,10 @@ impl SegmentManager { max_segment_size > SEGMENT_HEADER_SIZE as u64, "max_segment_size ({max_segment_size}) must exceed SEGMENT_HEADER_SIZE ({SEGMENT_HEADER_SIZE})" ); + assert!( + max_segment_size <= u32::MAX as u64, + "max_segment_size ({max_segment_size}) must not exceed u32::MAX (sidecar offsets are u32)" + ); io.mkdir(&segments_dir)?; Ok(Self { io, @@ -57,8 +72,7 @@ impl SegmentManager { } pub fn segment_path(&self, id: SegmentId) -> PathBuf { - self.segments_dir - .join(format!("{id}.{SEGMENT_FILE_EXTENSION}")) + segment_path(&self.segments_dir, id) } pub fn index_path(&self, id: SegmentId) -> PathBuf { @@ -66,17 +80,14 @@ impl SegmentManager { .join(format!("{id}.{INDEX_FILE_EXTENSION}")) } + pub fn sidecar_path(&self, id: SegmentId) -> PathBuf { + self.segments_dir + .join(format!("{id}.{SIDECAR_FILE_EXTENSION}")) + } + pub fn list_segments(&self) -> io::Result> { let entries = self.io.list_dir(&self.segments_dir)?; - let mut ids: Vec = entries - .iter() - .filter_map(|path| { - let stem = path.file_stem()?.to_str()?; - let ext = path.extension()?.to_str()?; - (ext == SEGMENT_FILE_EXTENSION) - .then(|| stem.parse::().ok().map(SegmentId::new))? - }) - .collect(); + let mut ids: Vec = entries.iter().filter_map(|p| parse_segment_id(p)).collect(); ids.sort(); Ok(ids) } @@ -222,11 +233,13 @@ impl SegmentManager { let _ = self.io.close(entry.fd); } } - match self.io.delete(&self.index_path(id)) { - Ok(()) => {} - Err(e) if e.kind() == io::ErrorKind::NotFound => {} - Err(e) => return Err(e), - } + [self.index_path(id), self.sidecar_path(id)] + .iter() + .try_for_each(|path| match self.io.delete(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + })?; self.io.delete(&self.segment_path(id))?; self.io.sync_dir(&self.segments_dir)?; self.retention_epoch.fetch_add(1, Ordering::Relaxed); @@ -260,7 +273,7 @@ mod tests { use crate::eventlog::segment_file::{SegmentWriter, ValidEvent}; use crate::eventlog::segment_index::{DEFAULT_INDEX_INTERVAL, rebuild_from_segment}; use crate::eventlog::types::{ - DidHash, EventSequence, EventTypeTag, SegmentOffset, TimestampMicros, + DidHash, EventSequence, EventTypeTag, MAX_EVENT_PAYLOAD, SegmentOffset, TimestampMicros, }; use crate::sim::SimulatedIO; @@ -431,8 +444,14 @@ mod tests { fn seal_segment_persists_index_and_marks_sealed() { let mgr = setup_manager(64 * 1024); let fd = mgr.open_for_append(SegmentId::new(1)).unwrap(); - let mut writer = - SegmentWriter::new(mgr.io(), fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); + let mut writer = SegmentWriter::new( + mgr.io(), + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); (1u64..=10).for_each(|i| { writer @@ -441,7 +460,8 @@ mod tests { }); writer.sync(mgr.io()).unwrap(); - let (index, _) = rebuild_from_segment(mgr.io(), fd, DEFAULT_INDEX_INTERVAL).unwrap(); + let (index, _) = + rebuild_from_segment(mgr.io(), fd, DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD).unwrap(); assert!(!mgr.is_sealed(SegmentId::new(1))); mgr.seal_segment(SegmentId::new(1), &index).unwrap(); @@ -457,14 +477,21 @@ mod tests { fn delete_segment_removes_files_and_handle() { let mgr = setup_manager(64 * 1024); let fd = mgr.open_for_append(SegmentId::new(1)).unwrap(); - let mut writer = - SegmentWriter::new(mgr.io(), fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); + let mut writer = SegmentWriter::new( + mgr.io(), + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); writer .append_event(mgr.io(), &test_event(1, b"will be deleted")) .unwrap(); writer.sync(mgr.io()).unwrap(); - let (index, _) = rebuild_from_segment(mgr.io(), fd, DEFAULT_INDEX_INTERVAL).unwrap(); + let (index, _) = + rebuild_from_segment(mgr.io(), fd, DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD).unwrap(); mgr.seal_segment(SegmentId::new(1), &index).unwrap(); let epoch_before = mgr.retention_epoch(); @@ -498,8 +525,14 @@ mod tests { let mgr = setup_manager(1024); let fd1 = mgr.open_for_append(SegmentId::new(1)).unwrap(); - let mut writer1 = - SegmentWriter::new(mgr.io(), fd1, SegmentId::new(1), EventSequence::new(1)).unwrap(); + let mut writer1 = SegmentWriter::new( + mgr.io(), + fd1, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); writer1 .append_event(mgr.io(), &test_event(1, b"first segment")) .unwrap(); @@ -508,14 +541,16 @@ mod tests { let (id2, fd2) = mgr.prepare_rotation(SegmentId::new(1)).unwrap(); mgr.commit_rotation(id2, fd2); - let mut writer2 = SegmentWriter::new(mgr.io(), fd2, id2, EventSequence::new(2)).unwrap(); + let mut writer2 = + SegmentWriter::new(mgr.io(), fd2, id2, EventSequence::new(2), MAX_EVENT_PAYLOAD) + .unwrap(); writer2 .append_event(mgr.io(), &test_event(2, b"second segment")) .unwrap(); writer2.sync(mgr.io()).unwrap(); let fd1_read = mgr.open_for_read(SegmentId::new(1)).unwrap(); - let events1 = crate::eventlog::SegmentReader::open(mgr.io(), fd1_read) + let events1 = crate::eventlog::SegmentReader::open(mgr.io(), fd1_read, MAX_EVENT_PAYLOAD) .unwrap() .valid_prefix() .unwrap(); @@ -523,7 +558,7 @@ mod tests { assert_eq!(events1[0].payload, b"first segment"); let fd2_read = mgr.open_for_read(id2).unwrap(); - let events2 = crate::eventlog::SegmentReader::open(mgr.io(), fd2_read) + let events2 = crate::eventlog::SegmentReader::open(mgr.io(), fd2_read, MAX_EVENT_PAYLOAD) .unwrap() .valid_prefix() .unwrap(); @@ -535,7 +570,14 @@ mod tests { fn seal_then_append_errors() { let mgr = setup_manager(64 * 1024); let fd = mgr.open_for_append(SegmentId::new(1)).unwrap(); - SegmentWriter::new(mgr.io(), fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); + SegmentWriter::new( + mgr.io(), + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); let index = SegmentIndex::new(); mgr.seal_segment(SegmentId::new(1), &index).unwrap(); @@ -569,14 +611,21 @@ mod tests { fn open_for_read_does_not_infer_sealed_from_index_file() { let mgr = setup_manager(64 * 1024); let fd = mgr.open_for_append(SegmentId::new(1)).unwrap(); - let mut writer = - SegmentWriter::new(mgr.io(), fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); + let mut writer = SegmentWriter::new( + mgr.io(), + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); writer .append_event(mgr.io(), &test_event(1, b"sealed test")) .unwrap(); writer.sync(mgr.io()).unwrap(); - let (index, _) = rebuild_from_segment(mgr.io(), fd, DEFAULT_INDEX_INTERVAL).unwrap(); + let (index, _) = + rebuild_from_segment(mgr.io(), fd, DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD).unwrap(); mgr.seal_segment(SegmentId::new(1), &index).unwrap(); mgr.handles.write().remove(&SegmentId::new(1)); diff --git a/crates/tranquil-store/src/eventlog/mod.rs b/crates/tranquil-store/src/eventlog/mod.rs index 0a5b014..d23869b 100644 --- a/crates/tranquil-store/src/eventlog/mod.rs +++ b/crates/tranquil-store/src/eventlog/mod.rs @@ -1,10 +1,12 @@ mod bridge; +mod commit_loop; mod manager; mod notifier; mod payload; mod reader; mod segment_file; mod segment_index; +mod sidecar; mod types; mod writer; @@ -12,10 +14,9 @@ use std::collections::VecDeque; use std::io; use std::path::PathBuf; use std::sync::Arc; -use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; -use parking_lot::Mutex; use tokio::sync::broadcast; use tracing::warn; use tranquil_db_traits::{RepoEventType, SequencedEvent}; @@ -25,27 +26,31 @@ use crate::blockstore::BlocksSynced; use crate::fsync_order::PostBlockstoreHook; use crate::io::StorageIO; +use commit_loop::{CommitThread, FreezeResponse, PendingBytesBudget, WriterNotify, WriterRequest}; + pub use bridge::{DeferredBroadcast, EventLogBridge}; -pub use manager::SegmentManager; +pub use manager::{SEGMENT_FILE_EXTENSION, SegmentManager, parse_segment_id, segment_path}; pub use notifier::EventLogNotifier; pub use payload::{ EventPayload, PayloadError, decode_payload, encode_payload, encode_payload_with_mutations, to_sequenced_event, validate_payload_size, }; -pub use reader::{EventLogReader, RawEvent}; +pub use reader::{EventLogReader, RawEvent, SequenceContiguityResult, SequenceGap}; pub use segment_file::{ EVENT_HEADER_SIZE, EVENT_RECORD_OVERHEAD, ReadEventRecord, SEGMENT_FORMAT_VERSION, SEGMENT_HEADER_SIZE, SEGMENT_MAGIC, SegmentReader, SegmentWriter, ValidEvent, ValidateEventRecord, decode_event_record, encode_event_record, validate_event_record, }; pub use segment_index::{DEFAULT_INDEX_INTERVAL, SegmentIndex, rebuild_from_segment}; +pub use sidecar::{SidecarEntry, SidecarIndex, build_sidecar_from_segment}; pub use types::{ - DEFAULT_SEGMENT_SIZE, DidHash, EventLength, EventSequence, EventTypeTag, MAX_EVENT_PAYLOAD, - SegmentId, SegmentOffset, TimestampMicros, + DEFAULT_MAX_EVENT_PAYLOAD, DEFAULT_SEGMENT_SIZE, DidHash, EventLength, EventSequence, + EventTypeTag, MAX_EVENT_PAYLOAD, SegmentId, SegmentOffset, TimestampMicros, }; pub use writer::{EventLogWriter, SyncResult}; const DEFAULT_BROADCAST_BUFFER: usize = 16384; +pub const DEFAULT_PENDING_BYTES_BUDGET: u64 = 1024 * 1024 * 1024; pub struct EventWithMutations { pub event: SequencedEvent, @@ -58,6 +63,9 @@ pub struct EventLogConfig { pub index_interval: usize, pub broadcast_buffer: usize, pub use_mmap: bool, + pub skip_sealed_checksum: bool, + pub pending_bytes_budget: u64, + pub max_event_payload: u32, } impl Default for EventLogConfig { @@ -68,47 +76,120 @@ impl Default for EventLogConfig { index_interval: DEFAULT_INDEX_INTERVAL, broadcast_buffer: DEFAULT_BROADCAST_BUFFER, use_mmap: true, + skip_sealed_checksum: false, + pending_bytes_budget: DEFAULT_PENDING_BYTES_BUDGET, + max_event_payload: DEFAULT_MAX_EVENT_PAYLOAD, + } + } +} + +pub struct EventLogSnapshotState { + pub max_seq: EventSequence, + pub active_segment_id: SegmentId, + pub active_segment_position: SegmentOffset, + pub sealed_segments: Vec, +} + +pub struct EventLogFreezeGuard { + _resume: Option>, +} + +impl Drop for EventLogFreezeGuard { + fn drop(&mut self) { + if let Some(resume) = self._resume.take() { + let _ = resume.send(()); } } } pub struct EventLog { - writer: Mutex>, + commit_thread: CommitThread, reader: Arc>, manager: Arc>, broadcast_tx: broadcast::Sender, - synced_seq: AtomicU64, - consecutive_sync_failures: AtomicU32, + synced_seq: Arc, + notify: Arc, + pending_bytes: Arc, + next_seq: AtomicU64, + max_payload: u32, } -impl EventLog { +impl EventLog { pub fn open(config: EventLogConfig, io: S) -> io::Result { + let max_payload = config.max_event_payload; let manager = Arc::new(SegmentManager::new( io, config.segments_dir, config.max_segment_size, )?); - let writer = EventLogWriter::open(Arc::clone(&manager), config.index_interval)?; + let writer = + EventLogWriter::open(Arc::clone(&manager), config.index_interval, max_payload)?; let synced = writer.synced_seq(); + let initial_next_seq = writer.current_seq().next(); - let reader = Arc::new(EventLogReader::new(Arc::clone(&manager), config.use_mmap)); + let reader = Arc::new(EventLogReader::new( + Arc::clone(&manager), + config.use_mmap, + config.skip_sealed_checksum, + max_payload, + )); reader.set_active_segment(writer.active_segment_id()); reader.seed_index(writer.active_segment_id(), writer.active_index_snapshot()); reader.refresh_segment_ranges()?; let (broadcast_tx, _) = broadcast::channel(config.broadcast_buffer); + let synced_seq = Arc::new(AtomicU64::new(synced.raw())); + let notify = Arc::new(WriterNotify::new(synced.raw())); + let pending_bytes = Arc::new(PendingBytesBudget::new(config.pending_bytes_budget)); + + let commit_thread = CommitThread::spawn( + writer, + Arc::clone(&reader), + broadcast_tx.clone(), + Arc::clone(¬ify), + Arc::clone(&synced_seq), + Arc::clone(&pending_bytes), + )?; + Ok(Self { - writer: Mutex::new(writer), + commit_thread, reader, manager, broadcast_tx, - synced_seq: AtomicU64::new(synced.raw()), - consecutive_sync_failures: AtomicU32::new(0), + synced_seq, + notify, + pending_bytes, + next_seq: AtomicU64::new(initial_next_seq.raw()), + max_payload, }) } + pub fn max_payload(&self) -> u32 { + self.max_payload + } + + pub fn pending_bytes_in_flight(&self) -> u64 { + self.pending_bytes.in_flight() + } + + pub fn pending_bytes_budget(&self) -> u64 { + self.pending_bytes.budget() + } + + fn reserve_seq(&self) -> EventSequence { + let raw = self.next_seq.fetch_add(1, Ordering::Relaxed); + EventSequence::new(raw) + } + + fn send_append(&self, event: ValidEvent) -> io::Result<()> { + self.commit_thread + .sender() + .send(WriterRequest::Append(event)) + .map_err(|_| io::Error::other("eventlog writer thread terminated")) + } + pub fn append_event( &self, did: &Did, @@ -127,13 +208,31 @@ impl EventLog { ) -> io::Result { let did_hash = DidHash::from_did(did.as_str()); let tag = repo_event_type_to_tag(event_type); - validate_payload_size(&payload) + validate_payload_size(&payload, self.max_payload) .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; - self.writer.lock().append(did_hash, tag, payload) + + self.pending_bytes.acquire(payload.len() as u64)?; + + let seq = self.reserve_seq(); + let timestamp = TimestampMicros::now(); + + let event = ValidEvent { + seq, + timestamp, + did_hash, + event_type: tag, + payload, + }; + self.send_append(event)?; + Ok(seq) + } + + pub fn group_sync(&self, my_seq: EventSequence) -> io::Result<()> { + self.notify.wait_for_sync(my_seq) } pub fn sync(&self) -> io::Result { - self.sync_and_broadcast() + self.sync_data() } pub fn append_and_sync( @@ -143,7 +242,7 @@ impl EventLog { event: &SequencedEvent, ) -> io::Result { let seq = self.append_event(did, event_type, event)?; - self.sync_and_broadcast()?; + self.group_sync(seq)?; Ok(seq) } @@ -151,44 +250,21 @@ impl EventLog { &self, events: Vec<(&Did, RepoEventType, &SequencedEvent)>, ) -> io::Result> { - let mut writer = self.writer.lock(); events .iter() - .map(|(did, event_type, event)| { - let did_hash = DidHash::from_did(did.as_str()); - let tag = repo_event_type_to_tag(*event_type); - let payload = encode_payload(event); - validate_payload_size(&payload) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; - writer.append(did_hash, tag, payload) - }) + .map(|(did, event_type, event)| self.append_event(did, *event_type, event)) .collect() } pub fn sync_data(&self) -> io::Result { - let mut writer = self.writer.lock(); - let result = writer.sync()?; - self.synced_seq - .store(result.synced_through.raw(), Ordering::Release); - - if let (Some(first), Some(last)) = - (result.flushed_events.first(), result.flushed_events.last()) - { - self.reader.extend_active_range(first.seq, last.seq); - } - Ok(result) - } - - pub fn broadcast_result(&self, result: &SyncResult) { - result.flushed_events.iter().for_each(|e| { - let _ = self.broadcast_tx.send(valid_event_to_raw(e)); - }); - } - - pub fn sync_and_broadcast(&self) -> io::Result { - let result = self.sync_data()?; - self.broadcast_result(&result); - Ok(result) + let (resp_tx, resp_rx) = flume::bounded(1); + self.commit_thread + .sender() + .send(WriterRequest::SyncBarrier { response: resp_tx }) + .map_err(|_| io::Error::other("eventlog writer thread terminated"))?; + resp_rx + .recv() + .map_err(|_| io::Error::other("eventlog writer thread terminated"))? } pub fn get_events_since( @@ -253,40 +329,25 @@ impl EventLog { } pub fn maybe_rotate(&self) -> io::Result { - let (sealed_id, new_active_id) = { - let mut writer = self.writer.lock(); - match writer.rotate_if_needed()? { - None => return Ok(false), - Some(sealed_id) => (sealed_id, writer.active_segment_id()), - } - }; - self.reader.on_segment_rotated(sealed_id, new_active_id)?; - Ok(true) + Ok(false) } pub fn run_retention(&self, max_age: Duration) -> io::Result { + self.run_retention_at(TimestampMicros::now(), max_age) + } + + pub fn run_retention_at(&self, now: TimestampMicros, max_age: Duration) -> io::Result { let max_age_us = u64::try_from(max_age.as_micros()).unwrap_or(u64::MAX); - let cutoff_us = TimestampMicros::now().raw().saturating_sub(max_age_us); - let active_id = self.writer.lock().active_segment_id(); + let cutoff_us = now.raw().saturating_sub(max_age_us); + + let sync_result = self.sync_data()?; + let active_id = sync_result.segment_id; let segments = self.manager.list_segments()?; let deleted = segments .iter() .take_while(|&&id| id != active_id) - .filter(|&&id| { - self.reader - .load_index(id) - .ok() - .and_then(|idx| idx.last_seq()) - .and_then(|seq| { - self.reader - .read_event_at(seq) - .ok() - .flatten() - .map(|e| e.timestamp.raw() < cutoff_us) - }) - .unwrap_or(false) - }) + .filter(|&&id| self.segment_past_cutoff(id, cutoff_us)) .copied() .collect::>(); @@ -294,6 +355,7 @@ impl EventLog { self.manager.delete_segment(id)?; self.reader.invalidate_index(id); self.reader.invalidate_mmap(id); + self.reader.invalidate_sidecar(id); Ok(()) })?; @@ -304,6 +366,44 @@ impl EventLog { Ok(deleted.len()) } + fn segment_past_cutoff(&self, id: SegmentId, cutoff_us: u64) -> bool { + let idx = match self.reader.load_index(id) { + Ok(idx) => idx, + Err(e) => { + warn!( + segment_id = id.raw(), + error = %e, + "eventlog retention: failed to load segment index, keeping segment" + ); + return false; + } + }; + let last_seq = match idx.last_seq() { + Some(seq) => seq, + None => return false, + }; + match self.reader.read_event_at(last_seq) { + Ok(Some(event)) => event.timestamp.raw() < cutoff_us, + Ok(None) => { + warn!( + segment_id = id.raw(), + last_seq = last_seq.raw(), + "eventlog retention: index reports last_seq but read_event_at returned None, keeping segment" + ); + false + } + Err(e) => { + warn!( + segment_id = id.raw(), + last_seq = last_seq.raw(), + error = %e, + "eventlog retention: failed to read last event, keeping segment" + ); + false + } + } + } + pub fn segment_count(&self) -> usize { self.manager.list_segments().map_or(0, |s| s.len()) } @@ -317,8 +417,55 @@ impl EventLog { }) } + pub fn snapshot_state(&self) -> io::Result { + let (state, _guard) = self.freeze()?; + Ok(state) + } + + pub fn freeze(&self) -> io::Result<(EventLogSnapshotState, EventLogFreezeGuard)> { + let (resp_tx, resp_rx) = flume::bounded(1); + let (resume_tx, resume_rx) = flume::bounded(1); + + self.commit_thread + .sender() + .send(WriterRequest::Freeze { + response: resp_tx, + resume: resume_rx, + }) + .map_err(|_| io::Error::other("eventlog writer thread terminated"))?; + + let freeze_resp: FreezeResponse = resp_rx + .recv() + .map_err(|_| io::Error::other("eventlog writer thread terminated"))??; + + let all_segments = self.manager.list_segments()?; + let sealed_segments: Vec = all_segments + .into_iter() + .filter(|&id| id != freeze_resp.segment_id) + .collect(); + + let state = EventLogSnapshotState { + max_seq: freeze_resp.synced_through, + active_segment_id: freeze_resp.segment_id, + active_segment_position: freeze_resp.position, + sealed_segments, + }; + + Ok(( + state, + EventLogFreezeGuard { + _resume: Some(resume_tx), + }, + )) + } + + pub fn segments_dir(&self) -> &std::path::Path { + self.manager.segments_dir() + } + pub fn shutdown(&self) -> io::Result<()> { - self.writer.lock().shutdown() + self.commit_thread.shutdown(); + Ok(()) } pub fn subscriber(&self, start_seq: EventSequence) -> EventLogSubscriber { @@ -337,34 +484,22 @@ impl EventLog { &self.manager } - pub fn consecutive_sync_failures(&self) -> u32 { - self.consecutive_sync_failures.load(Ordering::Relaxed) + fn last_assigned_seq(&self) -> EventSequence { + let raw = self.next_seq.load(Ordering::Acquire); + match raw.checked_sub(1) { + Some(0) | None => EventSequence::BEFORE_ALL, + Some(n) => EventSequence::new(n), + } } } -impl PostBlockstoreHook for EventLog { +impl PostBlockstoreHook for EventLog { fn on_blocks_synced(&self, _proof: &BlocksSynced) -> io::Result<()> { - match self.sync_and_broadcast() { - Ok(_) => { - self.consecutive_sync_failures.store(0, Ordering::Relaxed); - if let Err(e) = self.maybe_rotate() { - warn!(error = %e, "eventlog rotation deferred"); - } - Ok(()) - } - Err(e) => { - let count = self - .consecutive_sync_failures - .fetch_add(1, Ordering::Relaxed) - .saturating_add(1); - warn!( - error = %e, - consecutive_failures = count, - "eventlog sync failed after blockstore commit" - ); - Err(e) - } + let target = self.last_assigned_seq(); + if target == EventSequence::BEFORE_ALL { + return Ok(()); } + self.notify.wait_for_sync(target) } } @@ -469,13 +604,13 @@ impl EventLogSubscriber { } } -fn valid_event_to_raw(e: &ValidEvent) -> RawEvent { +fn valid_event_to_raw(e: ValidEvent) -> RawEvent { RawEvent { seq: e.seq, timestamp: e.timestamp, did_hash: e.did_hash, event_type: e.event_type, - payload: bytes::Bytes::from(e.payload.clone()), + payload: bytes::Bytes::from(e.payload), } } diff --git a/crates/tranquil-store/src/eventlog/payload.rs b/crates/tranquil-store/src/eventlog/payload.rs index 794d53a..1f7d54a 100644 --- a/crates/tranquil-store/src/eventlog/payload.rs +++ b/crates/tranquil-store/src/eventlog/payload.rs @@ -1,14 +1,16 @@ use serde::{Deserialize, Serialize}; -use tranquil_db_traits::{AccountStatus, SequenceNumber, SequencedEvent}; +use tranquil_db_traits::{ + AccountStatus, EventBlockInline, EventBlocks, SequenceNumber, SequencedEvent, +}; use tranquil_types::{CidLink, Did, Handle}; use crate::eventlog::reader::RawEvent; -use crate::eventlog::types::MAX_EVENT_PAYLOAD; -const PAYLOAD_VERSION: u8 = 1; -const LARGE_PAYLOAD_WARNING_THRESHOLD: usize = 1024 * 1024; +pub(crate) const PAYLOAD_VERSION_V1: u8 = 1; +const CURRENT_PAYLOAD_VERSION: u8 = PAYLOAD_VERSION_V1; +const LARGE_PAYLOAD_WARNING_THRESHOLD: usize = 4 * 1024 * 1024; -const CID_BYTE_LEN: usize = 36; +pub(crate) const CID_BYTE_LEN: usize = 36; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EventPayload { @@ -18,7 +20,7 @@ pub struct EventPayload { pub prev_data_cid: Option>, pub ops: Option>, pub blobs: Option>, - pub blocks_cids: Option>, + pub blocks: Option>, pub handle: Option, pub active: Option, pub status: Option, @@ -38,8 +40,8 @@ pub enum PayloadError { InvalidDid(String), #[error("invalid timestamp: {0}")] InvalidTimestamp(u64), - #[error("invalid ops JSON in payload: {0}")] - InvalidOps(serde_json::Error), + #[error("invalid ops DAG-CBOR in payload: {0}")] + InvalidDagCborOps(String), #[error("invalid handle in payload: {0}")] InvalidHandle(String), #[error("invalid CID length: got {got}, expected {expected}")] @@ -96,7 +98,7 @@ pub fn encode_payload_with_mutations( let ops_bytes = event .ops .as_ref() - .map(|v| serde_json::to_vec(v).expect("serde_json::Value always serializes")); + .map(|v| serde_ipld_dagcbor::to_vec(v).expect("serde_json::Value serializes to DAG-CBOR")); let payload = EventPayload { did: event.did.as_str().to_owned(), @@ -105,7 +107,10 @@ pub fn encode_payload_with_mutations( prev_data_cid: event.prev_data_cid.as_ref().and_then(cid_link_to_bytes), ops: ops_bytes, blobs: event.blobs.clone(), - blocks_cids: event.blocks_cids.clone(), + blocks: match event.blocks.as_ref() { + Some(EventBlocks::Inline(v)) => Some(v.clone()), + Some(EventBlocks::LegacyCids(_)) | None => None, + }, handle: event .handle .as_ref() @@ -127,7 +132,7 @@ pub fn encode_payload_with_mutations( } let mut buf = Vec::with_capacity(1 + body.len()); - buf.push(PAYLOAD_VERSION); + buf.push(CURRENT_PAYLOAD_VERSION); buf.extend_from_slice(&body); buf } @@ -137,15 +142,15 @@ pub fn decode_payload(bytes: &[u8]) -> Result { postcard::Error::DeserializeUnexpectedEnd, ))?; - if version != PAYLOAD_VERSION { + if version != PAYLOAD_VERSION_V1 { return Err(PayloadError::UnknownVersion(version)); } postcard::from_bytes(body).map_err(PayloadError::DeserializeFailed) } -pub fn validate_payload_size(payload: &[u8]) -> Result<(), PayloadError> { - let max = MAX_EVENT_PAYLOAD as usize; +pub fn validate_payload_size(payload: &[u8], max_payload: u32) -> Result<(), PayloadError> { + let max = max_payload as usize; if payload.len() > max { return Err(PayloadError::TooLarge { size: payload.len(), @@ -174,9 +179,11 @@ pub fn to_sequenced_event( let ops = payload .ops .as_ref() - .map(|bytes| serde_json::from_slice(bytes)) - .transpose() - .map_err(PayloadError::InvalidOps)?; + .map(|bytes| { + serde_ipld_dagcbor::from_slice(bytes) + .map_err(|e| PayloadError::InvalidDagCborOps(e.to_string())) + }) + .transpose()?; let handle = payload .handle @@ -209,7 +216,7 @@ pub fn to_sequenced_event( .flatten(), ops, blobs: payload.blobs.clone(), - blocks_cids: payload.blocks_cids.clone(), + blocks: payload.blocks.clone().map(EventBlocks::Inline), handle, active: payload.active, status: payload.status.and_then(u8_to_account_status), @@ -248,7 +255,7 @@ mod tests { prev_data_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, handle: None, active: Some(true), status: Some(AccountStatus::Active), @@ -256,7 +263,7 @@ mod tests { }; let encoded = encode_payload(&event); - assert_eq!(encoded[0], PAYLOAD_VERSION); + assert_eq!(encoded[0], CURRENT_PAYLOAD_VERSION); let decoded = decode_payload(&encoded).unwrap(); assert_eq!(decoded.did, event.did.as_str()); @@ -280,7 +287,10 @@ mod tests { prev_data_cid: Some(cid.clone()), ops: Some(ops.clone()), blobs: Some(vec!["bafkreibtest".to_owned()]), - blocks_cids: Some(vec!["bafyreiblock".to_owned()]), + blocks: Some(EventBlocks::Inline(vec![EventBlockInline { + cid_bytes: cid_link_to_bytes(&cid).unwrap(), + data: b"hello block".to_vec(), + }])), handle: Some(Handle::new("test.bsky.social").unwrap()), active: None, status: None, @@ -304,7 +314,14 @@ mod tests { assert_eq!(reconstructed.prev_cid, event.prev_cid); assert_eq!(reconstructed.prev_data_cid, event.prev_data_cid); assert_eq!(reconstructed.blobs, event.blobs); - assert_eq!(reconstructed.blocks_cids, event.blocks_cids); + let inline_len = |b: &EventBlocks| match b { + EventBlocks::Inline(v) => v.len(), + EventBlocks::LegacyCids(_) => 0, + }; + assert_eq!( + reconstructed.blocks.as_ref().map(inline_len), + event.blocks.as_ref().map(inline_len) + ); assert_eq!( reconstructed.handle.as_ref().map(|h: &Handle| h.as_str()), event.handle.as_ref().map(|h: &Handle| h.as_str()) @@ -328,7 +345,7 @@ mod tests { prev_data_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, handle: None, active: None, status: None, @@ -352,17 +369,18 @@ mod tests { #[test] fn validate_payload_size_accepts_within_limit() { - let data = vec![0u8; MAX_EVENT_PAYLOAD as usize]; - assert!(validate_payload_size(&data).is_ok()); + let data = vec![0u8; 1024]; + assert!(validate_payload_size(&data, 4096).is_ok()); } #[test] fn validate_payload_size_rejects_oversized() { - let data = vec![0u8; MAX_EVENT_PAYLOAD as usize + 1]; - match validate_payload_size(&data) { + let limit: u32 = 1024; + let data = vec![0u8; limit as usize + 1]; + match validate_payload_size(&data, limit) { Err(PayloadError::TooLarge { size, max }) => { - assert_eq!(size, MAX_EVENT_PAYLOAD as usize + 1); - assert_eq!(max, MAX_EVENT_PAYLOAD as usize); + assert_eq!(size, limit as usize + 1); + assert_eq!(max, limit as usize); } other => panic!("expected TooLarge, got {other:?}"), } @@ -436,7 +454,7 @@ mod tests { timestamp: TimestampMicros::new(us), did_hash: DidHash::from_did("did:plc:test"), event_type: EventTypeTag::COMMIT, - payload: Bytes::new(), + payload: Bytes::from_static(&[PAYLOAD_VERSION_V1]), }; let payload = EventPayload { @@ -446,7 +464,7 @@ mod tests { prev_data_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, handle: None, active: None, status: None, diff --git a/crates/tranquil-store/src/eventlog/reader.rs b/crates/tranquil-store/src/eventlog/reader.rs index 80c407f..309a6fc 100644 --- a/crates/tranquil-store/src/eventlog/reader.rs +++ b/crates/tranquil-store/src/eventlog/reader.rs @@ -12,12 +12,34 @@ use crate::io::{MappedFile, StorageIO}; use super::manager::SegmentManager; use super::segment_file::{ReadEventRecord, SEGMENT_HEADER_SIZE, decode_event_record}; use super::segment_index::{DEFAULT_INDEX_INTERVAL, SegmentIndex, rebuild_from_segment}; +use super::sidecar::{SidecarIndex, build_sidecar_from_segment}; use super::types::{ DidHash, EventSequence, EventTypeTag, SegmentId, SegmentOffset, TimestampMicros, }; const FIRST_EVENT_OFFSET: SegmentOffset = SegmentOffset::new(SEGMENT_HEADER_SIZE as u64); +#[derive(Debug, Clone)] +pub struct SequenceGap { + pub after_segment: SegmentId, + pub expected_seq: EventSequence, + pub actual_seq: EventSequence, +} + +#[derive(Debug, Clone)] +pub struct SequenceContiguityResult { + pub total_segments: u64, + pub min_seq: Option, + pub max_seq: Option, + pub gaps: Vec, +} + +impl SequenceContiguityResult { + pub fn is_contiguous(&self) -> bool { + self.gaps.is_empty() + } +} + #[derive(Debug, Clone)] pub struct RawEvent { pub seq: EventSequence, @@ -37,29 +59,45 @@ struct SegmentRange { pub struct EventLogReader { manager: Arc>, indexes: RwLock>>, + sidecars: RwLock>>, ranges: RwLock>, mmaps: RwLock>>, active_segment: RwLock>, use_mmap: bool, + skip_sealed_checksum: bool, + max_payload: u32, } impl EventLogReader { - pub fn new(manager: Arc>, use_mmap: bool) -> Self { + pub fn new( + manager: Arc>, + use_mmap: bool, + skip_sealed_checksum: bool, + max_payload: u32, + ) -> Self { Self { manager, indexes: RwLock::new(HashMap::new()), + sidecars: RwLock::new(HashMap::new()), ranges: RwLock::new(Vec::new()), mmaps: RwLock::new(HashMap::new()), active_segment: RwLock::new(None), use_mmap, + skip_sealed_checksum, + max_payload, } } + pub fn max_payload(&self) -> u32 { + self.max_payload + } + pub fn set_active_segment(&self, id: SegmentId) { *self.active_segment.write() = Some(id); } pub fn extend_active_range(&self, first_seq: EventSequence, last_seq: EventSequence) { + debug_assert!(first_seq <= last_seq); let active_id = match *self.active_segment.read() { Some(id) => id, None => return, @@ -108,7 +146,12 @@ impl EventLogReader { fn rebuild_index(&self, segment_id: SegmentId) -> io::Result { let fd = self.manager.open_for_read(segment_id)?; - let (idx, _) = rebuild_from_segment(self.manager.io(), fd, DEFAULT_INDEX_INTERVAL)?; + let (idx, _) = rebuild_from_segment( + self.manager.io(), + fd, + DEFAULT_INDEX_INTERVAL, + self.max_payload, + )?; let _ = idx.save(self.manager.io(), &self.manager.index_path(segment_id)); Ok(idx) } @@ -159,6 +202,34 @@ impl EventLogReader { Ok(()) } + pub fn check_sequence_contiguity(&self) -> SequenceContiguityResult { + let ranges = self.ranges.read(); + let mut gaps: Vec = Vec::new(); + let total_segments = ranges.len() as u64; + + ranges.windows(2).for_each(|pair| { + let expected_next = pair[0].last.next(); + let actual_next = pair[1].first; + if actual_next != expected_next { + gaps.push(SequenceGap { + after_segment: pair[0].id, + expected_seq: expected_next, + actual_seq: actual_next, + }); + } + }); + + let max_seq = ranges.last().map(|r| r.last); + let min_seq = ranges.first().map(|r| r.first); + + SequenceContiguityResult { + total_segments, + min_seq, + max_seq, + gaps, + } + } + fn is_mmap_eligible(&self, segment_id: SegmentId) -> bool { self.use_mmap && self @@ -222,15 +293,27 @@ impl EventLogReader { mut predicate: impl FnMut(&EventSequence) -> bool, ) -> io::Result { let mmap = self.get_mmap(segment_id)?; + let mmap_bytes = Bytes::from_owner(OwnedMmap(Arc::clone(&mmap))); let data: &[u8] = (*mmap).as_ref(); let file_size = data.len() as u64; + let skip_checksum = self.skip_sealed_checksum && self.is_mmap_eligible(segment_id); let offset = Cell::new(start_offset); let collected = Cell::new(0usize); + let max_payload = self.max_payload; std::iter::from_fn(|| { let cur = offset.get(); - (cur.raw() < file_size && collected.get() < limit) - .then(|| decode_mmap_event(data, cur, file_size, segment_id)) + (cur.raw() < file_size && collected.get() < limit).then(|| { + decode_mmap_event( + data, + &mmap_bytes, + cur, + file_size, + segment_id, + skip_checksum, + max_payload, + ) + }) }) .try_for_each(|result| -> io::Result<()> { match result? { @@ -268,8 +351,9 @@ impl EventLogReader { std::iter::from_fn(|| { let cur = offset.get(); - (cur.raw() < file_size && collected.get() < limit) - .then(|| decode_event_record(self.manager.io(), fd, cur, file_size)) + (cur.raw() < file_size && collected.get() < limit).then(|| { + decode_event_record(self.manager.io(), fd, cur, file_size, self.max_payload) + }) }) .try_for_each(|result| -> io::Result<()> { match result? { @@ -320,14 +404,13 @@ impl EventLogReader { }; let mut events = Vec::with_capacity(limit.min(1024)); + let done = Cell::new(false); - ranges[start_idx..].iter().enumerate().try_fold( - false, - |limit_reached, (i, range)| -> io::Result { - if limit_reached { - return Ok(true); - } - + ranges[start_idx..] + .iter() + .enumerate() + .take_while(|_| !done.get()) + .try_for_each(|(i, range)| -> io::Result<()> { let remaining = limit - events.len(); let is_first = i == 0; @@ -341,16 +424,16 @@ impl EventLogReader { } }; - self.scan_events_from_offset( + done.set(self.scan_events_from_offset( range.id, scan_offset, effective_seq, remaining, &mut events, |_| true, - ) - }, - )?; + )?); + Ok(()) + })?; Ok(events) } @@ -384,6 +467,7 @@ impl EventLogReader { ) -> io::Result<()> { self.invalidate_index(sealed_id); self.invalidate_mmap(sealed_id); + self.invalidate_sidecar(sealed_id); self.set_active_segment(new_active_id); self.refresh_segment_ranges() } @@ -395,6 +479,53 @@ impl EventLogReader { pub fn invalidate_index(&self, segment_id: SegmentId) { self.indexes.write().remove(&segment_id); } + + pub fn invalidate_sidecar(&self, segment_id: SegmentId) { + self.sidecars.write().remove(&segment_id); + } + + pub fn load_sidecar(&self, segment_id: SegmentId) -> io::Result>> { + if let Some(sc) = self.sidecars.read().get(&segment_id) { + return Ok(Some(Arc::clone(sc))); + } + + let sidecar = match SidecarIndex::load( + self.manager.io(), + &self.manager.sidecar_path(segment_id), + ) { + Ok(Some(sc)) => sc, + Ok(None) => return Ok(None), + Err(e) => { + warn!(segment = %segment_id, error = %e, "sidecar load failed, attempting rebuild"); + match self.rebuild_sidecar(segment_id) { + Ok(sc) => sc, + Err(rebuild_err) => { + warn!(segment = %segment_id, error = %rebuild_err, "sidecar rebuild also failed"); + return Ok(None); + } + } + } + }; + + let arc = Arc::new(sidecar); + self.sidecars.write().insert(segment_id, Arc::clone(&arc)); + Ok(Some(arc)) + } + + fn rebuild_sidecar(&self, segment_id: SegmentId) -> io::Result { + let fd = self.manager.open_for_read(segment_id)?; + let sidecar = build_sidecar_from_segment(self.manager.io(), fd, self.max_payload)?; + let _ = sidecar.save(self.manager.io(), &self.manager.sidecar_path(segment_id)); + Ok(sidecar) + } +} + +struct OwnedMmap(Arc); + +impl AsRef<[u8]> for OwnedMmap { + fn as_ref(&self) -> &[u8] { + (*self.0).as_ref() + } } enum MmapDecodeResult { @@ -406,12 +537,14 @@ enum MmapDecodeResult { fn decode_mmap_event( data: &[u8], + mmap_bytes: &Bytes, offset: SegmentOffset, file_size: u64, segment_id: SegmentId, + skip_checksum: bool, + max_payload: u32, ) -> io::Result { use super::segment_file::EVENT_HEADER_SIZE; - use super::types::MAX_EVENT_PAYLOAD; let raw = offset.raw(); if raw > file_size { @@ -469,7 +602,7 @@ fn decode_mmap_event( }; let payload_len = u32::from_le_bytes(header_slice[21..25].try_into().unwrap()); - if payload_len > MAX_EVENT_PAYLOAD { + if payload_len > max_payload { warn!( segment = %segment_id, offset = raw, @@ -494,25 +627,27 @@ fn decode_mmap_event( let payload_start = base + EVENT_HEADER_SIZE; let payload_end = payload_start + usize::try_from(payload_len).expect("payload_len fits usize"); - let checksum_start = payload_end; - let stored_checksum = - u32::from_le_bytes(data[checksum_start..checksum_start + 4].try_into().unwrap()); + if !skip_checksum { + let checksum_start = payload_end; + let stored_checksum = + u32::from_le_bytes(data[checksum_start..checksum_start + 4].try_into().unwrap()); - let mut hasher = xxhash_rust::xxh3::Xxh3::new(); - hasher.update(header_slice); - hasher.update(&data[payload_start..payload_end]); - let computed = hasher.digest() as u32; + let mut hasher = xxhash_rust::xxh3::Xxh3::new(); + hasher.update(header_slice); + hasher.update(&data[payload_start..payload_end]); + let computed = hasher.digest() as u32; - if stored_checksum != computed { - warn!( - segment = %segment_id, - offset = raw, - seq = %seq, - stored = stored_checksum, - computed, - "corrupted record in sealed segment: checksum mismatch" - ); - return Ok(MmapDecodeResult::Corrupted); + if stored_checksum != computed { + warn!( + segment = %segment_id, + offset = raw, + seq = %seq, + stored = stored_checksum, + computed, + "corrupted record in sealed segment: checksum mismatch" + ); + return Ok(MmapDecodeResult::Corrupted); + } } let next_offset = offset.advance(record_size); @@ -522,7 +657,7 @@ fn decode_mmap_event( timestamp, did_hash, event_type, - payload: Bytes::copy_from_slice(&data[payload_start..payload_end]), + payload: mmap_bytes.slice(payload_start..payload_end), }, next_offset, )) @@ -532,6 +667,7 @@ fn decode_mmap_event( mod tests { use super::*; use crate::eventlog::segment_file::EVENT_RECORD_OVERHEAD; + use crate::eventlog::types::MAX_EVENT_PAYLOAD; use crate::eventlog::writer::EventLogWriter; use crate::sim::SimulatedIO; use std::path::PathBuf; @@ -552,7 +688,8 @@ mod tests { let mgr = setup_manager(max_segment_size); { let mut writer = - EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=event_count).for_each(|i| { writer .append( @@ -566,7 +703,7 @@ mod tests { } mgr.shutdown(); - let reader = EventLogReader::new(Arc::clone(&mgr), false); + let reader = EventLogReader::new(Arc::clone(&mgr), false, false, MAX_EVENT_PAYLOAD); reader.refresh_segment_ranges().unwrap(); (mgr, reader) } @@ -586,7 +723,8 @@ mod tests { let mgr = setup_manager(max_segment_size); { let mut writer = - EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); let total = events_per_segment * num_segments; (1..=total).for_each(|i| { writer @@ -605,7 +743,7 @@ mod tests { } mgr.shutdown(); - let reader = EventLogReader::new(Arc::clone(&mgr), false); + let reader = EventLogReader::new(Arc::clone(&mgr), false, false, MAX_EVENT_PAYLOAD); reader.refresh_segment_ranges().unwrap(); (mgr, reader) } @@ -777,7 +915,7 @@ mod tests { fn mmap_read_matches_direct_read() { let (mgr, direct_reader) = setup_with_events(10, 50, 64 * 1024); - let mmap_reader = EventLogReader::new(Arc::clone(&mgr), true); + let mmap_reader = EventLogReader::new(Arc::clone(&mgr), true, false, MAX_EVENT_PAYLOAD); mmap_reader.refresh_segment_ranges().unwrap(); let direct_events = direct_reader @@ -820,7 +958,7 @@ mod tests { #[test] fn empty_reader_returns_empty() { let mgr = setup_manager(64 * 1024); - let reader = EventLogReader::new(Arc::clone(&mgr), false); + let reader = EventLogReader::new(Arc::clone(&mgr), false, false, MAX_EVENT_PAYLOAD); reader.refresh_segment_ranges().unwrap(); let events = reader @@ -858,7 +996,8 @@ mod tests { let mgr = setup_manager(64 * 1024); { let mut writer = - EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); let types = [ EventTypeTag::COMMIT, EventTypeTag::IDENTITY, @@ -878,7 +1017,7 @@ mod tests { } mgr.shutdown(); - let reader = EventLogReader::new(Arc::clone(&mgr), false); + let reader = EventLogReader::new(Arc::clone(&mgr), false, false, MAX_EVENT_PAYLOAD); reader.refresh_segment_ranges().unwrap(); let events = reader @@ -894,7 +1033,7 @@ mod tests { fn active_segment_excludes_mmap() { let (mgr, _) = setup_with_events(10, 50, 64 * 1024); - let reader = EventLogReader::new(Arc::clone(&mgr), true); + let reader = EventLogReader::new(Arc::clone(&mgr), true, false, MAX_EVENT_PAYLOAD); reader.set_active_segment(SegmentId::new(1)); reader.refresh_segment_ranges().unwrap(); @@ -905,7 +1044,7 @@ mod tests { #[test] fn no_active_segment_mmaps_all() { let reader: EventLogReader = - EventLogReader::new(setup_manager(64 * 1024), true); + EventLogReader::new(setup_manager(64 * 1024), true, false, MAX_EVENT_PAYLOAD); assert!(reader.is_mmap_eligible(SegmentId::new(1))); assert!(reader.is_mmap_eligible(SegmentId::new(99))); @@ -916,7 +1055,8 @@ mod tests { let mgr = setup_manager(64 * 1024); { let mut writer = - EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=5).for_each(|i| { writer .append( @@ -937,7 +1077,7 @@ mod tests { .save(mgr.io(), &mgr.index_path(SegmentId::new(1))) .unwrap(); - let reader = EventLogReader::new(Arc::clone(&mgr), false); + let reader = EventLogReader::new(Arc::clone(&mgr), false, false, MAX_EVENT_PAYLOAD); reader.refresh_segment_ranges().unwrap(); let events = reader @@ -954,7 +1094,8 @@ mod tests { let mgr = setup_manager(64 * 1024); { let mut writer = - EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=5).for_each(|i| { writer .append( @@ -975,7 +1116,7 @@ mod tests { .save(mgr.io(), &mgr.index_path(SegmentId::new(1))) .unwrap(); - let reader = EventLogReader::new(Arc::clone(&mgr), true); + let reader = EventLogReader::new(Arc::clone(&mgr), true, false, MAX_EVENT_PAYLOAD); reader.refresh_segment_ranges().unwrap(); let events = reader @@ -988,7 +1129,7 @@ mod tests { fn on_segment_rotated_updates_state() { let (mgr, _direct_reader) = setup_multi_segment(3, 2, 50); - let reader = EventLogReader::new(Arc::clone(&mgr), true); + let reader = EventLogReader::new(Arc::clone(&mgr), true, false, MAX_EVENT_PAYLOAD); reader.refresh_segment_ranges().unwrap(); assert_eq!( diff --git a/crates/tranquil-store/src/eventlog/segment_file.rs b/crates/tranquil-store/src/eventlog/segment_file.rs index 7a3ddc8..5384fb0 100644 --- a/crates/tranquil-store/src/eventlog/segment_file.rs +++ b/crates/tranquil-store/src/eventlog/segment_file.rs @@ -3,8 +3,7 @@ use std::io; use crate::io::{FileId, StorageIO}; use super::types::{ - DidHash, EventSequence, EventTypeTag, MAX_EVENT_PAYLOAD, SegmentId, SegmentOffset, - TimestampMicros, + DidHash, EventSequence, EventTypeTag, SegmentId, SegmentOffset, TimestampMicros, }; pub const SEGMENT_MAGIC: [u8; 4] = *b"TQEV"; @@ -46,6 +45,7 @@ pub fn encode_event_record( fd: FileId, offset: SegmentOffset, event: &ValidEvent, + max_payload: u32, ) -> io::Result { let payload_len = u32::try_from(event.payload.len()).map_err(|_| { io::Error::new( @@ -53,10 +53,10 @@ pub fn encode_event_record( "event payload exceeds u32::MAX", ) })?; - if payload_len > MAX_EVENT_PAYLOAD { + if payload_len > max_payload { return Err(io::Error::new( io::ErrorKind::InvalidInput, - "event payload exceeds MAX_EVENT_PAYLOAD", + format!("event payload {payload_len} exceeds configured max_payload {max_payload}"), )); } @@ -96,6 +96,7 @@ pub fn decode_event_record( fd: FileId, offset: SegmentOffset, file_size: u64, + max_payload: u32, ) -> io::Result> { let raw = offset.raw(); if raw > file_size { @@ -128,7 +129,7 @@ pub fn decode_event_record( }; let payload_len = u32::from_le_bytes(header[21..25].try_into().unwrap()); - if payload_len > MAX_EVENT_PAYLOAD { + if payload_len > max_payload { return Ok(Some(ReadEventRecord::Corrupted { offset })); } @@ -186,6 +187,7 @@ pub fn validate_event_record( fd: FileId, offset: SegmentOffset, file_size: u64, + max_payload: u32, ) -> io::Result> { let raw = offset.raw(); assert!( @@ -216,7 +218,7 @@ pub fn validate_event_record( } let payload_len = u32::from_le_bytes(header[21..25].try_into().unwrap()); - if payload_len > MAX_EVENT_PAYLOAD { + if payload_len > max_payload { return Ok(Some(ValidateEventRecord::Corrupted)); } @@ -266,6 +268,7 @@ pub struct SegmentWriter { position: SegmentOffset, base_seq: EventSequence, last_seq: Option, + max_payload: u32, } impl SegmentWriter { @@ -274,6 +277,7 @@ impl SegmentWriter { fd: FileId, segment_id: SegmentId, base_seq: EventSequence, + max_payload: u32, ) -> io::Result { let mut header = [0u8; SEGMENT_HEADER_SIZE]; header[..4].copy_from_slice(&SEGMENT_MAGIC); @@ -285,6 +289,7 @@ impl SegmentWriter { position: SegmentOffset::new(SEGMENT_HEADER_SIZE as u64), base_seq, last_seq: None, + max_payload, }) } @@ -295,6 +300,7 @@ impl SegmentWriter { position: SegmentOffset, base_seq: EventSequence, last_seq: Option, + max_payload: u32, ) -> Self { assert!( position.raw() >= SEGMENT_HEADER_SIZE as u64, @@ -315,9 +321,14 @@ impl SegmentWriter { position, base_seq, last_seq, + max_payload, } } + pub fn max_payload(&self) -> u32 { + self.max_payload + } + pub fn append_event( &mut self, io: &S, @@ -330,7 +341,8 @@ impl SegmentWriter { self.last_seq.unwrap() ); let record_offset = self.position; - let bytes_written = encode_event_record(io, self.fd, record_offset, event)?; + let bytes_written = + encode_event_record(io, self.fd, record_offset, event, self.max_payload)?; self.position = self.position.advance(bytes_written); self.last_seq = Some(event.seq); Ok(record_offset) @@ -362,10 +374,11 @@ pub struct SegmentReader<'a, S: StorageIO> { fd: FileId, position: SegmentOffset, file_size: u64, + max_payload: u32, } impl<'a, S: StorageIO> SegmentReader<'a, S> { - pub fn open(io: &'a S, fd: FileId) -> io::Result { + pub fn open(io: &'a S, fd: FileId, max_payload: u32) -> io::Result { let file_size = io.file_size(fd)?; if file_size < SEGMENT_HEADER_SIZE as u64 { return Err(io::Error::new( @@ -395,9 +408,14 @@ impl<'a, S: StorageIO> SegmentReader<'a, S> { fd, position: SegmentOffset::new(SEGMENT_HEADER_SIZE as u64), file_size, + max_payload, }) } + pub fn max_payload(&self) -> u32 { + self.max_payload + } + pub fn valid_prefix(self) -> io::Result> { self.map(|result| { result.map(|record| match record { @@ -430,7 +448,13 @@ impl Iterator for SegmentReader<'_, S> { type Item = io::Result; fn next(&mut self) -> Option { - match decode_event_record(self.io, self.fd, self.position, self.file_size) { + match decode_event_record( + self.io, + self.fd, + self.position, + self.file_size, + self.max_payload, + ) { Err(e) => { self.position = SegmentOffset::new(self.file_size); Some(Err(e)) @@ -455,6 +479,7 @@ impl Iterator for SegmentReader<'_, S> { mod tests { use super::*; use crate::OpenOptions; + use crate::eventlog::types::MAX_EVENT_PAYLOAD; use crate::sim::SimulatedIO; use proptest::prelude::*; use std::path::Path; @@ -487,8 +512,14 @@ mod tests { #[test] fn write_and_read_single_event() { let (sim, fd) = setup(); - let mut writer = - SegmentWriter::new(&sim, fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); + let mut writer = SegmentWriter::new( + &sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); let event = test_event(1, b"test event payload"); let offset = writer.append_event(&sim, &event).unwrap(); @@ -496,7 +527,7 @@ mod tests { assert_eq!(offset, SegmentOffset::new(SEGMENT_HEADER_SIZE as u64)); - let reader = SegmentReader::open(&sim, fd).unwrap(); + let reader = SegmentReader::open(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); let events = reader.valid_prefix().unwrap(); assert_eq!(events.len(), 1); assert_eq!(events[0], event); @@ -505,8 +536,14 @@ mod tests { #[test] fn write_and_read_multiple_events() { let (sim, fd) = setup(); - let mut writer = - SegmentWriter::new(&sim, fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); + let mut writer = SegmentWriter::new( + &sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); let written: Vec = (1u64..=3) .map(|i| { @@ -517,7 +554,7 @@ mod tests { .collect(); writer.sync(&sim).unwrap(); - let reader = SegmentReader::open(&sim, fd).unwrap(); + let reader = SegmentReader::open(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); let events = reader.valid_prefix().unwrap(); assert_eq!(events, written); } @@ -525,9 +562,16 @@ mod tests { #[test] fn empty_segment_has_no_events() { let (sim, fd) = setup(); - SegmentWriter::new(&sim, fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); + SegmentWriter::new( + &sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); - let reader = SegmentReader::open(&sim, fd).unwrap(); + let reader = SegmentReader::open(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); let events = reader.valid_prefix().unwrap(); assert!(events.is_empty()); } @@ -535,8 +579,14 @@ mod tests { #[test] fn detects_truncated_event() { let (sim, fd) = setup(); - let mut writer = - SegmentWriter::new(&sim, fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); + let mut writer = SegmentWriter::new( + &sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); writer .append_event(&sim, &test_event(1, b"complete event")) .unwrap(); @@ -546,7 +596,7 @@ mod tests { .unwrap(); sim.sync(fd).unwrap(); - let mut reader = SegmentReader::open(&sim, fd).unwrap(); + let mut reader = SegmentReader::open(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); let first = reader.next().unwrap().unwrap(); assert!(matches!(first, ReadEventRecord::Valid { .. })); @@ -557,8 +607,14 @@ mod tests { #[test] fn checksum_detects_corruption() { let (sim, fd) = setup(); - let mut writer = - SegmentWriter::new(&sim, fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); + let mut writer = SegmentWriter::new( + &sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); writer .append_event(&sim, &test_event(1, &vec![0xAA; 256])) .unwrap(); @@ -567,7 +623,7 @@ mod tests { let corrupt_offset = SEGMENT_HEADER_SIZE as u64 + EVENT_HEADER_SIZE as u64 + 128; sim.write_all_at(fd, corrupt_offset, &[0x00]).unwrap(); - let mut reader = SegmentReader::open(&sim, fd).unwrap(); + let mut reader = SegmentReader::open(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); let record = reader.next().unwrap().unwrap(); assert!(matches!(record, ReadEventRecord::Corrupted { .. })); } @@ -575,8 +631,14 @@ mod tests { #[test] fn crash_before_sync_loses_events() { let (sim, fd) = setup(); - let mut writer = - SegmentWriter::new(&sim, fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); + let mut writer = SegmentWriter::new( + &sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); writer .append_event(&sim, &test_event(1, b"synced")) .unwrap(); @@ -592,7 +654,7 @@ mod tests { let fd = sim .open(Path::new("/test/segment.tqe"), OpenOptions::read()) .unwrap(); - let reader = SegmentReader::open(&sim, fd).unwrap(); + let reader = SegmentReader::open(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); let events = reader.valid_prefix().unwrap(); assert_eq!(events.len(), 1); assert_eq!(events[0].payload, b"synced"); @@ -601,20 +663,76 @@ mod tests { #[test] fn rejects_oversized_payload() { let (sim, fd) = setup(); - let mut writer = - SegmentWriter::new(&sim, fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); - let result = writer.append_event( + const SMALL_MAX: u32 = 1024; + let mut writer = SegmentWriter::new( &sim, - &test_event(1, &vec![0u8; MAX_EVENT_PAYLOAD as usize + 1]), - ); + fd, + SegmentId::new(1), + EventSequence::new(1), + SMALL_MAX, + ) + .unwrap(); + let result = writer.append_event(&sim, &test_event(1, &vec![0u8; SMALL_MAX as usize + 1])); assert!(result.is_err()); } + #[test] + fn reader_rejects_corrupt_header_claiming_oversize() { + let (sim, fd) = setup(); + const SMALL_MAX: u32 = 1024; + let mut writer = SegmentWriter::new( + &sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); + writer + .append_event(&sim, &test_event(1, &vec![0xAA; 2048])) + .unwrap(); + writer.sync(&sim).unwrap(); + + let mut reader = SegmentReader::open(&sim, fd, SMALL_MAX).unwrap(); + let record = reader.next().unwrap().unwrap(); + assert!(matches!(record, ReadEventRecord::Corrupted { .. })); + } + + #[test] + fn reader_with_larger_max_reads_writer_segment() { + let (sim, fd) = setup(); + const WRITER_MAX: u32 = 16 * 1024; + const READER_MAX: u32 = 1024 * 1024 * 1024; + let mut writer = SegmentWriter::new( + &sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + WRITER_MAX, + ) + .unwrap(); + writer + .append_event(&sim, &test_event(1, &vec![0xCD; 8 * 1024])) + .unwrap(); + writer.sync(&sim).unwrap(); + + let reader = SegmentReader::open(&sim, fd, READER_MAX).unwrap(); + let events = reader.valid_prefix().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].payload.len(), 8 * 1024); + } + #[test] fn zero_length_payload_round_trips() { let (sim, fd) = setup(); - let mut writer = - SegmentWriter::new(&sim, fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); + let mut writer = SegmentWriter::new( + &sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); let event = ValidEvent { seq: EventSequence::new(1), timestamp: TimestampMicros::new(1_000_000), @@ -625,7 +743,7 @@ mod tests { writer.append_event(&sim, &event).unwrap(); writer.sync(&sim).unwrap(); - let reader = SegmentReader::open(&sim, fd).unwrap(); + let reader = SegmentReader::open(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); let events = reader.valid_prefix().unwrap(); assert_eq!(events, vec![event]); } @@ -633,12 +751,16 @@ mod tests { #[test] fn accepts_exact_max_payload() { let (sim, fd) = setup(); - let mut writer = - SegmentWriter::new(&sim, fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); - let result = writer.append_event( + const SMALL_MAX: u32 = 4096; + let mut writer = SegmentWriter::new( &sim, - &test_event(1, &vec![0xBB; MAX_EVENT_PAYLOAD as usize]), - ); + fd, + SegmentId::new(1), + EventSequence::new(1), + SMALL_MAX, + ) + .unwrap(); + let result = writer.append_event(&sim, &test_event(1, &vec![0xBB; SMALL_MAX as usize])); assert!(result.is_ok()); } @@ -653,7 +775,7 @@ mod tests { .unwrap(); sim.write_all_at(fd, 0, b"NOPE\x01").unwrap(); - let result = SegmentReader::open(&sim, fd); + let result = SegmentReader::open(&sim, fd, MAX_EVENT_PAYLOAD); assert!(result.is_err()); } @@ -671,12 +793,13 @@ mod tests { event_type: EventTypeTag::ACCOUNT, payload: b"round trip test data".to_vec(), }; - let bytes_written = encode_event_record(&sim, fd, offset, &event).unwrap(); + let bytes_written = + encode_event_record(&sim, fd, offset, &event, MAX_EVENT_PAYLOAD).unwrap(); let expected_size = EVENT_RECORD_OVERHEAD as u64 + event.payload.len() as u64; assert_eq!(bytes_written, expected_size); let file_size = sim.file_size(fd).unwrap(); - let record = decode_event_record(&sim, fd, offset, file_size) + let record = decode_event_record(&sim, fd, offset, file_size, MAX_EVENT_PAYLOAD) .unwrap() .unwrap(); match record { @@ -688,8 +811,14 @@ mod tests { #[test] fn resume_writer_continues_at_position() { let (sim, fd) = setup(); - let mut writer = - SegmentWriter::new(&sim, fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); + let mut writer = SegmentWriter::new( + &sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); writer.append_event(&sim, &test_event(1, b"first")).unwrap(); writer.sync(&sim).unwrap(); @@ -701,13 +830,14 @@ mod tests { resume_pos, EventSequence::new(1), Some(EventSequence::new(1)), + MAX_EVENT_PAYLOAD, ); writer2 .append_event(&sim, &test_event(2, b"second")) .unwrap(); writer2.sync(&sim).unwrap(); - let reader = SegmentReader::open(&sim, fd).unwrap(); + let reader = SegmentReader::open(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); let events = reader.valid_prefix().unwrap(); assert_eq!(events.len(), 2); assert_eq!(events[0].payload, b"first"); @@ -717,8 +847,14 @@ mod tests { #[test] fn all_event_types_round_trip() { let (sim, fd) = setup(); - let mut writer = - SegmentWriter::new(&sim, fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); + let mut writer = SegmentWriter::new( + &sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); let types = [ EventTypeTag::COMMIT, @@ -739,7 +875,7 @@ mod tests { }); writer.sync(&sim).unwrap(); - let reader = SegmentReader::open(&sim, fd).unwrap(); + let reader = SegmentReader::open(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); let events = reader.valid_prefix().unwrap(); assert_eq!(events.len(), 4); events @@ -753,7 +889,14 @@ mod tests { #[test] fn seq_zero_detected_as_corrupted() { let (sim, fd) = setup(); - SegmentWriter::new(&sim, fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); + SegmentWriter::new( + &sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); let mut raw_header = [0u8; EVENT_HEADER_SIZE]; raw_header[0..8].copy_from_slice(&0u64.to_le_bytes()); @@ -777,7 +920,7 @@ mod tests { ) .unwrap(); - let mut reader = SegmentReader::open(&sim, fd).unwrap(); + let mut reader = SegmentReader::open(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); let record = reader.next().unwrap().unwrap(); assert!(matches!(record, ReadEventRecord::Corrupted { .. })); } @@ -785,8 +928,14 @@ mod tests { #[test] fn writer_accessors() { let (sim, fd) = setup(); - let writer = - SegmentWriter::new(&sim, fd, SegmentId::new(7), EventSequence::new(100)).unwrap(); + let writer = SegmentWriter::new( + &sim, + fd, + SegmentId::new(7), + EventSequence::new(100), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); assert_eq!(writer.segment_id(), SegmentId::new(7)); assert_eq!(writer.base_seq(), EventSequence::new(100)); assert_eq!( @@ -804,9 +953,13 @@ mod tests { let written_count = if let Ok(fd) = sim.open(Path::new("/data/segment.tqe"), OpenOptions::read_write()) { - if let Ok(mut writer) = - SegmentWriter::new(&sim, fd, SegmentId::new(1), EventSequence::new(1)) - { + if let Ok(mut writer) = SegmentWriter::new( + &sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) { let count = (1u64..=20).fold(0u64, |count, i| { let event = ValidEvent { seq: EventSequence::new(i), @@ -833,7 +986,7 @@ mod tests { sim.crash(); if let Ok(fd) = sim.open(Path::new("/data/segment.tqe"), OpenOptions::read()) - && let Ok(reader) = SegmentReader::open(&sim, fd) + && let Ok(reader) = SegmentReader::open(&sim, fd, MAX_EVENT_PAYLOAD) { let recovered: Vec<_> = reader .map_while(|r| match r { @@ -878,8 +1031,14 @@ mod tests { let fd = sim .open(Path::new("/data/segment.tqe"), OpenOptions::read_write()) .unwrap(); - let mut writer = - SegmentWriter::new(&sim, fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); + let mut writer = SegmentWriter::new( + &sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); let data_len = ((seed % 256) as usize).max(1); let event = ValidEvent { @@ -902,7 +1061,7 @@ mod tests { byte_buf[0] ^= 1 << flip_bit; sim.write_all_at(fd, flip_pos, &byte_buf).unwrap(); - let mut reader = SegmentReader::open(&sim, fd).unwrap(); + let mut reader = SegmentReader::open(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); let record = reader.next().unwrap().unwrap(); assert!( !matches!(record, ReadEventRecord::Valid { .. }), diff --git a/crates/tranquil-store/src/eventlog/segment_index.rs b/crates/tranquil-store/src/eventlog/segment_index.rs index 9bb77cb..6a29ff3 100644 --- a/crates/tranquil-store/src/eventlog/segment_index.rs +++ b/crates/tranquil-store/src/eventlog/segment_index.rs @@ -159,6 +159,7 @@ pub fn rebuild_from_segment( io: &S, segment_fd: FileId, index_interval: usize, + max_payload: u32, ) -> io::Result<(SegmentIndex, Option)> { assert!(index_interval > 0, "index_interval must be positive"); let file_size = io.file_size(segment_fd)?; @@ -193,7 +194,7 @@ pub fn rebuild_from_segment( if offset.raw() >= file_size { return None; } - match validate_event_record(io, segment_fd, offset, file_size) { + match validate_event_record(io, segment_fd, offset, file_size, max_payload) { Err(e) => Some(Err(e)), Ok(None) => None, Ok(Some(ValidateEventRecord::Valid { seq, next_offset })) => { @@ -254,7 +255,8 @@ mod tests { EVENT_HEADER_SIZE, SegmentWriter, ValidEvent, encode_event_record, }; use crate::eventlog::types::{ - DidHash, EventSequence, EventTypeTag, SegmentId, SegmentOffset, TimestampMicros, + DidHash, EventSequence, EventTypeTag, MAX_EVENT_PAYLOAD, SegmentId, SegmentOffset, + TimestampMicros, }; use crate::sim::SimulatedIO; use std::path::Path; @@ -285,8 +287,14 @@ mod tests { fd: FileId, count: u64, ) -> Vec<(EventSequence, SegmentOffset)> { - let mut writer = - SegmentWriter::new(io, fd, SegmentId::new(0), EventSequence::new(1)).unwrap(); + let mut writer = SegmentWriter::new( + io, + fd, + SegmentId::new(0), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); let offsets: Vec<_> = (1..=count) .map(|i| { let event = test_event(i, format!("payload-{i}").as_bytes()); @@ -436,10 +444,18 @@ mod tests { #[test] fn rebuild_empty_segment() { let (sim, fd) = setup(); - SegmentWriter::new(&sim, fd, SegmentId::new(0), EventSequence::new(1)).unwrap(); + SegmentWriter::new( + &sim, + fd, + SegmentId::new(0), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); sim.sync(fd).unwrap(); - let (index, last_seq) = rebuild_from_segment(&sim, fd, DEFAULT_INDEX_INTERVAL).unwrap(); + let (index, last_seq) = + rebuild_from_segment(&sim, fd, DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD).unwrap(); assert_eq!(index.entry_count(), 0); assert_eq!(last_seq, None); } @@ -450,7 +466,8 @@ mod tests { let offsets = write_n_events(&sim, fd, 1); sim.sync(fd).unwrap(); - let (index, last_seq) = rebuild_from_segment(&sim, fd, DEFAULT_INDEX_INTERVAL).unwrap(); + let (index, last_seq) = + rebuild_from_segment(&sim, fd, DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD).unwrap(); assert_eq!(last_seq, Some(EventSequence::new(1))); assert_eq!(index.entry_count(), 1); assert_eq!(index.first_seq(), Some(EventSequence::new(1))); @@ -463,7 +480,8 @@ mod tests { let offsets = write_n_events(&sim, fd, 10); sim.sync(fd).unwrap(); - let (index, last_seq) = rebuild_from_segment(&sim, fd, DEFAULT_INDEX_INTERVAL).unwrap(); + let (index, last_seq) = + rebuild_from_segment(&sim, fd, DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD).unwrap(); assert_eq!(last_seq, Some(EventSequence::new(10))); assert_eq!(index.entry_count(), 2); assert_eq!(index.first_seq(), Some(EventSequence::new(1))); @@ -478,7 +496,7 @@ mod tests { let offsets = write_n_events(&sim, fd, 600); sim.sync(fd).unwrap(); - let (index, last_seq) = rebuild_from_segment(&sim, fd, 256).unwrap(); + let (index, last_seq) = rebuild_from_segment(&sim, fd, 256, MAX_EVENT_PAYLOAD).unwrap(); assert_eq!(last_seq, Some(EventSequence::new(600))); assert_eq!(index.first_seq(), Some(EventSequence::new(1))); assert_eq!(index.last_seq(), Some(EventSequence::new(600))); @@ -504,7 +522,8 @@ mod tests { let file_size_with_garbage = sim.file_size(fd).unwrap(); assert!(file_size_with_garbage > file_size_before); - let (index, last_seq) = rebuild_from_segment(&sim, fd, DEFAULT_INDEX_INTERVAL).unwrap(); + let (index, last_seq) = + rebuild_from_segment(&sim, fd, DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD).unwrap(); assert_eq!(last_seq, Some(EventSequence::new(5))); assert_eq!(index.first_seq(), Some(EventSequence::new(1))); @@ -524,7 +543,8 @@ mod tests { sim.write_all_at(fd, valid_end, &partial_header).unwrap(); sim.sync(fd).unwrap(); - let (_, last_seq) = rebuild_from_segment(&sim, fd, DEFAULT_INDEX_INTERVAL).unwrap(); + let (_, last_seq) = + rebuild_from_segment(&sim, fd, DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD).unwrap(); assert_eq!(last_seq, Some(EventSequence::new(3))); assert_eq!(sim.file_size(fd).unwrap(), valid_end); } @@ -532,8 +552,14 @@ mod tests { #[test] fn rebuild_truncates_at_non_monotonic_seq() { let (sim, fd) = setup(); - let mut writer = - SegmentWriter::new(&sim, fd, SegmentId::new(0), EventSequence::new(1)).unwrap(); + let mut writer = SegmentWriter::new( + &sim, + fd, + SegmentId::new(0), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); let event1 = test_event(1, b"first"); let event2 = test_event(2, b"second"); @@ -553,10 +579,11 @@ mod tests { event_type: EventTypeTag::COMMIT, payload: b"regressed".to_vec(), }; - encode_event_record(&sim, fd, offset_after_two, ®ressed).unwrap(); + encode_event_record(&sim, fd, offset_after_two, ®ressed, MAX_EVENT_PAYLOAD).unwrap(); sim.sync(fd).unwrap(); - let (index, last_seq) = rebuild_from_segment(&sim, fd, DEFAULT_INDEX_INTERVAL).unwrap(); + let (index, last_seq) = + rebuild_from_segment(&sim, fd, DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD).unwrap(); assert_eq!(last_seq, Some(EventSequence::new(2))); assert_eq!(index.first_seq(), Some(EventSequence::new(1))); assert_eq!(index.last_seq(), Some(EventSequence::new(2))); @@ -569,7 +596,7 @@ mod tests { let offsets = write_n_events(&sim, fd, 10); sim.sync(fd).unwrap(); - let (index, _) = rebuild_from_segment(&sim, fd, 1).unwrap(); + let (index, _) = rebuild_from_segment(&sim, fd, 1, MAX_EVENT_PAYLOAD).unwrap(); assert_eq!(index.entry_count(), 10); offsets.iter().enumerate().for_each(|(i, (seq, offset))| { @@ -590,7 +617,7 @@ mod tests { write_n_events(&sim, fd, 300); sim.sync(fd).unwrap(); - let (index, last_seq) = rebuild_from_segment(&sim, fd, 256).unwrap(); + let (index, last_seq) = rebuild_from_segment(&sim, fd, 256, MAX_EVENT_PAYLOAD).unwrap(); assert_eq!(last_seq, Some(EventSequence::new(300))); let index_path = Path::new("/test/00000000.tqi"); @@ -640,7 +667,7 @@ mod tests { sim.write_all_at(fd, 0, b"NOPE\x01").unwrap(); sim.sync(fd).unwrap(); - let result = rebuild_from_segment(&sim, fd, DEFAULT_INDEX_INTERVAL); + let result = rebuild_from_segment(&sim, fd, DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD); assert!(result.is_err()); } @@ -651,7 +678,7 @@ mod tests { sim.sync(fd).unwrap(); let size_before = sim.file_size(fd).unwrap(); - rebuild_from_segment(&sim, fd, DEFAULT_INDEX_INTERVAL).unwrap(); + rebuild_from_segment(&sim, fd, DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD).unwrap(); let size_after = sim.file_size(fd).unwrap(); assert_eq!(size_before, size_after); } diff --git a/crates/tranquil-store/src/eventlog/sidecar.rs b/crates/tranquil-store/src/eventlog/sidecar.rs new file mode 100644 index 0000000..72936d1 --- /dev/null +++ b/crates/tranquil-store/src/eventlog/sidecar.rs @@ -0,0 +1,1076 @@ +use std::cell::Cell; +use std::io; +use std::path::Path; + +use crate::io::{FileId, OpenOptions, StorageIO}; +use crate::record::{RecordReader, RecordWriter}; + +use super::payload::{CID_BYTE_LEN, PAYLOAD_VERSION_V1}; +use super::segment_file::{ + EVENT_HEADER_SIZE, ReadEventRecord, SEGMENT_HEADER_SIZE, SEGMENT_MAGIC, decode_event_record, +}; +use super::types::{DidHash, EventSequence, EventTypeTag, SegmentOffset, TimestampMicros}; + +pub const SIDECAR_MAGIC: [u8; 4] = *b"TQSC"; +pub const SIDECAR_VERSION: u8 = 1; +pub const SIDECAR_HEADER_SIZE: usize = 16; +pub const SIDECAR_ENTRY_SIZE: usize = 64; + +const MAX_SIDECAR_ENTRIES: usize = 4 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SidecarEntry { + pub seq: u64, + pub timestamp: u64, + pub did_hash: u32, + pub event_type: u8, + pub active: u8, + pub status: u8, + pub did_offset: u32, + pub did_len: u16, + pub commit_cid_offset: u32, + pub prev_cid_offset: u32, + pub prev_data_cid_offset: u32, + pub ops_offset: u32, + pub ops_len: u32, + pub rev_offset: u32, + pub rev_len: u16, + pub handle_offset: u32, + pub handle_len: u16, +} + +impl SidecarEntry { + fn encode(&self, buf: &mut [u8; SIDECAR_ENTRY_SIZE]) { + buf[0..8].copy_from_slice(&self.seq.to_le_bytes()); + buf[8..16].copy_from_slice(&self.timestamp.to_le_bytes()); + buf[16..20].copy_from_slice(&self.did_hash.to_le_bytes()); + buf[20] = self.event_type; + buf[21] = self.active; + buf[22] = self.status; + buf[23] = 0; + buf[24..28].copy_from_slice(&self.did_offset.to_le_bytes()); + buf[28..30].copy_from_slice(&self.did_len.to_le_bytes()); + buf[30..34].copy_from_slice(&self.commit_cid_offset.to_le_bytes()); + buf[34..38].copy_from_slice(&self.prev_cid_offset.to_le_bytes()); + buf[38..42].copy_from_slice(&self.prev_data_cid_offset.to_le_bytes()); + buf[42..46].copy_from_slice(&self.ops_offset.to_le_bytes()); + buf[46..50].copy_from_slice(&self.ops_len.to_le_bytes()); + buf[50..54].copy_from_slice(&self.rev_offset.to_le_bytes()); + buf[54..56].copy_from_slice(&self.rev_len.to_le_bytes()); + buf[56..60].copy_from_slice(&self.handle_offset.to_le_bytes()); + buf[60..62].copy_from_slice(&self.handle_len.to_le_bytes()); + buf[62..64].copy_from_slice(&[0u8; 2]); + } + + fn decode(buf: &[u8; SIDECAR_ENTRY_SIZE]) -> Self { + Self { + seq: u64::from_le_bytes(buf[0..8].try_into().unwrap()), + timestamp: u64::from_le_bytes(buf[8..16].try_into().unwrap()), + did_hash: u32::from_le_bytes(buf[16..20].try_into().unwrap()), + event_type: buf[20], + active: buf[21], + status: buf[22], + did_offset: u32::from_le_bytes(buf[24..28].try_into().unwrap()), + did_len: u16::from_le_bytes(buf[28..30].try_into().unwrap()), + commit_cid_offset: u32::from_le_bytes(buf[30..34].try_into().unwrap()), + prev_cid_offset: u32::from_le_bytes(buf[34..38].try_into().unwrap()), + prev_data_cid_offset: u32::from_le_bytes(buf[38..42].try_into().unwrap()), + ops_offset: u32::from_le_bytes(buf[42..46].try_into().unwrap()), + ops_len: u32::from_le_bytes(buf[46..50].try_into().unwrap()), + rev_offset: u32::from_le_bytes(buf[50..54].try_into().unwrap()), + rev_len: u16::from_le_bytes(buf[54..56].try_into().unwrap()), + handle_offset: u32::from_le_bytes(buf[56..60].try_into().unwrap()), + handle_len: u16::from_le_bytes(buf[60..62].try_into().unwrap()), + } + } + + pub fn seq(&self) -> EventSequence { + EventSequence::new(self.seq) + } + + pub fn timestamp(&self) -> TimestampMicros { + TimestampMicros::new(self.timestamp) + } + + pub fn did_hash(&self) -> DidHash { + DidHash::from_raw(self.did_hash) + } + + pub fn event_type_tag(&self) -> Option { + EventTypeTag::from_raw(self.event_type) + } + + pub fn has_ops(&self) -> bool { + self.ops_offset != 0 + } + + pub fn has_commit_cid(&self) -> bool { + self.commit_cid_offset != 0 + } + + pub fn cid_byte_len(&self) -> usize { + CID_BYTE_LEN + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SidecarIndex { + entries: Vec, +} + +impl SidecarIndex { + pub fn new(entries: Vec) -> Self { + Self { entries } + } + + pub fn entries(&self) -> &[SidecarEntry] { + &self.entries + } + + pub fn entry_count(&self) -> usize { + self.entries.len() + } + + pub fn lookup_by_seq(&self, target_seq: EventSequence) -> Option<&SidecarEntry> { + let target = target_seq.raw(); + self.entries + .binary_search_by_key(&target, |e| e.seq) + .ok() + .map(|idx| &self.entries[idx]) + } + + pub fn first_seq(&self) -> Option { + self.entries.first().map(|e| EventSequence::new(e.seq)) + } + + pub fn last_seq(&self) -> Option { + self.entries.last().map(|e| EventSequence::new(e.seq)) + } + + pub fn save(&self, io: &S, path: &Path) -> io::Result<()> { + let tmp_path = path.with_extension("tqs.tmp"); + let fd = io.open(&tmp_path, OpenOptions::read_write())?; + + let result = (|| { + let data = self.serialize(); + let mut writer = RecordWriter::new(io, fd)?; + writer.append(&data)?; + io.truncate(fd, writer.position())?; + writer.sync()?; + Ok(()) + })(); + + if let Err(e) = result { + let _ = io.close(fd); + return Err(e); + } + io.close(fd)?; + + if let Err(e) = io.rename(&tmp_path, path) { + let _ = io.delete(&tmp_path); + return Err(e); + } + if let Some(parent) = path.parent() { + io.sync_dir(parent)?; + } + Ok(()) + } + + pub fn load(io: &S, path: &Path) -> io::Result> { + let fd = match io.open(path, OpenOptions::read_only_existing()) { + Ok(fd) => fd, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e), + }; + + let reader = match RecordReader::open(io, fd) { + Ok(r) => r, + Err(e) => { + let _ = io.close(fd); + return Err(e); + } + }; + let records = reader.valid_records(); + io.close(fd)?; + + let data = records.into_iter().next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "sidecar file contains no records", + ) + })?; + + Self::deserialize(&data).map(Some) + } + + fn serialize(&self) -> Vec { + let entry_count = u32::try_from(self.entries.len()).expect("entry count fits u32"); + let total_size = SIDECAR_HEADER_SIZE + self.entries.len() * SIDECAR_ENTRY_SIZE; + let mut buf = vec![0u8; total_size]; + + buf[0..4].copy_from_slice(&SIDECAR_MAGIC); + buf[4] = SIDECAR_VERSION; + buf[5..9].copy_from_slice(&entry_count.to_le_bytes()); + buf[9..11].copy_from_slice(&(SIDECAR_ENTRY_SIZE as u16).to_le_bytes()); + + self.entries.iter().enumerate().for_each(|(i, entry)| { + let offset = SIDECAR_HEADER_SIZE + i * SIDECAR_ENTRY_SIZE; + let entry_buf: &mut [u8; SIDECAR_ENTRY_SIZE] = (&mut buf + [offset..offset + SIDECAR_ENTRY_SIZE]) + .try_into() + .unwrap(); + entry.encode(entry_buf); + }); + + buf + } + + fn deserialize(data: &[u8]) -> io::Result { + if data.len() < SIDECAR_HEADER_SIZE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sidecar data too small for header", + )); + } + + if data[0..4] != SIDECAR_MAGIC { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "bad sidecar magic", + )); + } + + if data[4] != SIDECAR_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported sidecar version", + )); + } + + let entry_count = u32::from_le_bytes(data[5..9].try_into().unwrap()) as usize; + let entry_size = u16::from_le_bytes(data[9..11].try_into().unwrap()) as usize; + + if entry_size != SIDECAR_ENTRY_SIZE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "sidecar entry size mismatch: got {entry_size}, expected {SIDECAR_ENTRY_SIZE}" + ), + )); + } + + if entry_count > MAX_SIDECAR_ENTRIES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sidecar contains too many entries", + )); + } + + let expected_size = SIDECAR_HEADER_SIZE + entry_count * SIDECAR_ENTRY_SIZE; + if data.len() < expected_size { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sidecar data truncated", + )); + } + + let entries: Vec = (0..entry_count) + .map(|i| { + let offset = SIDECAR_HEADER_SIZE + i * SIDECAR_ENTRY_SIZE; + let entry_buf: &[u8; SIDECAR_ENTRY_SIZE] = (&data + [offset..offset + SIDECAR_ENTRY_SIZE]) + .try_into() + .unwrap(); + SidecarEntry::decode(entry_buf) + }) + .collect(); + + let is_sorted = entries.windows(2).all(|pair| pair[0].seq < pair[1].seq); + if !is_sorted { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sidecar entries not monotonically sorted by seq", + )); + } + + Ok(Self { entries }) + } +} + +struct FieldSpan { + offset: usize, + len: usize, +} + +struct PayloadFieldPositions { + did: FieldSpan, + commit_cid: Option, + prev_cid: Option, + prev_data_cid: Option, + ops: Option, + handle: Option, + active: Option, + status: Option, + rev: Option, +} + +fn read_varint(data: &[u8], pos: usize) -> Option<(usize, usize)> { + let mut result: usize = 0; + let mut shift = 0u32; + let mut current = pos; + loop { + let byte = *data.get(current)?; + current += 1; + result |= ((byte & 0x7f) as usize) << shift; + if byte & 0x80 == 0 { + return Some((result, current)); + } + shift += 7; + if shift >= 35 { + return None; + } + } +} + +fn read_bytes_span(data: &[u8], pos: usize) -> Option<(FieldSpan, usize)> { + let (len, after_varint) = read_varint(data, pos)?; + let end = after_varint.checked_add(len)?; + if end > data.len() { + return None; + } + Some(( + FieldSpan { + offset: after_varint, + len, + }, + end, + )) +} + +fn read_optional_bytes_span(data: &[u8], pos: usize) -> Option<(Option, usize)> { + let tag = *data.get(pos)?; + match tag { + 0 => Some((None, pos + 1)), + 1 => { + let (span, end) = read_bytes_span(data, pos + 1)?; + Some((Some(span), end)) + } + _ => None, + } +} + +fn skip_optional_vec_of_strings(data: &[u8], pos: usize) -> Option { + let tag = *data.get(pos)?; + match tag { + 0 => Some(pos + 1), + 1 => { + let (count, current) = read_varint(data, pos + 1)?; + (0..count).try_fold(current, |cur, _| { + let (_, end) = read_bytes_span(data, cur)?; + Some(end) + }) + } + _ => None, + } +} + +fn read_optional_bool(data: &[u8], pos: usize) -> Option<(Option, usize)> { + let tag = *data.get(pos)?; + match tag { + 0 => Some((None, pos + 1)), + 1 => { + let val = *data.get(pos + 1)?; + Some((Some(val != 0), pos + 2)) + } + _ => None, + } +} + +fn read_optional_u8(data: &[u8], pos: usize) -> Option<(Option, usize)> { + let tag = *data.get(pos)?; + match tag { + 0 => Some((None, pos + 1)), + 1 => { + let val = *data.get(pos + 1)?; + Some((Some(val), pos + 2)) + } + _ => None, + } +} + +fn extract_field_positions(postcard_body: &[u8]) -> Option { + let (did, pos) = read_bytes_span(postcard_body, 0)?; + let (commit_cid, pos) = read_optional_bytes_span(postcard_body, pos)?; + let (prev_cid, pos) = read_optional_bytes_span(postcard_body, pos)?; + let (prev_data_cid, pos) = read_optional_bytes_span(postcard_body, pos)?; + let (ops, pos) = read_optional_bytes_span(postcard_body, pos)?; + let pos = skip_optional_vec_of_strings(postcard_body, pos)?; + let pos = skip_optional_vec_of_strings(postcard_body, pos)?; + let (handle, pos) = read_optional_bytes_span(postcard_body, pos)?; + let (active, pos) = read_optional_bool(postcard_body, pos)?; + let (status, pos) = read_optional_u8(postcard_body, pos)?; + let (rev, _pos) = read_optional_bytes_span(postcard_body, pos)?; + + Some(PayloadFieldPositions { + did, + commit_cid, + prev_cid, + prev_data_cid, + ops, + handle, + active, + status, + rev, + }) +} + +fn to_segment_offset(payload_base: u64, body_field_offset: usize) -> u32 { + u32::try_from(payload_base + body_field_offset as u64).unwrap_or(0) +} + +fn active_to_tag(active: Option) -> u8 { + match active { + None => 0, + Some(false) => 1, + Some(true) => 2, + } +} + +fn status_to_tag(status: Option) -> u8 { + match status { + None => 0, + Some(v) => v.saturating_add(1), + } +} + +pub fn build_sidecar_from_segment( + io: &S, + segment_fd: FileId, + max_payload: u32, +) -> io::Result { + let file_size = io.file_size(segment_fd)?; + + if file_size < SEGMENT_HEADER_SIZE as u64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "file too small for segment header", + )); + } + + let mut header = [0u8; SEGMENT_HEADER_SIZE]; + io.read_exact_at(segment_fd, 0, &mut header)?; + if header[..SEGMENT_MAGIC.len()] != SEGMENT_MAGIC { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "bad segment magic", + )); + } + + let offset = Cell::new(SegmentOffset::new(SEGMENT_HEADER_SIZE as u64)); + + let entries: Vec = std::iter::from_fn(|| { + (offset.get().raw() < file_size) + .then(|| decode_event_record(io, segment_fd, offset.get(), file_size, max_payload)) + }) + .map_while(|result| match result { + Err(e) => Some(Err(e)), + Ok(None | Some(ReadEventRecord::Corrupted { .. } | ReadEventRecord::Truncated { .. })) => { + None + } + Ok(Some(ReadEventRecord::Valid { event, next_offset })) => { + let payload_start = offset.get().raw() + EVENT_HEADER_SIZE as u64; + offset.set(next_offset); + + match build_sidecar_entry_from_payload( + &event.payload, + payload_start, + event.seq, + event.timestamp, + event.did_hash, + event.event_type, + ) { + Some(entry) => Some(Ok(entry)), + None => { + tracing::warn!( + seq = %event.seq, + "failed to extract field positions for sidecar, skipping rest" + ); + None + } + } + } + }) + .collect::>>()?; + + Ok(SidecarIndex::new(entries)) +} + +fn build_sidecar_entry_from_payload( + payload: &[u8], + payload_start: u64, + seq: EventSequence, + timestamp: TimestampMicros, + did_hash: DidHash, + event_type: EventTypeTag, +) -> Option { + let (&version, postcard_body) = payload.split_first()?; + if version != PAYLOAD_VERSION_V1 { + return None; + } + + let positions = extract_field_positions(postcard_body)?; + + let body_base = payload_start + 1; + + let seg_offset = |span: &FieldSpan| -> u32 { to_segment_offset(body_base, span.offset) }; + + Some(SidecarEntry { + seq: seq.raw(), + timestamp: timestamp.raw(), + did_hash: did_hash.raw(), + event_type: event_type.raw(), + active: active_to_tag(positions.active), + status: status_to_tag(positions.status), + did_offset: seg_offset(&positions.did), + did_len: u16::try_from(positions.did.len).unwrap_or(u16::MAX), + commit_cid_offset: positions.commit_cid.as_ref().map_or(0, seg_offset), + prev_cid_offset: positions.prev_cid.as_ref().map_or(0, seg_offset), + prev_data_cid_offset: positions.prev_data_cid.as_ref().map_or(0, seg_offset), + ops_offset: positions.ops.as_ref().map_or(0, seg_offset), + ops_len: positions + .ops + .as_ref() + .map_or(0, |s| u32::try_from(s.len).unwrap_or(u32::MAX)), + rev_offset: positions.rev.as_ref().map_or(0, seg_offset), + rev_len: positions + .rev + .as_ref() + .map_or(0, |s| u16::try_from(s.len).unwrap_or(u16::MAX)), + handle_offset: positions.handle.as_ref().map_or(0, seg_offset), + handle_len: positions + .handle + .as_ref() + .map_or(0, |s| u16::try_from(s.len).unwrap_or(u16::MAX)), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::OpenOptions; + use crate::eventlog::payload::{encode_payload, encode_payload_with_mutations}; + use crate::eventlog::segment_file::{SegmentWriter, ValidEvent}; + use crate::eventlog::types::{ + DidHash, EventSequence, EventTypeTag, MAX_EVENT_PAYLOAD, SegmentId, TimestampMicros, + }; + use crate::sim::SimulatedIO; + use sha2::Digest; + use std::path::Path; + use tranquil_db_traits::{AccountStatus, RepoEventType, SequenceNumber, SequencedEvent}; + use tranquil_types::{Did, Handle}; + + fn test_did() -> Did { + Did::new("did:plc:testuser1234567890abcdef").unwrap() + } + + fn test_cid_link() -> tranquil_types::CidLink { + let hash = sha2::Digest::finalize(sha2::Sha256::new()); + let mh = multihash::Multihash::<64>::wrap(0x12, &hash).unwrap(); + let c = cid::Cid::new_v1(0x71, mh); + tranquil_types::CidLink::from_cid(&c) + } + + fn setup() -> (SimulatedIO, FileId) { + let sim = SimulatedIO::pristine(42); + let dir = Path::new("/test"); + sim.mkdir(dir).unwrap(); + sim.sync_dir(dir).unwrap(); + let fd = sim + .open(Path::new("/test/segment.tqe"), OpenOptions::read_write()) + .unwrap(); + (sim, fd) + } + + fn make_commit_event(seq: u64) -> SequencedEvent { + let cid = test_cid_link(); + let ops = serde_json::json!([{"action": "create", "path": "app.bsky.feed.post/abc"}]); + + SequencedEvent { + seq: SequenceNumber::from_raw(seq as i64), + did: test_did(), + created_at: chrono::Utc::now(), + event_type: RepoEventType::Commit, + commit_cid: Some(cid.clone()), + prev_cid: Some(cid.clone()), + prev_data_cid: Some(cid), + ops: Some(ops), + blobs: Some(vec!["bafkreibtest".to_owned()]), + blocks: None, + handle: Some(Handle::new("test.bsky.social").unwrap()), + active: None, + status: None, + rev: Some("rev123".to_owned()), + } + } + + fn make_account_event(seq: u64) -> SequencedEvent { + SequencedEvent { + seq: SequenceNumber::from_raw(seq as i64), + did: test_did(), + created_at: chrono::Utc::now(), + event_type: RepoEventType::Account, + commit_cid: None, + prev_cid: None, + prev_data_cid: None, + ops: None, + blobs: None, + blocks: None, + handle: None, + active: Some(true), + status: Some(AccountStatus::Active), + rev: None, + } + } + + fn write_events(sim: &SimulatedIO, fd: FileId, events: &[SequencedEvent]) -> Vec { + let mut writer = SegmentWriter::new( + sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); + + let valid_events: Vec = events + .iter() + .enumerate() + .map(|(i, event)| { + let payload = encode_payload(event); + let event_type = match event.event_type { + RepoEventType::Commit => EventTypeTag::COMMIT, + RepoEventType::Identity => EventTypeTag::IDENTITY, + RepoEventType::Account => EventTypeTag::ACCOUNT, + RepoEventType::Sync => EventTypeTag::SYNC, + }; + let ve = ValidEvent { + seq: EventSequence::new((i + 1) as u64), + timestamp: TimestampMicros::new((i as u64 + 1) * 1_000_000), + did_hash: DidHash::from_did(event.did.as_str()), + event_type, + payload, + }; + writer.append_event(sim, &ve).unwrap(); + ve + }) + .collect(); + + writer.sync(sim).unwrap(); + valid_events + } + + #[test] + fn entry_encode_decode_round_trip() { + let entry = SidecarEntry { + seq: 42, + timestamp: 1_700_000_000_000_000, + did_hash: 0xDEADBEEF, + event_type: 1, + active: 2, + status: 1, + did_offset: 100, + did_len: 30, + commit_cid_offset: 200, + prev_cid_offset: 300, + prev_data_cid_offset: 400, + ops_offset: 500, + ops_len: 1024, + rev_offset: 600, + rev_len: 10, + handle_offset: 700, + handle_len: 20, + }; + + let mut buf = [0u8; SIDECAR_ENTRY_SIZE]; + entry.encode(&mut buf); + let decoded = SidecarEntry::decode(&buf); + assert_eq!(entry, decoded); + } + + #[test] + fn sidecar_serialize_deserialize_round_trip() { + let entries = vec![ + SidecarEntry { + seq: 1, + timestamp: 1_000_000, + did_hash: 100, + event_type: 1, + active: 0, + status: 0, + did_offset: 50, + did_len: 30, + commit_cid_offset: 0, + prev_cid_offset: 0, + prev_data_cid_offset: 0, + ops_offset: 0, + ops_len: 0, + rev_offset: 0, + rev_len: 0, + handle_offset: 0, + handle_len: 0, + }, + SidecarEntry { + seq: 2, + timestamp: 2_000_000, + did_hash: 200, + event_type: 3, + active: 2, + status: 1, + did_offset: 150, + did_len: 30, + commit_cid_offset: 0, + prev_cid_offset: 0, + prev_data_cid_offset: 0, + ops_offset: 0, + ops_len: 0, + rev_offset: 0, + rev_len: 0, + handle_offset: 0, + handle_len: 0, + }, + ]; + + let index = SidecarIndex::new(entries.clone()); + let serialized = index.serialize(); + let deserialized = SidecarIndex::deserialize(&serialized).unwrap(); + assert_eq!(index, deserialized); + } + + #[test] + fn sidecar_save_load_round_trip() { + let sim = SimulatedIO::pristine(42); + let dir = Path::new("/test"); + sim.mkdir(dir).unwrap(); + sim.sync_dir(dir).unwrap(); + + let entries = vec![SidecarEntry { + seq: 1, + timestamp: 1_000_000, + did_hash: 100, + event_type: 1, + active: 0, + status: 0, + did_offset: 50, + did_len: 30, + commit_cid_offset: 0, + prev_cid_offset: 0, + prev_data_cid_offset: 0, + ops_offset: 0, + ops_len: 0, + rev_offset: 0, + rev_len: 0, + handle_offset: 0, + handle_len: 0, + }]; + + let index = SidecarIndex::new(entries); + let path = Path::new("/test/00000001.tqs"); + index.save(&sim, path).unwrap(); + + let loaded = SidecarIndex::load(&sim, path).unwrap().unwrap(); + assert_eq!(index, loaded); + } + + #[test] + fn sidecar_load_missing_returns_none() { + let sim = SimulatedIO::pristine(42); + let dir = Path::new("/test"); + sim.mkdir(dir).unwrap(); + sim.sync_dir(dir).unwrap(); + + let result = SidecarIndex::load(&sim, Path::new("/test/missing.tqs")).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn build_sidecar_from_commit_events() { + let (sim, fd) = setup(); + let events: Vec<_> = (1u64..=3).map(make_commit_event).collect(); + write_events(&sim, fd, &events); + + let sidecar = build_sidecar_from_segment(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); + assert_eq!(sidecar.entry_count(), 3); + + sidecar.entries().iter().enumerate().for_each(|(i, entry)| { + assert_eq!(entry.seq, (i + 1) as u64); + assert_eq!(entry.event_type, EventTypeTag::COMMIT.raw()); + assert!(entry.did_offset > 0); + assert!(entry.did_len > 0); + assert!(entry.commit_cid_offset > 0); + assert!(entry.prev_cid_offset > 0); + assert!(entry.prev_data_cid_offset > 0); + assert!(entry.ops_offset > 0); + assert!(entry.ops_len > 0); + assert!(entry.rev_offset > 0); + assert!(entry.rev_len > 0); + assert!(entry.handle_offset > 0); + assert!(entry.handle_len > 0); + }); + } + + #[test] + fn build_sidecar_from_account_events() { + let (sim, fd) = setup(); + let events: Vec<_> = (1u64..=2).map(make_account_event).collect(); + write_events(&sim, fd, &events); + + let sidecar = build_sidecar_from_segment(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); + assert_eq!(sidecar.entry_count(), 2); + + sidecar.entries().iter().for_each(|entry| { + assert_eq!(entry.event_type, EventTypeTag::ACCOUNT.raw()); + assert!(entry.did_offset > 0); + assert_eq!(entry.commit_cid_offset, 0); + assert_eq!(entry.ops_offset, 0); + assert_eq!(entry.ops_len, 0); + assert_eq!(entry.active, 2); + assert_eq!(entry.status, 1); + }); + } + + #[test] + fn build_sidecar_empty_segment() { + let (sim, fd) = setup(); + SegmentWriter::new( + &sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); + sim.sync(fd).unwrap(); + + let sidecar = build_sidecar_from_segment(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); + assert_eq!(sidecar.entry_count(), 0); + } + + #[test] + fn sidecar_lookup_by_seq() { + let (sim, fd) = setup(); + let events: Vec<_> = (1u64..=5).map(make_commit_event).collect(); + write_events(&sim, fd, &events); + + let sidecar = build_sidecar_from_segment(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); + + let entry = sidecar.lookup_by_seq(EventSequence::new(3)).unwrap(); + assert_eq!(entry.seq, 3); + + assert!(sidecar.lookup_by_seq(EventSequence::new(99)).is_none()); + } + + #[test] + fn sidecar_field_offsets_point_to_valid_data() { + let (sim, fd) = setup(); + let events = vec![make_commit_event(1)]; + write_events(&sim, fd, &events); + + let sidecar = build_sidecar_from_segment(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); + let entry = &sidecar.entries()[0]; + + let file_size = sim.file_size(fd).unwrap(); + let mut did_bytes = vec![0u8; entry.did_len as usize]; + sim.read_exact_at(fd, entry.did_offset as u64, &mut did_bytes) + .unwrap(); + let did_str = std::str::from_utf8(&did_bytes).unwrap(); + assert_eq!(did_str, "did:plc:testuser1234567890abcdef"); + + let mut rev_bytes = vec![0u8; entry.rev_len as usize]; + sim.read_exact_at(fd, entry.rev_offset as u64, &mut rev_bytes) + .unwrap(); + let rev_str = std::str::from_utf8(&rev_bytes).unwrap(); + assert_eq!(rev_str, "rev123"); + + let mut handle_bytes = vec![0u8; entry.handle_len as usize]; + sim.read_exact_at(fd, entry.handle_offset as u64, &mut handle_bytes) + .unwrap(); + let handle_str = std::str::from_utf8(&handle_bytes).unwrap(); + assert_eq!(handle_str, "test.bsky.social"); + + assert!(entry.commit_cid_offset > 0); + assert!((entry.commit_cid_offset as u64) + CID_BYTE_LEN as u64 <= file_size); + } + + #[test] + fn sidecar_build_and_reload() { + let sim = SimulatedIO::pristine(42); + let dir = Path::new("/test"); + sim.mkdir(dir).unwrap(); + sim.sync_dir(dir).unwrap(); + + let fd = sim + .open(Path::new("/test/segment.tqe"), OpenOptions::read_write()) + .unwrap(); + let events: Vec<_> = (1u64..=10).map(make_commit_event).collect(); + write_events(&sim, fd, &events); + + let sidecar = build_sidecar_from_segment(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); + let path = Path::new("/test/00000001.tqs"); + sidecar.save(&sim, path).unwrap(); + + let loaded = SidecarIndex::load(&sim, path).unwrap().unwrap(); + assert_eq!(sidecar, loaded); + assert_eq!(loaded.entry_count(), 10); + } + + #[test] + fn sidecar_with_mutations() { + let (sim, fd) = setup(); + let event = make_commit_event(1); + let mutation_bytes = b"mutation-data"; + let payload = encode_payload_with_mutations(&event, Some(mutation_bytes)); + + let ve = ValidEvent { + seq: EventSequence::new(1), + timestamp: TimestampMicros::new(1_000_000), + did_hash: DidHash::from_did(event.did.as_str()), + event_type: EventTypeTag::COMMIT, + payload, + }; + + let mut writer = SegmentWriter::new( + &sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); + writer.append_event(&sim, &ve).unwrap(); + writer.sync(&sim).unwrap(); + + let sidecar = build_sidecar_from_segment(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); + assert_eq!(sidecar.entry_count(), 1); + assert!(sidecar.entries()[0].ops_offset > 0); + } + + #[test] + fn extract_positions_minimal_payload() { + let event = make_account_event(1); + let encoded = encode_payload(&event); + let postcard_body = &encoded[1..]; + + let positions = extract_field_positions(postcard_body).unwrap(); + assert!(positions.did.len > 0); + assert!(positions.commit_cid.is_none()); + assert!(positions.ops.is_none()); + assert_eq!(positions.active, Some(true)); + assert_eq!(positions.status, Some(0)); + } + + #[test] + fn extract_positions_full_payload() { + let event = make_commit_event(1); + let encoded = encode_payload(&event); + let postcard_body = &encoded[1..]; + + let positions = extract_field_positions(postcard_body).unwrap(); + + let slice = |span: &FieldSpan| &postcard_body[span.offset..span.offset + span.len]; + + assert_eq!( + std::str::from_utf8(slice(&positions.did)).unwrap(), + "did:plc:testuser1234567890abcdef" + ); + + let commit_cid_span = positions.commit_cid.unwrap(); + let prev_cid_span = positions.prev_cid.unwrap(); + let prev_data_cid_span = positions.prev_data_cid.unwrap(); + assert_eq!(commit_cid_span.len, CID_BYTE_LEN); + assert_eq!(prev_cid_span.len, CID_BYTE_LEN); + assert_eq!(prev_data_cid_span.len, CID_BYTE_LEN); + assert_eq!(slice(&commit_cid_span), slice(&prev_cid_span)); + assert_eq!(slice(&commit_cid_span), slice(&prev_data_cid_span)); + + let expected_cid = test_cid_link().to_cid().unwrap().to_bytes(); + assert_eq!(slice(&commit_cid_span), expected_cid.as_slice()); + + let ops_span = positions.ops.unwrap(); + let ops_decoded: serde_json::Value = + serde_ipld_dagcbor::from_slice(slice(&ops_span)).unwrap(); + let expected_ops = + serde_json::json!([{"action": "create", "path": "app.bsky.feed.post/abc"}]); + assert_eq!(ops_decoded, expected_ops); + + let handle_span = positions.handle.unwrap(); + assert_eq!( + std::str::from_utf8(slice(&handle_span)).unwrap(), + "test.bsky.social" + ); + + assert_eq!(positions.active, None); + assert_eq!(positions.status, None); + + let rev_span = positions.rev.unwrap(); + assert_eq!(std::str::from_utf8(slice(&rev_span)).unwrap(), "rev123"); + } + + #[test] + fn bad_magic_rejected() { + let mut data = vec![0u8; SIDECAR_HEADER_SIZE]; + data[0..4].copy_from_slice(b"NOPE"); + data[4] = SIDECAR_VERSION; + + let result = SidecarIndex::deserialize(&data); + assert!(result.is_err()); + } + + #[test] + fn unsorted_entries_rejected() { + let entries = vec![ + SidecarEntry { + seq: 2, + timestamp: 0, + did_hash: 0, + event_type: 1, + active: 0, + status: 0, + did_offset: 0, + did_len: 0, + commit_cid_offset: 0, + prev_cid_offset: 0, + prev_data_cid_offset: 0, + ops_offset: 0, + ops_len: 0, + rev_offset: 0, + rev_len: 0, + handle_offset: 0, + handle_len: 0, + }, + SidecarEntry { + seq: 1, + timestamp: 0, + did_hash: 0, + event_type: 1, + active: 0, + status: 0, + did_offset: 0, + did_len: 0, + commit_cid_offset: 0, + prev_cid_offset: 0, + prev_data_cid_offset: 0, + ops_offset: 0, + ops_len: 0, + rev_offset: 0, + rev_len: 0, + handle_offset: 0, + handle_len: 0, + }, + ]; + + let index = SidecarIndex { entries }; + let serialized = index.serialize(); + let result = SidecarIndex::deserialize(&serialized); + assert!(result.is_err()); + } +} diff --git a/crates/tranquil-store/src/eventlog/types.rs b/crates/tranquil-store/src/eventlog/types.rs index fee53e6..07161f2 100644 --- a/crates/tranquil-store/src/eventlog/types.rs +++ b/crates/tranquil-store/src/eventlog/types.rs @@ -1,8 +1,9 @@ use serde::{Deserialize, Serialize}; use tranquil_db_traits::SequenceNumber; -pub const MAX_EVENT_PAYLOAD: u32 = 4 * 1024 * 1024; -pub const DEFAULT_SEGMENT_SIZE: u64 = 64 * 1024 * 1024; +pub const MAX_EVENT_PAYLOAD: u32 = u32::MAX; +pub const DEFAULT_MAX_EVENT_PAYLOAD: u32 = 256 * 1024 * 1024; +pub const DEFAULT_SEGMENT_SIZE: u64 = 256 * 1024 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct EventSequence(u64); @@ -114,15 +115,11 @@ impl SegmentOffset { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct EventLength(u32); impl EventLength { pub fn new(length: u32) -> Self { - assert!( - length <= MAX_EVENT_PAYLOAD, - "EventLength {length} exceeds MAX_EVENT_PAYLOAD {MAX_EVENT_PAYLOAD}" - ); Self(length) } @@ -135,18 +132,6 @@ impl EventLength { } } -impl<'de> Deserialize<'de> for EventLength { - fn deserialize>(deserializer: D) -> Result { - let raw = u32::deserialize(deserializer)?; - if raw > MAX_EVENT_PAYLOAD { - return Err(serde::de::Error::custom(format_args!( - "EventLength {raw} exceeds MAX_EVENT_PAYLOAD {MAX_EVENT_PAYLOAD}" - ))); - } - Ok(Self(raw)) - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct DidHash(u32); @@ -359,12 +344,6 @@ mod tests { assert_eq!(len.raw(), MAX_EVENT_PAYLOAD); } - #[test] - #[should_panic(expected = "exceeds MAX_EVENT_PAYLOAD")] - fn event_length_overflow_panics() { - EventLength::new(MAX_EVENT_PAYLOAD + 1); - } - #[test] fn did_hash_deterministic() { let hash1 = DidHash::from_did("did:plc:abc123"); @@ -474,13 +453,6 @@ mod tests { assert!(postcard::from_bytes::(&bytes).is_err()); } - #[test] - fn postcard_rejects_oversized_event_length() { - let oversized = MAX_EVENT_PAYLOAD + 1; - let bytes = postcard::to_allocvec(&oversized).unwrap(); - assert!(postcard::from_bytes::(&bytes).is_err()); - } - #[test] fn event_sequence_to_sequence_number() { let es = EventSequence::new(42); diff --git a/crates/tranquil-store/src/eventlog/writer.rs b/crates/tranquil-store/src/eventlog/writer.rs index 1b67a5e..0e18f64 100644 --- a/crates/tranquil-store/src/eventlog/writer.rs +++ b/crates/tranquil-store/src/eventlog/writer.rs @@ -8,9 +8,9 @@ use crate::io::StorageIO; use super::manager::SegmentManager; use super::segment_file::{SEGMENT_HEADER_SIZE, SegmentWriter, ValidEvent}; use super::segment_index::{DEFAULT_INDEX_INTERVAL, SegmentIndex, rebuild_from_segment}; +use super::sidecar::build_sidecar_from_segment; use super::types::{ - DidHash, EventSequence, EventTypeTag, MAX_EVENT_PAYLOAD, SegmentId, SegmentOffset, - TimestampMicros, + DidHash, EventSequence, EventTypeTag, SegmentId, SegmentOffset, TimestampMicros, }; #[derive(Debug)] @@ -28,14 +28,20 @@ pub struct EventLogWriter { next_seq: EventSequence, synced_seq: EventSequence, index_interval: usize, + max_payload: u32, event_count_in_segment: usize, last_event_offset: Option, pending_events: Vec, } impl EventLogWriter { - pub fn open(manager: Arc>, index_interval: usize) -> io::Result { + pub fn open( + manager: Arc>, + index_interval: usize, + max_payload: u32, + ) -> io::Result { assert!(index_interval > 0, "index_interval must be positive"); + assert!(max_payload > 0, "max_payload must be positive"); let segments = manager.list_segments()?; @@ -45,8 +51,11 @@ impl EventLogWriter { SegmentId::new(1), EventSequence::new(1), index_interval, + max_payload, ), - Some(&last_id) => Self::recover_active(manager, &segments, last_id, index_interval), + Some(&last_id) => { + Self::recover_active(manager, &segments, last_id, index_interval, max_payload) + } } } @@ -55,10 +64,11 @@ impl EventLogWriter { segment_id: SegmentId, next_seq: EventSequence, index_interval: usize, + max_payload: u32, ) -> io::Result { let fd = manager.open_for_append(segment_id)?; manager.io().truncate(fd, 0)?; - let writer = SegmentWriter::new(manager.io(), fd, segment_id, next_seq)?; + let writer = SegmentWriter::new(manager.io(), fd, segment_id, next_seq, max_payload)?; writer.sync(manager.io())?; manager.io().sync_dir(manager.segments_dir())?; @@ -69,6 +79,7 @@ impl EventLogWriter { next_seq, synced_seq: next_seq.prev_or_before_all(), index_interval, + max_payload, event_count_in_segment: 0, last_event_offset: None, pending_events: Vec::new(), @@ -80,6 +91,7 @@ impl EventLogWriter { segments: &[SegmentId], active_id: SegmentId, index_interval: usize, + max_payload: u32, ) -> io::Result { let fd = manager.open_for_append(active_id)?; @@ -87,6 +99,7 @@ impl EventLogWriter { manager.io(), fd, index_interval, + max_payload, ) { Ok(result) => result, Err(rebuild_err) => { @@ -94,13 +107,15 @@ impl EventLogWriter { if file_size <= SEGMENT_HEADER_SIZE as u64 { manager.io().truncate(fd, 0)?; let prev_segments = &segments[..segments.len().saturating_sub(1)]; - let next_seq = find_last_seq_from_segments(&manager, prev_segments)? - .map_or(EventSequence::new(1), |s| s.next()); + let next_seq = + find_last_seq_from_segments(&manager, prev_segments, max_payload)? + .map_or(EventSequence::new(1), |s| s.next()); return Self::init_fresh( Arc::clone(&manager), active_id, next_seq, index_interval, + max_payload, ); } return Err(io::Error::new( @@ -118,7 +133,8 @@ impl EventLogWriter { let next_seq = match last_seq_in_active { Some(seq) => { - if let Some(sealed_last) = find_last_seq_from_segments(&manager, prev_segments)? + if let Some(sealed_last) = + find_last_seq_from_segments(&manager, prev_segments, max_payload)? && seq <= sealed_last { return Err(io::Error::new( @@ -131,7 +147,7 @@ impl EventLogWriter { } seq.next() } - None => find_last_seq_from_segments(&manager, prev_segments)? + None => find_last_seq_from_segments(&manager, prev_segments, max_payload)? .map_or(EventSequence::new(1), |s| s.next()), }; @@ -159,6 +175,7 @@ impl EventLogWriter { position, base_seq, last_seq_in_active, + max_payload, ); if let Err(e) = manager.io().delete(&manager.index_path(active_id)) @@ -174,6 +191,7 @@ impl EventLogWriter { next_seq, synced_seq, index_interval, + max_payload, event_count_in_segment, last_event_offset, pending_events: Vec::new(), @@ -188,12 +206,11 @@ impl EventLogWriter { ) -> io::Result { let payload_len = u32::try_from(payload.len()) .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "payload exceeds u32::MAX"))?; - if payload_len > MAX_EVENT_PAYLOAD { + if payload_len > self.max_payload { + let max = self.max_payload; return Err(io::Error::new( io::ErrorKind::InvalidInput, - format!( - "payload length {payload_len} exceeds MAX_EVENT_PAYLOAD {MAX_EVENT_PAYLOAD}" - ), + format!("payload length {payload_len} exceeds configured max_payload {max}"), )); } @@ -229,6 +246,32 @@ impl EventLogWriter { Ok(seq) } + pub fn append_valid_event(&mut self, event: ValidEvent) -> io::Result<()> { + let offset = self.active_writer.append_event(self.manager.io(), &event)?; + + let should_index = self.event_count_in_segment == 0 + || self + .event_count_in_segment + .is_multiple_of(self.index_interval); + if should_index { + self.active_index.record(event.seq, offset); + } + + self.event_count_in_segment = self + .event_count_in_segment + .checked_add(1) + .expect("event_count_in_segment overflow"); + self.last_event_offset = Some(offset); + self.next_seq = event.seq.next(); + self.pending_events.push(event); + + Ok(()) + } + + pub fn peek_pending_event(&self, seq: EventSequence) -> Option<&ValidEvent> { + self.pending_events.iter().find(|e| e.seq == seq) + } + pub fn sync(&mut self) -> io::Result { if !self.pending_events.is_empty() { self.active_writer.sync(self.manager.io())?; @@ -260,9 +303,20 @@ impl EventLogWriter { self.manager.seal_segment(old_id, &self.active_index)?; + match self.build_sidecar_for_segment(old_id) { + Ok(()) => {} + Err(e) => warn!(segment = %old_id, error = %e, "sidecar build failed (non-fatal)"), + } + let (new_id, new_fd) = self.manager.prepare_rotation(old_id)?; - match SegmentWriter::new::(self.manager.io(), new_fd, new_id, self.next_seq) { + match SegmentWriter::new::( + self.manager.io(), + new_fd, + new_id, + self.next_seq, + self.max_payload, + ) { Ok(writer) => { self.active_writer = writer; self.active_index = SegmentIndex::new(); @@ -306,6 +360,13 @@ impl EventLogWriter { self.active_writer.position() } + fn build_sidecar_for_segment(&self, segment_id: SegmentId) -> io::Result<()> { + let fd = self.manager.open_for_read(segment_id)?; + let sidecar = build_sidecar_from_segment(self.manager.io(), fd, self.max_payload)?; + let path = self.manager.sidecar_path(segment_id); + sidecar.save(self.manager.io(), &path) + } + pub fn shutdown(&mut self) -> io::Result<()> { let _ = self.sync()?; self.ensure_last_event_indexed(); @@ -325,6 +386,7 @@ impl EventLogWriter { fn find_last_seq_from_segments( manager: &SegmentManager, segments: &[SegmentId], + max_payload: u32, ) -> io::Result> { segments.iter().rev().try_fold(None, |acc, &seg_id| { if acc.is_some() { @@ -336,7 +398,8 @@ fn find_last_seq_from_segments( Err(e) if e.kind() != io::ErrorKind::InvalidData => Err(e), _ => { let fd = manager.open_for_read(seg_id)?; - let (_, last_seq) = rebuild_from_segment(manager.io(), fd, DEFAULT_INDEX_INTERVAL)?; + let (_, last_seq) = + rebuild_from_segment(manager.io(), fd, DEFAULT_INDEX_INTERVAL, max_payload)?; Ok(last_seq) } } @@ -348,6 +411,7 @@ mod tests { use super::*; use crate::eventlog::segment_file::{EVENT_RECORD_OVERHEAD, SegmentReader}; use crate::eventlog::segment_index::DEFAULT_INDEX_INTERVAL; + use crate::eventlog::types::MAX_EVENT_PAYLOAD; use crate::sim::SimulatedIO; use std::path::{Path, PathBuf}; @@ -372,7 +436,9 @@ mod tests { #[test] fn open_fresh_creates_segment() { let mgr = setup_manager(64 * 1024); - let writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); assert_eq!(writer.active_segment_id(), SegmentId::new(1)); assert_eq!(writer.current_seq(), EventSequence::BEFORE_ALL); @@ -389,7 +455,9 @@ mod tests { #[test] fn append_assigns_contiguous_sequences() { let mgr = setup_manager(64 * 1024); - let mut writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let mut writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); let seqs: Vec = (1..=5) .map(|i| append_test_event(&mut writer, &format!("did:plc:user{i}"))) @@ -402,7 +470,9 @@ mod tests { #[test] fn sync_returns_flushed_events() { let mgr = setup_manager(64 * 1024); - let mut writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let mut writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=3).for_each(|i| { append_test_event(&mut writer, &format!("did:plc:user{i}")); @@ -427,7 +497,9 @@ mod tests { #[test] fn sync_without_pending_is_noop() { let mgr = setup_manager(64 * 1024); - let mut writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let mut writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); let result = writer.sync().unwrap(); assert_eq!(result.synced_through, EventSequence::BEFORE_ALL); @@ -437,7 +509,9 @@ mod tests { #[test] fn second_sync_returns_only_new_events() { let mgr = setup_manager(64 * 1024); - let mut writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let mut writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=3).for_each(|i| { append_test_event(&mut writer, &format!("did:plc:user{i}")); @@ -460,7 +534,8 @@ mod tests { { let mut writer = - EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=5).for_each(|i| { append_test_event(&mut writer, &format!("did:plc:user{i}")); }); @@ -469,13 +544,15 @@ mod tests { mgr.shutdown(); - let writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); assert_eq!(writer.current_seq(), EventSequence::new(5)); assert_eq!(writer.synced_seq(), EventSequence::new(5)); assert_eq!(writer.active_segment_id(), SegmentId::new(1)); let fd = mgr.open_for_read(SegmentId::new(1)).unwrap(); - let events = SegmentReader::open(mgr.io(), fd) + let events = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD) .unwrap() .valid_prefix() .unwrap(); @@ -488,7 +565,8 @@ mod tests { { let mut writer = - EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=3).for_each(|i| { append_test_event(&mut writer, &format!("did:plc:user{i}")); }); @@ -503,7 +581,9 @@ mod tests { mgr.shutdown(); mgr.io().crash(); - let writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); assert_eq!(writer.current_seq(), EventSequence::new(3)); assert_eq!(writer.next_seq, EventSequence::new(4)); } @@ -515,7 +595,9 @@ mod tests { let max_segment_size = SEGMENT_HEADER_SIZE + record_size * 3; let mgr = setup_manager(max_segment_size as u64); - let mut writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let mut writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=3).for_each(|i| { writer @@ -546,7 +628,9 @@ mod tests { let max_segment_size = SEGMENT_HEADER_SIZE + record_size * 2; let mgr = setup_manager(max_segment_size as u64); - let mut writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let mut writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=2).for_each(|i| { writer @@ -576,7 +660,9 @@ mod tests { let max_segment_size = SEGMENT_HEADER_SIZE + record_size * 2; let mgr = setup_manager(max_segment_size as u64); - let mut writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let mut writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=2).for_each(|i| { writer @@ -610,7 +696,8 @@ mod tests { { let mut writer = - EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=2).for_each(|i| { writer .append( @@ -635,7 +722,9 @@ mod tests { mgr.shutdown(); - let writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); assert_eq!(writer.active_segment_id(), SegmentId::new(2)); assert_eq!(writer.current_seq(), EventSequence::new(3)); assert_eq!(writer.next_seq, EventSequence::new(4)); @@ -651,7 +740,8 @@ mod tests { { let mut writer = - EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=2).for_each(|i| { writer .append( @@ -668,7 +758,9 @@ mod tests { mgr.shutdown(); mgr.io().crash(); - let writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); assert_eq!(writer.next_seq, EventSequence::new(3)); } @@ -682,7 +774,8 @@ mod tests { { let mut writer = - EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=2).for_each(|i| { writer .append( @@ -698,11 +791,13 @@ mod tests { mgr.shutdown(); - let writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); assert_eq!(writer.next_seq, EventSequence::new(3)); let fd = mgr.open_for_read(SegmentId::new(1)).unwrap(); - let events = SegmentReader::open(mgr.io(), fd) + let events = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD) .unwrap() .valid_prefix() .unwrap(); @@ -712,7 +807,9 @@ mod tests { #[test] fn checkpoint_creates_index_file() { let mgr = setup_manager(64 * 1024); - let mut writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let mut writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=10).for_each(|i| { append_test_event(&mut writer, &format!("did:plc:user{i}")); @@ -729,7 +826,9 @@ mod tests { #[test] fn checkpoint_empty_index_is_noop() { let mgr = setup_manager(64 * 1024); - let writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); writer.checkpoint_index().unwrap(); @@ -741,7 +840,9 @@ mod tests { #[test] fn current_seq_and_synced_seq_diverge_before_sync() { let mgr = setup_manager(64 * 1024); - let mut writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let mut writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); append_test_event(&mut writer, "did:plc:user1"); append_test_event(&mut writer, "did:plc:user2"); @@ -758,7 +859,7 @@ mod tests { #[test] fn sparse_index_built_at_intervals() { let mgr = setup_manager(64 * 1024); - let mut writer = EventLogWriter::open(Arc::clone(&mgr), 4).unwrap(); + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 4, MAX_EVENT_PAYLOAD).unwrap(); (1..=10).for_each(|i| { append_test_event(&mut writer, &format!("did:plc:user{i}")); @@ -781,7 +882,8 @@ mod tests { { let mut writer = - EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=9).for_each(|i| { writer .append( @@ -801,7 +903,9 @@ mod tests { mgr.shutdown(); - let writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); assert_eq!(writer.next_seq, EventSequence::new(10)); let segments = mgr.list_segments().unwrap(); @@ -811,7 +915,9 @@ mod tests { #[test] fn shutdown_syncs_and_checkpoints() { let mgr = setup_manager(64 * 1024); - let mut writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let mut writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=5).for_each(|i| { append_test_event(&mut writer, &format!("did:plc:user{i}")); @@ -834,7 +940,7 @@ mod tests { let max_segment_size = SEGMENT_HEADER_SIZE + record_size * 5; let mgr = setup_manager(max_segment_size as u64); - let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); (1..=5).for_each(|i| { writer @@ -861,11 +967,15 @@ mod tests { let mgr = setup_manager(64 * 1024); { - let _writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let _writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); } mgr.shutdown(); - let writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); assert_eq!(writer.active_segment_id(), SegmentId::new(1)); assert_eq!(writer.current_seq(), EventSequence::BEFORE_ALL); } @@ -876,7 +986,8 @@ mod tests { { let mut writer = - EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=3).for_each(|i| { append_test_event(&mut writer, &format!("did:plc:user{i}")); }); @@ -885,13 +996,15 @@ mod tests { mgr.shutdown(); - let mut writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let mut writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); let seq = append_test_event(&mut writer, "did:plc:user4"); assert_eq!(seq, EventSequence::new(4)); writer.sync().unwrap(); let fd = mgr.open_for_read(SegmentId::new(1)).unwrap(); - let events = SegmentReader::open(mgr.io(), fd) + let events = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD) .unwrap() .valid_prefix() .unwrap(); @@ -909,7 +1022,8 @@ mod tests { { let mut writer = - EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); (1..=2).for_each(|i| { writer .append( @@ -955,14 +1069,18 @@ mod tests { mgr.io().sync(fd2).unwrap(); mgr.io().close(fd2).unwrap(); - let writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); assert_eq!(writer.next_seq, EventSequence::new(5)); } #[test] fn rotation_not_needed_returns_false() { let mgr = setup_manager(64 * 1024); - let mut writer = EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL).unwrap(); + let mut writer = + EventLogWriter::open(Arc::clone(&mgr), DEFAULT_INDEX_INTERVAL, MAX_EVENT_PAYLOAD) + .unwrap(); append_test_event(&mut writer, "did:plc:user1"); writer.sync().unwrap(); diff --git a/crates/tranquil-store/src/harness.rs b/crates/tranquil-store/src/harness.rs index cac1029..305f1ab 100644 --- a/crates/tranquil-store/src/harness.rs +++ b/crates/tranquil-store/src/harness.rs @@ -207,6 +207,8 @@ pub struct PristineComparisonResult { #[cfg(test)] mod tests { use super::*; + use crate::sim::sim_seed_range; + use rayon::prelude::*; #[test] fn no_fault_recovers_all_synced() { @@ -242,7 +244,7 @@ mod tests { #[test] fn faulted_recovery_never_exceeds_written() { - (0..1000).for_each(|seed| { + sim_seed_range().into_par_iter().for_each(|seed| { let payloads: Vec> = (0..5).map(|i| format!("data-{i}").into_bytes()).collect(); let Ok(result) = run_crash_test(seed, FaultConfig::moderate(), &payloads, 2) else { return; @@ -258,7 +260,7 @@ mod tests { #[test] fn pristine_comparison_with_faults() { - (0..1000).for_each(|seed| { + sim_seed_range().into_par_iter().for_each(|seed| { let payloads: Vec> = (0..8).map(|i| format!("item-{i}").into_bytes()).collect(); let Ok(result) = run_pristine_comparison(seed, FaultConfig::moderate(), &payloads, 4) else { diff --git a/crates/tranquil-store/src/io.rs b/crates/tranquil-store/src/io.rs index fdb3310..14aa07b 100644 --- a/crates/tranquil-store/src/io.rs +++ b/crates/tranquil-store/src/io.rs @@ -25,6 +25,7 @@ impl AsRef<[u8]> for MappedFile { pub struct FileId(u64); impl FileId { + #[cfg(any(test, feature = "test-harness"))] pub(crate) fn new(id: u64) -> Self { Self(id) } diff --git a/crates/tranquil-store/src/lib.rs b/crates/tranquil-store/src/lib.rs index d29c4c7..ffebff7 100644 --- a/crates/tranquil-store/src/lib.rs +++ b/crates/tranquil-store/src/lib.rs @@ -1,4 +1,8 @@ +pub mod archival; +pub mod backup; pub mod blockstore; +pub mod bloom; +pub mod consistency; pub mod eventlog; pub mod fsync_order; #[cfg(any(test, feature = "test-harness"))] @@ -21,4 +25,11 @@ pub use record::{ RecordReader, RecordWriter, }; #[cfg(any(test, feature = "test-harness"))] -pub use sim::{FaultConfig, OpRecord, SimulatedIO}; +pub use sim::{ + FaultConfig, OpRecord, SimulatedIO, sim_proptest_cases, sim_seed_count, sim_seed_range, + sim_single_seed, +}; + +pub(crate) fn wall_clock_ms() -> blockstore::WallClockMs { + blockstore::WallClockMs::now() +} diff --git a/crates/tranquil-store/src/metastore/backlink_ops.rs b/crates/tranquil-store/src/metastore/backlink_ops.rs index 281a93b..97319a3 100644 --- a/crates/tranquil-store/src/metastore/backlink_ops.rs +++ b/crates/tranquil-store/src/metastore/backlink_ops.rs @@ -175,7 +175,6 @@ impl BacklinkOps { )?; let uri: AtUri = val.source_uri.into(); - let is_self = uri.as_str() == bl.uri.as_str(); let matches_collection = uri.collection().is_some_and(|c| c == collection_str); let matches_path = match discriminant_to_path(val.path) { Some(p) => p == bl.path, @@ -188,11 +187,7 @@ impl BacklinkOps { false } }; - if !is_self - && matches_collection - && matches_path - && !seen.contains(uri.as_str()) - { + if matches_collection && matches_path && !seen.contains(uri.as_str()) { seen.insert(uri.as_str().to_owned()); conflicts.push(uri); } @@ -263,9 +258,9 @@ mod tests { fn add_and_query_by_target() { let h = setup(); let ops = h.metastore.backlink_ops(); - let (_user_id, user_hash) = create_repo(&h, "alice", 1); + let (_user_id, user_hash) = create_repo(&h, "olaren", 1); - let uri = AtUri::from_parts("did:plc:alice", "app.bsky.feed.like", "3k2abc"); + let uri = AtUri::from_parts("did:plc:olaren", "app.bsky.feed.like", "3k2abc"); let backlinks = vec![Backlink { uri: uri.clone(), path: BacklinkPath::SubjectUri, @@ -290,9 +285,9 @@ mod tests { fn remove_by_uri_deletes_both_indexes() { let h = setup(); let ops = h.metastore.backlink_ops(); - let (_user_id, user_hash) = create_repo(&h, "bob", 2); + let (_user_id, user_hash) = create_repo(&h, "teq", 2); - let uri = AtUri::from_parts("did:plc:bob", "app.bsky.graph.follow", "3k2fol"); + let uri = AtUri::from_parts("did:plc:teq", "app.bsky.graph.follow", "3k2fol"); let backlinks = vec![Backlink { uri: uri.clone(), path: BacklinkPath::Subject, @@ -335,11 +330,11 @@ mod tests { fn remove_by_repo_deletes_all_user_backlinks() { let h = setup(); let ops = h.metastore.backlink_ops(); - let (_user_id, user_hash) = create_repo(&h, "carol", 3); + let (_user_id, user_hash) = create_repo(&h, "nel", 3); let backlinks: Vec = (0..5) .map(|i| Backlink { - uri: AtUri::from_parts("did:plc:carol", "app.bsky.feed.like", &format!("3k2r{i}")), + uri: AtUri::from_parts("did:plc:nel", "app.bsky.feed.like", &format!("3k2r{i}")), path: BacklinkPath::SubjectUri, link_to: format!("at://did:plc:target{i}/app.bsky.feed.post/3k2p{i}"), }) @@ -376,10 +371,10 @@ mod tests { fn get_backlink_conflicts_finds_matching() { let h = setup(); let ops = h.metastore.backlink_ops(); - let (user_id, user_hash) = create_repo(&h, "dave", 4); + let (user_id, user_hash) = create_repo(&h, "lyna", 4); let existing = Backlink { - uri: AtUri::from_parts("did:plc:dave", "app.bsky.feed.like", "3k2old"), + uri: AtUri::from_parts("did:plc:lyna", "app.bsky.feed.like", "3k2old"), path: BacklinkPath::SubjectUri, link_to: "at://did:plc:someone/app.bsky.feed.post/3k2p1".to_string(), }; @@ -390,7 +385,7 @@ mod tests { batch.commit().unwrap(); let proposed = vec![Backlink { - uri: AtUri::from_parts("did:plc:dave", "app.bsky.feed.like", "3k2new"), + uri: AtUri::from_parts("did:plc:lyna", "app.bsky.feed.like", "3k2new"), path: BacklinkPath::SubjectUri, link_to: "at://did:plc:someone/app.bsky.feed.post/3k2p1".to_string(), }]; @@ -403,7 +398,7 @@ mod tests { assert_eq!(conflicts.len(), 1); assert_eq!( conflicts[0].as_str(), - "at://did:plc:dave/app.bsky.feed.like/3k2old" + "at://did:plc:lyna/app.bsky.feed.like/3k2old" ); } @@ -411,10 +406,10 @@ mod tests { fn get_backlink_conflicts_ignores_different_collection() { let h = setup(); let ops = h.metastore.backlink_ops(); - let (user_id, user_hash) = create_repo(&h, "eve", 5); + let (user_id, user_hash) = create_repo(&h, "bailey", 5); let existing = Backlink { - uri: AtUri::from_parts("did:plc:eve", "app.bsky.feed.like", "3k2old"), + uri: AtUri::from_parts("did:plc:bailey", "app.bsky.feed.like", "3k2old"), path: BacklinkPath::SubjectUri, link_to: "at://did:plc:someone/app.bsky.feed.post/3k2p1".to_string(), }; @@ -425,7 +420,7 @@ mod tests { batch.commit().unwrap(); let proposed = vec![Backlink { - uri: AtUri::from_parts("did:plc:eve", "app.bsky.feed.repost", "3k2new"), + uri: AtUri::from_parts("did:plc:bailey", "app.bsky.feed.repost", "3k2new"), path: BacklinkPath::SubjectUri, link_to: "at://did:plc:someone/app.bsky.feed.post/3k2p1".to_string(), }]; @@ -442,10 +437,10 @@ mod tests { fn get_backlink_conflicts_ignores_different_path() { let h = setup(); let ops = h.metastore.backlink_ops(); - let (user_id, user_hash) = create_repo(&h, "frank", 6); + let (user_id, user_hash) = create_repo(&h, "olaren", 6); let existing = Backlink { - uri: AtUri::from_parts("did:plc:frank", "app.bsky.graph.follow", "3k2old"), + uri: AtUri::from_parts("did:plc:olaren", "app.bsky.graph.follow", "3k2old"), path: BacklinkPath::Subject, link_to: "did:plc:target".to_string(), }; @@ -456,7 +451,7 @@ mod tests { batch.commit().unwrap(); let proposed = vec![Backlink { - uri: AtUri::from_parts("did:plc:frank", "app.bsky.graph.follow", "3k2new"), + uri: AtUri::from_parts("did:plc:olaren", "app.bsky.graph.follow", "3k2new"), path: BacklinkPath::SubjectUri, link_to: "did:plc:target".to_string(), }]; @@ -473,11 +468,11 @@ mod tests { fn get_backlink_conflicts_ignores_other_users() { let h = setup(); let ops = h.metastore.backlink_ops(); - let (_user_id_a, user_hash_a) = create_repo(&h, "grace", 7); - let (user_id_b, _user_hash_b) = create_repo(&h, "henry", 8); + let (_user_id_a, user_hash_a) = create_repo(&h, "teq", 7); + let (user_id_b, _user_hash_b) = create_repo(&h, "nel", 8); let existing = Backlink { - uri: AtUri::from_parts("did:plc:grace", "app.bsky.feed.like", "3k2old"), + uri: AtUri::from_parts("did:plc:teq", "app.bsky.feed.like", "3k2old"), path: BacklinkPath::SubjectUri, link_to: "at://did:plc:target/app.bsky.feed.post/3k2p1".to_string(), }; @@ -488,7 +483,7 @@ mod tests { batch.commit().unwrap(); let proposed = vec![Backlink { - uri: AtUri::from_parts("did:plc:henry", "app.bsky.feed.like", "3k2new"), + uri: AtUri::from_parts("did:plc:nel", "app.bsky.feed.like", "3k2new"), path: BacklinkPath::SubjectUri, link_to: "at://did:plc:target/app.bsky.feed.post/3k2p1".to_string(), }]; @@ -502,13 +497,13 @@ mod tests { } #[test] - fn get_backlink_conflicts_excludes_self_match() { + fn get_backlink_conflicts_includes_self_match() { let h = setup(); let ops = h.metastore.backlink_ops(); - let (user_id, user_hash) = create_repo(&h, "luna", 12); + let (user_id, user_hash) = create_repo(&h, "lyna", 12); let existing = Backlink { - uri: AtUri::from_parts("did:plc:luna", "app.bsky.feed.like", "3k2same"), + uri: AtUri::from_parts("did:plc:lyna", "app.bsky.feed.like", "3k2same"), path: BacklinkPath::SubjectUri, link_to: "at://did:plc:someone/app.bsky.feed.post/3k2p1".to_string(), }; @@ -519,7 +514,7 @@ mod tests { batch.commit().unwrap(); let proposed = vec![Backlink { - uri: AtUri::from_parts("did:plc:luna", "app.bsky.feed.like", "3k2same"), + uri: AtUri::from_parts("did:plc:lyna", "app.bsky.feed.like", "3k2same"), path: BacklinkPath::SubjectUri, link_to: "at://did:plc:someone/app.bsky.feed.post/3k2p1".to_string(), }]; @@ -529,14 +524,14 @@ mod tests { .get_backlink_conflicts(user_id, &collection, &proposed) .unwrap(); - assert!(conflicts.is_empty()); + assert_eq!(conflicts.len(), 1); } #[test] fn empty_backlinks_returns_empty_conflicts() { let h = setup(); let ops = h.metastore.backlink_ops(); - let (user_id, _user_hash) = create_repo(&h, "ivan", 9); + let (user_id, _user_hash) = create_repo(&h, "bailey", 9); let collection = Nsid::from("app.bsky.feed.like".to_string()); let conflicts = ops @@ -549,15 +544,15 @@ mod tests { fn remove_by_uri_only_removes_matching_rkey() { let h = setup(); let ops = h.metastore.backlink_ops(); - let (_user_id, user_hash) = create_repo(&h, "julia", 10); + let (_user_id, user_hash) = create_repo(&h, "bailey", 10); let bl1 = Backlink { - uri: AtUri::from_parts("did:plc:julia", "app.bsky.feed.like", "3k2aaa"), + uri: AtUri::from_parts("did:plc:bailey", "app.bsky.feed.like", "3k2aaa"), path: BacklinkPath::SubjectUri, link_to: "at://did:plc:t1/app.bsky.feed.post/p1".to_string(), }; let bl2 = Backlink { - uri: AtUri::from_parts("did:plc:julia", "app.bsky.feed.like", "3k2bbb"), + uri: AtUri::from_parts("did:plc:bailey", "app.bsky.feed.like", "3k2bbb"), path: BacklinkPath::SubjectUri, link_to: "at://did:plc:t2/app.bsky.feed.post/p2".to_string(), }; diff --git a/crates/tranquil-store/src/metastore/client.rs b/crates/tranquil-store/src/metastore/client.rs index 504ae53..bc5fccd 100644 --- a/crates/tranquil-store/src/metastore/client.rs +++ b/crates/tranquil-store/src/metastore/client.rs @@ -6,26 +6,25 @@ use chrono::{DateTime, Utc}; use tokio::sync::oneshot; use tranquil_db_traits::{ AccountSearchResult, AccountStatus, AdminAccountInfo, ApplyCommitError, ApplyCommitInput, - ApplyCommitResult, Backlink, BrokenGenesisCommit, CommitEventData, CommsChannel, CommsType, + ApplyCommitResult, Backlink, CommitEventData, CommsChannel, CommsType, CompletePasskeySetupInput, CreateAccountError, CreateDelegatedAccountInput, CreatePasskeyAccountInput, CreatePasswordAccountInput, CreatePasswordAccountResult, CreateSsoAccountInput, DbError, DeletionRequest, DeletionRequestWithToken, DidWebOverrides, - EventBlocksCids, ImportBlock, ImportRecord, ImportRepoError, InviteCodeError, InviteCodeInfo, - InviteCodeRow, InviteCodeSortOrder, InviteCodeUse, MigrationReactivationError, - MigrationReactivationInput, NotificationHistoryRow, NotificationPrefs, OAuthTokenWithUser, - PasswordResetResult, PlcTokenInfo, QueuedComms, ReactivatedAccountInfo, - RecoverPasskeyAccountInput, RecoverPasskeyAccountResult, RepoAccountInfo, RepoInfo, - RepoListItem, RepoWithoutRev, ReservedSigningKey, ReservedSigningKeyFull, - ScheduledDeletionAccount, ScopePreference, SequenceNumber, SequencedEvent, StoredBackupCode, - StoredPasskey, TokenFamilyId, TotpRecord, TotpRecordState, User2faStatus, UserAuthInfo, - UserCommsPrefs, UserConfirmSignup, UserDidWebInfo, UserEmailInfo, UserForDeletion, - UserForDidDoc, UserForDidDocBuild, UserForPasskeyRecovery, UserForPasskeySetup, - UserForRecovery, UserForVerification, UserIdAndHandle, UserIdAndPasswordHash, - UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, UserKeyWithId, UserLegacyLoginPref, - UserLoginCheck, UserLoginFull, UserLoginInfo, UserNeedingRecordBlobsBackfill, UserPasswordInfo, - UserResendVerification, UserResetCodeInfo, UserRow, UserSessionInfo, UserStatus, - UserVerificationInfo, UserWithKey, UserWithoutBlocks, ValidatedInviteCode, - WebauthnChallengeType, + ImportBlock, ImportRecord, ImportRepoError, InviteCodeError, InviteCodeInfo, InviteCodeRow, + InviteCodeSortOrder, InviteCodeUse, MigrationReactivationError, MigrationReactivationInput, + NotificationHistoryRow, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult, + PlcTokenInfo, PruneCount, QueuedComms, ReactivatedAccountInfo, RecoverPasskeyAccountInput, + RecoverPasskeyAccountResult, RepoAccountInfo, RepoInfo, RepoListItem, RepoWithoutRev, + ReservedSigningKey, ReservedSigningKeyFull, ScheduledDeletionAccount, ScopePreference, + SequenceNumber, SequencedEvent, StoredBackupCode, StoredPasskey, TokenFamilyId, TotpRecord, + TotpRecordState, User2faStatus, UserAuthInfo, UserCommsPrefs, UserConfirmSignup, + UserDidWebInfo, UserEmailInfo, UserForDeletion, UserForDidDoc, UserForDidDocBuild, + UserForPasskeyRecovery, UserForPasskeySetup, UserForRecovery, UserForVerification, + UserIdAndHandle, UserIdAndPasswordHash, UserIdHandleEmail, UserInfoForAuth, UserKeyInfo, + UserKeyWithId, UserLegacyLoginPref, UserLoginCheck, UserLoginFull, UserLoginInfo, + UserNeedingRecordBlobsBackfill, UserPasswordInfo, UserResendVerification, UserResetCodeInfo, + UserRow, UserSessionInfo, UserStatus, UserVerificationInfo, UserWithKey, UserWithoutBlocks, + ValidatedInviteCode, WebauthnChallengeType, }; use tranquil_oauth::{AuthorizedClientData, DeviceData, RequestData, TokenData}; use tranquil_types::{ @@ -40,6 +39,7 @@ use super::handler::{ SsoRequest, UserBlockRequest, UserRequest, }; use super::keys::UserHash; +use crate::eventlog::{EventLog, TimestampMicros}; use crate::io::StorageIO; async fn recv(rx: oneshot::Receiver>) -> Result { @@ -88,6 +88,7 @@ async fn recv_migration_reactivation( pub struct MetastoreClient { pool: Arc, + event_log: Arc>, _phantom: PhantomData, } @@ -95,15 +96,17 @@ impl Clone for MetastoreClient { fn clone(&self) -> Self { Self { pool: Arc::clone(&self.pool), + event_log: Arc::clone(&self.event_log), _phantom: PhantomData, } } } impl MetastoreClient { - pub fn new(pool: Arc) -> Self { + pub fn new(pool: Arc, event_log: Arc>) -> Self { Self { pool, + event_log, _phantom: PhantomData, } } @@ -112,6 +115,10 @@ impl MetastoreClient { &self.pool } + pub fn event_log(&self) -> &Arc> { + &self.event_log + } + pub async fn create_repo_full( &self, user_id: Uuid, @@ -467,20 +474,6 @@ impl tranquil_db_traits::RepoRepository for MetastoreCli recv(rx).await } - async fn find_unreferenced_blocks( - &self, - candidate_cids: &[Vec], - ) -> Result>, DbError> { - let (tx, rx) = oneshot::channel(); - self.pool.send(MetastoreRequest::UserBlock( - UserBlockRequest::FindUnreferencedBlocks { - candidate_cids: candidate_cids.to_vec(), - tx, - }, - ))?; - recv(rx).await - } - async fn insert_commit_event(&self, data: &CommitEventData) -> Result { let (tx, rx) = oneshot::channel(); self.pool @@ -526,6 +519,7 @@ impl tranquil_db_traits::RepoRepository for MetastoreCli did: &Did, commit_cid: &CidLink, rev: Option<&str>, + commit_bytes: &[u8], ) -> Result { let (tx, rx) = oneshot::channel(); self.pool @@ -533,6 +527,7 @@ impl tranquil_db_traits::RepoRepository for MetastoreCli did: did.clone(), commit_cid: commit_cid.clone(), rev: rev.map(str::to_owned), + commit_bytes: commit_bytes.to_vec(), tx, }))?; recv(rx).await @@ -544,6 +539,8 @@ impl tranquil_db_traits::RepoRepository for MetastoreCli commit_cid: &CidLink, mst_root_cid: &CidLink, rev: &str, + commit_bytes: &[u8], + mst_root_bytes: &[u8], ) -> Result { let (tx, rx) = oneshot::channel(); self.pool.send(MetastoreRequest::Event( @@ -552,27 +549,14 @@ impl tranquil_db_traits::RepoRepository for MetastoreCli commit_cid: commit_cid.clone(), mst_root_cid: mst_root_cid.clone(), rev: rev.to_string(), + commit_bytes: commit_bytes.to_vec(), + mst_root_bytes: mst_root_bytes.to_vec(), tx, }, ))?; recv(rx).await } - async fn update_seq_blocks_cids( - &self, - seq: SequenceNumber, - blocks_cids: &[String], - ) -> Result<(), DbError> { - let (tx, rx) = oneshot::channel(); - self.pool - .send(MetastoreRequest::Event(EventRequest::UpdateSeqBlocksCids { - seq, - blocks_cids: blocks_cids.to_vec(), - tx, - }))?; - recv(rx).await - } - async fn delete_sequences_except( &self, did: &Did, @@ -589,6 +573,34 @@ impl tranquil_db_traits::RepoRepository for MetastoreCli recv(rx).await } + async fn prune_events_older_than(&self, cutoff: DateTime) -> Result { + let cutoff_micros = cutoff.timestamp_micros(); + if cutoff_micros < 0 { + return Err(DbError::Query(format!( + "eventlog retention: refusing pre-epoch cutoff {cutoff_micros} us (would prune entire log)" + ))); + } + let now_micros = Utc::now().timestamp_micros(); + let now_us = u64::try_from(now_micros).map_err(|_| { + DbError::Query(format!( + "eventlog retention: current wall time {now_micros} us out of u64 range" + )) + })?; + let cutoff_us = u64::try_from(cutoff_micros).map_err(|_| { + DbError::Query(format!( + "eventlog retention: cutoff {cutoff_micros} us out of u64 range" + )) + })?; + let max_age = std::time::Duration::from_micros(now_us.saturating_sub(cutoff_us)); + let event_log = Arc::clone(&self.event_log); + let now = TimestampMicros::new(now_us); + tokio::task::spawn_blocking(move || event_log.run_retention_at(now, max_age)) + .await + .map_err(|e| DbError::Connection(format!("retention task panicked: {e}")))? + .map(|n| PruneCount::Segments(n as u64)) + .map_err(|e| DbError::Query(format!("eventlog retention failed: {e}"))) + } + async fn get_max_seq(&self) -> Result { let (tx, rx) = oneshot::channel(); self.pool @@ -674,21 +686,6 @@ impl tranquil_db_traits::RepoRepository for MetastoreCli recv(rx).await } - async fn get_events_since_rev( - &self, - did: &Did, - since_rev: &str, - ) -> Result, DbError> { - let (tx, rx) = oneshot::channel(); - self.pool - .send(MetastoreRequest::Event(EventRequest::GetEventsSinceRev { - did: did.clone(), - since_rev: since_rev.to_string(), - tx, - }))?; - recv(rx).await - } - async fn list_repos_paginated( &self, cursor_did: Option<&Did>, @@ -764,14 +761,6 @@ impl tranquil_db_traits::RepoRepository for MetastoreCli recv_commit(rx).await } - async fn get_broken_genesis_commits(&self) -> Result, DbError> { - let (tx, rx) = oneshot::channel(); - self.pool.send(MetastoreRequest::Commit(Box::new( - CommitRequest::GetBrokenGenesisCommits { tx }, - )))?; - recv(rx).await - } - async fn get_users_without_blocks(&self) -> Result, DbError> { let (tx, rx) = oneshot::channel(); self.pool.send(MetastoreRequest::Commit(Box::new( diff --git a/crates/tranquil-store/src/metastore/commit_ops.rs b/crates/tranquil-store/src/metastore/commit_ops.rs index ad7e774..5cbbcc5 100644 --- a/crates/tranquil-store/src/metastore/commit_ops.rs +++ b/crates/tranquil-store/src/metastore/commit_ops.rs @@ -24,9 +24,8 @@ use crate::eventlog::EventLogBridge; use crate::io::StorageIO; use tranquil_db_traits::{ - ApplyCommitError, ApplyCommitInput, ApplyCommitResult, BrokenGenesisCommit, ImportBlock, - ImportRecord, ImportRepoError, RepoEventType, SequenceNumber, UserNeedingRecordBlobsBackfill, - UserWithoutBlocks, + ApplyCommitError, ApplyCommitInput, ApplyCommitResult, ImportBlock, ImportRecord, + ImportRepoError, UserNeedingRecordBlobsBackfill, UserWithoutBlocks, }; use tranquil_types::{AtUri, CidLink, Did}; @@ -85,7 +84,7 @@ pub struct CommitOps { blockstore: Option, } -impl CommitOps { +impl CommitOps { pub fn new( db: Database, repo_data: Keyspace, @@ -382,46 +381,6 @@ impl CommitOps { ) } - pub fn get_broken_genesis_commits(&self) -> Result, MetastoreError> { - const PAGE_SIZE: usize = 4096; - self.collect_broken_genesis_page(SequenceNumber::ZERO, Vec::new(), PAGE_SIZE) - } - - fn collect_broken_genesis_page( - &self, - cursor: SequenceNumber, - acc: Vec, - page_size: usize, - ) -> Result, MetastoreError> { - let limit = i64::try_from(page_size).unwrap_or(i64::MAX); - let events = self - .event_ops - .get_events_since_seq(cursor, Some(limit)) - .map_err(|_| MetastoreError::CorruptData("failed to read events"))?; - - let page_len = events.len(); - let page_high_seq = events.last().map(|e| e.seq).unwrap_or(cursor); - - let results = events.into_iter().fold(acc, |mut results, e| { - if e.event_type == RepoEventType::Commit - && e.prev_cid.is_none() - && e.commit_cid.is_none() - { - results.push(BrokenGenesisCommit { - seq: e.seq, - did: e.did, - commit_cid: e.commit_cid, - }); - } - results - }); - - match page_len < page_size { - true => Ok(results), - false => self.collect_broken_genesis_page(page_high_seq, results, page_size), - } - } - pub fn get_users_without_blocks(&self) -> Result, MetastoreError> { const MAX_RESULTS: usize = 10_000; @@ -518,7 +477,7 @@ mod tests { use crate::eventlog::{EventLog, EventLogConfig}; use crate::io::RealIO; use crate::metastore::{Metastore, MetastoreConfig}; - use tranquil_db_traits::CommitEventData; + use tranquil_db_traits::{CommitEventData, RepoEventType, SequenceNumber}; use tranquil_types::{Handle, Nsid, Rkey}; struct TestHarness { @@ -603,7 +562,7 @@ mod tests { fn apply_commit_updates_records_and_meta() { let h = setup(); let ops = make_commit_ops(&h); - let (user_id, did, root_cid) = create_test_repo(&h, "alice", 1); + let (user_id, did, root_cid) = create_test_repo(&h, "olaren", 1); let new_root = test_cid_link(2); let record_cid = test_cid_link(3); @@ -633,7 +592,7 @@ mod tests { prev_cid: Some(root_cid.clone()), ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev1".to_string()), }, @@ -660,7 +619,7 @@ mod tests { fn apply_commit_cas_rejects_stale_root() { let h = setup(); let ops = make_commit_ops(&h); - let (user_id, did, _root_cid) = create_test_repo(&h, "bob", 10); + let (user_id, did, _root_cid) = create_test_repo(&h, "teq", 10); let stale_root = test_cid_link(99); let new_root = test_cid_link(11); @@ -678,13 +637,13 @@ mod tests { backlinks_to_add: vec![], backlinks_to_remove: vec![], commit_event: CommitEventData { - did: test_did("bob"), + did: test_did("teq"), event_type: RepoEventType::Commit, commit_cid: None, prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev1".to_string()), }, @@ -704,7 +663,7 @@ mod tests { let input = ApplyCommitInput { user_id: Uuid::new_v4(), - did: test_did("nobody"), + did: test_did("nonexistent"), expected_root_cid: None, new_root_cid: test_cid_link(1), new_rev: "rev1".to_string(), @@ -715,13 +674,13 @@ mod tests { backlinks_to_add: vec![], backlinks_to_remove: vec![], commit_event: CommitEventData { - did: test_did("nobody"), + did: test_did("nonexistent"), event_type: RepoEventType::Commit, commit_cid: None, prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: None, }, @@ -737,7 +696,7 @@ mod tests { fn apply_commit_record_deletes() { let h = setup(); let ops = make_commit_ops(&h); - let (user_id, did, root_cid) = create_test_repo(&h, "carol", 20); + let (user_id, did, root_cid) = create_test_repo(&h, "nel", 20); let mid_root = test_cid_link(21); let record_cid = test_cid_link(22); @@ -767,7 +726,7 @@ mod tests { prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev1".to_string()), }, @@ -805,7 +764,7 @@ mod tests { prev_cid: Some(mid_root.clone()), ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev2".to_string()), }, @@ -825,7 +784,7 @@ mod tests { fn apply_commit_event_visible_after_commit() { let h = setup(); let ops = make_commit_ops(&h); - let (user_id, did, root_cid) = create_test_repo(&h, "dave", 30); + let (user_id, did, root_cid) = create_test_repo(&h, "lyna", 30); let new_root = test_cid_link(31); let input = ApplyCommitInput { @@ -847,7 +806,7 @@ mod tests { prev_cid: Some(root_cid.clone()), ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev1".to_string()), }, @@ -866,7 +825,7 @@ mod tests { fn import_repo_data_inserts_records() { let h = setup(); let ops = make_commit_ops(&h); - let (user_id, _did, root_cid) = create_test_repo(&h, "eve", 40); + let (user_id, _did, root_cid) = create_test_repo(&h, "bailey", 40); let collection = Nsid::from("app.bsky.feed.post".to_string()); let rkey = Rkey::from("3k2import".to_string()); @@ -897,7 +856,7 @@ mod tests { fn import_repo_data_cas_rejects_stale_root() { let h = setup(); let ops = make_commit_ops(&h); - let (user_id, _did, _root_cid) = create_test_repo(&h, "frank", 50); + let (user_id, _did, _root_cid) = create_test_repo(&h, "olaren", 50); let stale = test_cid_link(99); let result = ops.import_repo_data(user_id, &[], &[], Some(&stale)); @@ -908,8 +867,8 @@ mod tests { fn insert_record_blobs_and_backfill_query() { let h = setup(); let ops = make_commit_ops(&h); - let (user_id_a, did_a, _) = create_test_repo(&h, "grace", 60); - let (user_id_b, _did_b, _) = create_test_repo(&h, "henry", 61); + let (user_id_a, did_a, _) = create_test_repo(&h, "teq", 60); + let (user_id_b, _did_b, _) = create_test_repo(&h, "nel", 61); let needing = ops.get_users_needing_record_blobs_backfill(100).unwrap(); assert_eq!(needing.len(), 2); @@ -928,8 +887,8 @@ mod tests { fn get_users_without_blocks_returns_users_with_no_blocks() { let h = setup(); let ops = make_commit_ops(&h); - let (user_id_a, did_a, root_a) = create_test_repo(&h, "ivan", 70); - let (user_id_b, _did_b, _root_b) = create_test_repo(&h, "julia", 71); + let (user_id_a, did_a, root_a) = create_test_repo(&h, "lyna", 70); + let (user_id_b, _did_b, _root_b) = create_test_repo(&h, "bailey", 71); let new_root = test_cid_link(72); let input = ApplyCommitInput { @@ -951,7 +910,7 @@ mod tests { prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev1".to_string()), }, @@ -989,7 +948,7 @@ mod tests { prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev_force".to_string()), }, @@ -1041,7 +1000,7 @@ mod tests { prev_cid: Some(root_cid.clone()), ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev1".to_string()), }, @@ -1085,7 +1044,7 @@ mod tests { prev_cid: Some(mid_root.clone()), ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev2".to_string()), }, @@ -1179,7 +1138,7 @@ mod tests { prev_cid: Some(initial_root.clone()), ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev1".to_string()), }, @@ -1305,7 +1264,7 @@ mod tests { prev_cid: Some(initial_root.clone()), ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev1".to_string()), }; @@ -1432,7 +1391,7 @@ mod tests { prev_cid: Some(root_cid.clone()), ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev1".to_string()), }, @@ -1479,7 +1438,7 @@ mod tests { prev_cid: Some(mid_root.clone()), ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev2".to_string()), }, diff --git a/crates/tranquil-store/src/metastore/event_keys.rs b/crates/tranquil-store/src/metastore/event_keys.rs index 112e612..d3ca969 100644 --- a/crates/tranquil-store/src/metastore/event_keys.rs +++ b/crates/tranquil-store/src/metastore/event_keys.rs @@ -1,34 +1,8 @@ -use serde::{Deserialize, Serialize}; use smallvec::SmallVec; use super::encoding::KeyBuilder; use super::keys::{KeyTag, UserHash}; -const SEQ_META_SCHEMA_VERSION: u8 = 1; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SeqMetaValue { - pub blocks_cids: Vec, -} - -impl SeqMetaValue { - pub fn serialize(&self) -> Vec { - let payload = postcard::to_allocvec(self).expect("SeqMetaValue serialization cannot fail"); - let mut buf = Vec::with_capacity(1 + payload.len()); - buf.push(SEQ_META_SCHEMA_VERSION); - buf.extend_from_slice(&payload); - buf - } - - pub fn deserialize(bytes: &[u8]) -> Option { - let (&version, payload) = bytes.split_first()?; - match version { - SEQ_META_SCHEMA_VERSION => postcard::from_bytes(payload).ok(), - _ => None, - } - } -} - pub fn rev_to_seq_key(user_hash: UserHash, rev: &str) -> SmallVec<[u8; 128]> { KeyBuilder::new() .tag(KeyTag::REV_TO_SEQ) @@ -44,10 +18,6 @@ pub fn rev_to_seq_user_prefix(user_hash: UserHash) -> SmallVec<[u8; 128]> { .build() } -pub fn seq_meta_key(seq: u64) -> SmallVec<[u8; 128]> { - KeyBuilder::new().tag(KeyTag::SEQ_META).u64(seq).build() -} - pub fn seq_tombstone_key(seq: u64) -> SmallVec<[u8; 128]> { KeyBuilder::new() .tag(KeyTag::SEQ_TOMBSTONE) @@ -82,50 +52,6 @@ mod tests { use super::*; use crate::metastore::encoding::KeyReader; - #[test] - fn seq_meta_value_roundtrip() { - let value = SeqMetaValue { - blocks_cids: vec!["bafyreiblock1".to_owned(), "bafyreiblock2".to_owned()], - }; - let bytes = value.serialize(); - let decoded = SeqMetaValue::deserialize(&bytes).unwrap(); - assert_eq!(decoded, value); - } - - #[test] - fn seq_meta_value_empty_blocks() { - let value = SeqMetaValue { - blocks_cids: vec![], - }; - let bytes = value.serialize(); - let decoded = SeqMetaValue::deserialize(&bytes).unwrap(); - assert_eq!(decoded, value); - } - - #[test] - fn seq_meta_schema_version_first_byte() { - let value = SeqMetaValue { - blocks_cids: vec![], - }; - let bytes = value.serialize(); - assert_eq!(bytes[0], SEQ_META_SCHEMA_VERSION); - } - - #[test] - fn seq_meta_rejects_unknown_version() { - let value = SeqMetaValue { - blocks_cids: vec![], - }; - let mut bytes = value.serialize(); - bytes[0] = 99; - assert!(SeqMetaValue::deserialize(&bytes).is_none()); - } - - #[test] - fn seq_meta_rejects_empty_input() { - assert!(SeqMetaValue::deserialize(&[]).is_none()); - } - #[test] fn rev_to_seq_key_roundtrip() { let hash = UserHash::from_raw(0xDEAD_BEEF_CAFE_BABE); @@ -156,24 +82,6 @@ mod tests { assert!(full.as_slice().starts_with(prefix.as_slice())); } - #[test] - fn seq_meta_key_roundtrip() { - let key = seq_meta_key(12345); - let mut reader = KeyReader::new(&key); - assert_eq!(reader.tag(), Some(KeyTag::SEQ_META.raw())); - assert_eq!(reader.u64(), Some(12345)); - assert!(reader.is_empty()); - } - - #[test] - fn seq_meta_keys_sort_by_seq() { - let k1 = seq_meta_key(1); - let k2 = seq_meta_key(2); - let k3 = seq_meta_key(100); - assert!(k1.as_slice() < k2.as_slice()); - assert!(k2.as_slice() < k3.as_slice()); - } - #[test] fn seq_tombstone_key_roundtrip() { let key = seq_tombstone_key(999); diff --git a/crates/tranquil-store/src/metastore/event_ops.rs b/crates/tranquil-store/src/metastore/event_ops.rs index 38aa485..d3dd5b2 100644 --- a/crates/tranquil-store/src/metastore/event_ops.rs +++ b/crates/tranquil-store/src/metastore/event_ops.rs @@ -5,15 +5,14 @@ use chrono::{DateTime, Utc}; use fjall::{Database, Keyspace}; use tracing::warn; use tranquil_db_traits::{ - AccountStatus, CommitEventData, DbError, EventBlocksCids, RepoEventType, SequenceNumber, - SequencedEvent, + AccountStatus, CommitEventData, DbError, RepoEventType, SequenceNumber, SequencedEvent, }; use tranquil_types::{CidLink, Did, Handle}; use super::encoding::{KeyReader, exclusive_upper_bound}; use super::event_keys::{ - SeqMetaValue, did_events_key, did_events_prefix, metastore_cursor_key, rev_to_seq_key, - rev_to_seq_user_prefix, seq_meta_key, seq_tombstone_key, + did_events_key, did_events_prefix, metastore_cursor_key, rev_to_seq_key, + rev_to_seq_user_prefix, seq_tombstone_key, }; use super::keys::UserHash; use super::recovery::CommitMutationSet; @@ -29,7 +28,7 @@ pub struct EventOps { bridge: Arc>, } -impl EventOps { +impl EventOps { pub fn new(db: Database, repo_data: Keyspace, bridge: Arc>) -> Self { Self { db, @@ -61,7 +60,7 @@ impl EventOps { let payload = crate::eventlog::encode_payload_with_mutations(&event, mutation_set_bytes); let (seq, deferred) = self .bridge - .insert_event_deferred_raw(&data.did, data.event_type, payload) + .insert_event_group_commit_raw(&data.did, data.event_type, payload) .map_err(|e| DbError::Query(e.to_string()))?; let seq_u64 = seq_to_u64(seq)?; @@ -90,7 +89,10 @@ impl EventOps { prev_data_cid: data.prev_data_cid.clone(), ops: data.ops.clone(), blobs: data.blobs.clone(), - blocks_cids: data.blocks_cids.clone(), + blocks: data + .blocks + .clone() + .map(tranquil_db_traits::EventBlocks::Inline), handle: None, active: None, status: None, @@ -113,7 +115,7 @@ impl EventOps { prev_data_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, handle: handle.cloned(), active: None, status: None, @@ -139,7 +141,7 @@ impl EventOps { prev_data_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, handle: None, active, status: Some(status), @@ -154,7 +156,15 @@ impl EventOps { did: &Did, commit_cid: &CidLink, rev: Option<&str>, + commit_bytes: &[u8], ) -> Result { + let inline = tranquil_db_traits::EventBlockInline { + cid_bytes: commit_cid + .to_cid() + .expect("CidLink invariant: validated at construction") + .to_bytes(), + data: commit_bytes.to_vec(), + }; let event = SequencedEvent { seq: SequenceNumber::ZERO, did: did.clone(), @@ -165,7 +175,7 @@ impl EventOps { prev_data_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: Some(tranquil_db_traits::EventBlocks::Inline(vec![inline])), handle: None, active: None, status: None, @@ -181,7 +191,23 @@ impl EventOps { commit_cid: &CidLink, mst_root_cid: &CidLink, rev: &str, + commit_bytes: &[u8], + mst_root_bytes: &[u8], ) -> Result { + let commit_block = tranquil_db_traits::EventBlockInline { + cid_bytes: commit_cid + .to_cid() + .expect("CidLink invariant: validated at construction") + .to_bytes(), + data: commit_bytes.to_vec(), + }; + let mst_block = tranquil_db_traits::EventBlockInline { + cid_bytes: mst_root_cid + .to_cid() + .expect("CidLink invariant: validated at construction") + .to_bytes(), + data: mst_root_bytes.to_vec(), + }; let event = SequencedEvent { seq: SequenceNumber::ZERO, did: did.clone(), @@ -192,7 +218,10 @@ impl EventOps { prev_data_cid: Some(mst_root_cid.clone()), ops: None, blobs: None, - blocks_cids: None, + blocks: Some(tranquil_db_traits::EventBlocks::Inline(vec![ + commit_block, + mst_block, + ])), handle: None, active: None, status: None, @@ -208,7 +237,7 @@ impl EventOps { limit: Option, ) -> Result, DbError> { let events = self.bridge.get_events_since_seq(since, limit)?; - self.apply_sidecars_and_filter(events) + self.filter_tombstoned(events) } pub fn get_events_in_seq_range( @@ -217,7 +246,7 @@ impl EventOps { end: SequenceNumber, ) -> Result, DbError> { let events = self.bridge.get_events_in_seq_range(start, end)?; - self.apply_sidecars_and_filter(events) + self.filter_tombstoned(events) } pub fn get_event_by_seq(&self, seq: SequenceNumber) -> Result, DbError> { @@ -230,9 +259,7 @@ impl EventOps { return Ok(None); } - self.bridge - .get_event_by_seq(seq) - .map(|opt| opt.map(|e| self.merge_sidecar(e))) + self.bridge.get_event_by_seq(seq) } pub fn get_events_since_cursor( @@ -241,7 +268,7 @@ impl EventOps { limit: i64, ) -> Result, DbError> { let events = self.bridge.get_events_since_cursor(cursor, limit)?; - self.apply_sidecars_and_filter(events) + self.filter_tombstoned(events) } pub fn get_max_seq(&self) -> SequenceNumber { @@ -255,56 +282,6 @@ impl EventOps { self.bridge.get_min_seq_since(since) } - pub fn get_events_since_rev( - &self, - did: &Did, - since_rev: &str, - ) -> Result, DbError> { - let user_hash = UserHash::from_did(did.as_str()); - - let key = rev_to_seq_key(user_hash, since_rev); - let since_seq_u64 = match self.repo_data.get(key).map_err(fjall_to_db)? { - Some(bytes) => { - let arr: [u8; 8] = bytes - .as_ref() - .try_into() - .map_err(|_| DbError::Query("corrupt rev_to_seq value".to_owned()))?; - u64::from_be_bytes(arr) - } - None => return Ok(Vec::new()), - }; - - let start_seq = match since_seq_u64.checked_add(1) { - Some(s) => s, - None => return Ok(Vec::new()), - }; - - let user_seqs = self.scan_did_events(user_hash, start_seq)?; - - user_seqs - .into_iter() - .try_fold(Vec::new(), |mut acc, seq_u64| { - if self.is_tombstoned(seq_u64)? { - return Ok(acc); - } - let seq_sn = SequenceNumber::from_raw( - i64::try_from(seq_u64) - .map_err(|_| DbError::Query("seq exceeds i64::MAX".to_owned()))?, - ); - match self.bridge.get_event_by_seq(seq_sn)? { - Some(event) if event.rev.is_some() => { - let merged = self.merge_sidecar(event); - acc.push(EventBlocksCids { - blocks_cids: merged.blocks_cids, - commit_cid: merged.commit_cid, - }); - Ok(acc) - } - _ => Ok(acc), - } - }) - } - pub fn get_blob_cids_since_rev( &self, did: &Did, @@ -359,23 +336,6 @@ impl EventOps { }) } - pub fn update_seq_blocks_cids( - &self, - seq: SequenceNumber, - blocks_cids: &[String], - ) -> Result<(), DbError> { - let seq_u64 = seq - .as_u64() - .ok_or_else(|| DbError::Query("invalid sequence number".to_owned()))?; - let key = seq_meta_key(seq_u64); - let value = SeqMetaValue { - blocks_cids: blocks_cids.to_vec(), - }; - self.repo_data - .insert(key.as_slice(), value.serialize()) - .map_err(fjall_to_db) - } - pub fn delete_sequences_except( &self, did: &Did, @@ -413,7 +373,6 @@ impl EventOps { seqs.iter().for_each(|&seq| { batch.insert(&self.repo_data, seq_tombstone_key(seq).as_slice(), []); batch.remove(&self.repo_data, did_events_key(user_hash, seq).as_slice()); - batch.remove(&self.repo_data, seq_meta_key(seq).as_slice()); }); stale_rev_keys.iter().for_each(|key| { batch.remove(&self.repo_data, key.as_slice()); @@ -707,29 +666,7 @@ impl EventOps { } } - fn merge_sidecar(&self, mut event: SequencedEvent) -> SequencedEvent { - let seq_u64 = match event.seq.as_u64() { - Some(v) => v, - None => return event, - }; - - let key = seq_meta_key(seq_u64); - match self.repo_data.get(key.as_slice()) { - Ok(Some(sidecar_bytes)) => { - if let Some(sidecar) = SeqMetaValue::deserialize(sidecar_bytes.as_ref()) { - event.blocks_cids = Some(sidecar.blocks_cids); - } - } - Ok(None) => {} - Err(e) => { - warn!(seq = seq_u64, error = %e, "failed to read seq sidecar, returning event without sidecar merge"); - } - } - - event - } - - fn apply_sidecars_and_filter( + fn filter_tombstoned( &self, events: Vec, ) -> Result, DbError> { @@ -739,7 +676,7 @@ impl EventOps { None => false, }; if !tombstoned { - acc.push(self.merge_sidecar(e)); + acc.push(e); } Ok(acc) }) @@ -832,7 +769,7 @@ mod tests { prev_cid: None, ops: Some(serde_json::json!([{"action": "create", "path": "app.bsky.feed.post/abc"}])), blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("3k2abcde".to_owned()), }; @@ -850,7 +787,7 @@ mod tests { #[test] fn insert_and_query_identity_event() { let h = setup(); - let handle = Handle::new("alice.test").unwrap(); + let handle = Handle::new("olaren.test").unwrap(); let seq = h .event_ops @@ -862,7 +799,7 @@ mod tests { assert_eq!(event.event_type, RepoEventType::Identity); assert_eq!( event.handle.as_ref().map(|h| h.as_str()), - Some("alice.test") + Some("olaren.test") ); } @@ -888,7 +825,7 @@ mod tests { let seq = h .event_ops - .insert_sync_event(&test_did(), &cid, Some("rev1")) + .insert_sync_event(&test_did(), &cid, Some("rev1"), b"sync_commit_bytes") .unwrap(); assert!(seq.as_i64() > 0); @@ -906,7 +843,14 @@ mod tests { let seq = h .event_ops - .insert_genesis_commit_event(&test_did(), &commit_cid, &mst_cid, "genesis_rev") + .insert_genesis_commit_event( + &test_did(), + &commit_cid, + &mst_cid, + "genesis_rev", + b"genesis_commit_bytes", + b"genesis_mst_bytes", + ) .unwrap(); assert!(seq.as_i64() > 0); @@ -1019,30 +963,6 @@ mod tests { assert_eq!(h.event_ops.get_max_seq(), seq); } - #[test] - fn update_seq_blocks_cids_merges_on_query() { - let h = setup(); - let data = CommitEventData { - did: test_did(), - event_type: RepoEventType::Commit, - commit_cid: Some(test_cid_link()), - prev_cid: None, - ops: None, - blobs: None, - blocks_cids: None, - prev_data_cid: None, - rev: Some("rev1".to_owned()), - }; - - let seq = h.event_ops.insert_commit_event(&data).unwrap(); - - let blocks = vec!["bafyblock1".to_owned(), "bafyblock2".to_owned()]; - h.event_ops.update_seq_blocks_cids(seq, &blocks).unwrap(); - - let event = h.event_ops.get_event_by_seq(seq).unwrap().unwrap(); - assert_eq!(event.blocks_cids, Some(blocks)); - } - #[test] fn delete_sequences_except_tombstones_others() { let h = setup(); @@ -1058,7 +978,7 @@ mod tests { prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev_a".to_owned()), }) @@ -1073,7 +993,7 @@ mod tests { prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev_b".to_owned()), }) @@ -1100,7 +1020,7 @@ mod tests { prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev_x".to_owned()), }) @@ -1115,7 +1035,7 @@ mod tests { prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev_y".to_owned()), }) @@ -1131,56 +1051,6 @@ mod tests { assert_eq!(events[0].seq, seq2); } - #[test] - fn get_events_since_rev() { - let h = setup(); - let did = test_did(); - let cid = test_cid_link(); - - h.event_ops - .insert_commit_event(&CommitEventData { - did: did.clone(), - event_type: RepoEventType::Commit, - commit_cid: Some(cid.clone()), - prev_cid: None, - ops: None, - blobs: None, - blocks_cids: Some(vec!["block_a".to_owned()]), - prev_data_cid: None, - rev: Some("rev_1".to_owned()), - }) - .unwrap(); - - h.event_ops - .insert_commit_event(&CommitEventData { - did: did.clone(), - event_type: RepoEventType::Commit, - commit_cid: Some(cid.clone()), - prev_cid: None, - ops: None, - blobs: None, - blocks_cids: Some(vec!["block_b".to_owned()]), - prev_data_cid: None, - rev: Some("rev_2".to_owned()), - }) - .unwrap(); - - let events = h.event_ops.get_events_since_rev(&did, "rev_1").unwrap(); - - assert_eq!(events.len(), 1); - assert_eq!(events[0].blocks_cids, Some(vec!["block_b".to_owned()])); - } - - #[test] - fn get_events_since_rev_unknown_rev_returns_empty() { - let h = setup(); - let events = h - .event_ops - .get_events_since_rev(&test_did(), "nonexistent_rev") - .unwrap(); - assert!(events.is_empty()); - } - #[test] fn metastore_cursor_read_write() { let h = setup(); @@ -1251,7 +1121,7 @@ mod tests { prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("r1".to_owned()), }) @@ -1263,7 +1133,7 @@ mod tests { .unwrap(); let s4 = h .event_ops - .insert_sync_event(&did, &cid, Some("r2")) + .insert_sync_event(&did, &cid, Some("r2"), b"sync_commit_bytes") .unwrap(); let events = h @@ -1296,7 +1166,7 @@ mod tests { prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev_keep".to_owned()), }) @@ -1311,7 +1181,7 @@ mod tests { let keep_seq = h .event_ops - .insert_sync_event(&did, &cid, Some("rev_sync")) + .insert_sync_event(&did, &cid, Some("rev_sync"), b"sync_commit_bytes") .unwrap(); h.event_ops.delete_sequences_except(&did, keep_seq).unwrap(); @@ -1351,7 +1221,7 @@ mod tests { prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev_old".to_owned()), }) @@ -1366,7 +1236,7 @@ mod tests { prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev_keep".to_owned()), }) @@ -1417,7 +1287,7 @@ mod tests { prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev_a".to_owned()), }) @@ -1432,7 +1302,7 @@ mod tests { prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev_b".to_owned()), }) @@ -1460,154 +1330,6 @@ mod tests { ); } - #[test] - fn delete_sequences_except_cleans_seq_meta_entries() { - let h = setup(); - let did = test_did(); - let cid = test_cid_link(); - - let seq1 = h - .event_ops - .insert_commit_event(&CommitEventData { - did: did.clone(), - event_type: RepoEventType::Commit, - commit_cid: Some(cid.clone()), - prev_cid: None, - ops: None, - blobs: None, - blocks_cids: None, - prev_data_cid: None, - rev: Some("rev_a".to_owned()), - }) - .unwrap(); - - let seq2 = h - .event_ops - .insert_commit_event(&CommitEventData { - did: did.clone(), - event_type: RepoEventType::Commit, - commit_cid: Some(cid.clone()), - prev_cid: None, - ops: None, - blobs: None, - blocks_cids: None, - prev_data_cid: None, - rev: Some("rev_b".to_owned()), - }) - .unwrap(); - - h.event_ops - .update_seq_blocks_cids(seq1, &["block1".to_owned()]) - .unwrap(); - h.event_ops - .update_seq_blocks_cids(seq2, &["block2".to_owned()]) - .unwrap(); - - let stale_key = super::super::event_keys::seq_meta_key(seq1.as_u64().unwrap()); - assert!( - h.event_ops - .repo_data - .get(stale_key.as_slice()) - .unwrap() - .is_some() - ); - - h.event_ops.delete_sequences_except(&did, seq2).unwrap(); - - assert!( - h.event_ops - .repo_data - .get(stale_key.as_slice()) - .unwrap() - .is_none() - ); - - let kept_key = super::super::event_keys::seq_meta_key(seq2.as_u64().unwrap()); - assert!( - h.event_ops - .repo_data - .get(kept_key.as_slice()) - .unwrap() - .is_some() - ); - } - - #[test] - fn get_events_since_rev_excludes_events_without_rev() { - let h = setup(); - let did = test_did(); - let cid = test_cid_link(); - - h.event_ops - .insert_commit_event(&CommitEventData { - did: did.clone(), - event_type: RepoEventType::Commit, - commit_cid: Some(cid.clone()), - prev_cid: None, - ops: None, - blobs: None, - blocks_cids: Some(vec!["block_1".to_owned()]), - prev_data_cid: None, - rev: Some("rev_1".to_owned()), - }) - .unwrap(); - - h.event_ops.insert_identity_event(&did, None).unwrap(); - - h.event_ops - .insert_account_event(&did, AccountStatus::Active) - .unwrap(); - - h.event_ops - .insert_commit_event(&CommitEventData { - did: did.clone(), - event_type: RepoEventType::Commit, - commit_cid: Some(cid.clone()), - prev_cid: None, - ops: None, - blobs: None, - blocks_cids: Some(vec!["block_2".to_owned()]), - prev_data_cid: None, - rev: Some("rev_2".to_owned()), - }) - .unwrap(); - - let events = h.event_ops.get_events_since_rev(&did, "rev_1").unwrap(); - - assert_eq!(events.len(), 1); - assert_eq!(events[0].blocks_cids, Some(vec!["block_2".to_owned()])); - } - - #[test] - fn sync_event_with_rev_appears_in_get_events_since_rev() { - let h = setup(); - let did = test_did(); - let cid = test_cid_link(); - - h.event_ops - .insert_commit_event(&CommitEventData { - did: did.clone(), - event_type: RepoEventType::Commit, - commit_cid: Some(cid.clone()), - prev_cid: None, - ops: None, - blobs: None, - blocks_cids: None, - prev_data_cid: None, - rev: Some("rev_a".to_owned()), - }) - .unwrap(); - - h.event_ops - .insert_sync_event(&did, &cid, Some("rev_b")) - .unwrap(); - - let events = h.event_ops.get_events_since_rev(&did, "rev_a").unwrap(); - - assert_eq!(events.len(), 1); - assert_eq!(events[0].commit_cid, Some(cid)); - } - #[test] fn recover_sidecar_indexes_no_gap() { let h = setup(); @@ -1622,7 +1344,7 @@ mod tests { prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev_1".to_owned()), }) @@ -1651,7 +1373,7 @@ mod tests { prev_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, prev_data_cid: None, rev: Some("rev_1".to_owned()), }) @@ -1671,7 +1393,7 @@ mod tests { prev_data_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, handle: None, active: None, status: None, @@ -1689,7 +1411,7 @@ mod tests { prev_data_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, handle: None, active: None, status: None, @@ -1707,7 +1429,7 @@ mod tests { prev_data_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, handle: None, active: None, status: None, @@ -1715,18 +1437,26 @@ mod tests { }; h.event_ops.bridge.insert_event(&crash_event_3).unwrap(); + let user_hash = super::UserHash::from_did(did.as_str()); + let rev2_key = super::super::event_keys::rev_to_seq_key(user_hash, "rev_2"); assert!( h.event_ops - .get_events_since_rev(&did, "rev_2") + .repo_data + .get(rev2_key.as_slice()) .unwrap() - .is_empty() + .is_none() ); let recovered = h.event_ops.recover_sidecar_indexes().unwrap(); assert_eq!(recovered, 3); - let events = h.event_ops.get_events_since_rev(&did, "rev_2").unwrap(); - assert_eq!(events.len(), 1); + assert!( + h.event_ops + .repo_data + .get(rev2_key.as_slice()) + .unwrap() + .is_some() + ); let cursor = h.event_ops.read_last_applied_cursor().unwrap(); assert!(cursor.is_some()); diff --git a/crates/tranquil-store/src/metastore/handler.rs b/crates/tranquil-store/src/metastore/handler.rs index e7b6bed..a4a5004 100644 --- a/crates/tranquil-store/src/metastore/handler.rs +++ b/crates/tranquil-store/src/metastore/handler.rs @@ -7,13 +7,13 @@ use tokio::sync::oneshot; use tranquil_db_traits::DbScope; use tranquil_db_traits::{ AccountSearchResult, AccountStatus, AdminAccountInfo, ApplyCommitError, ApplyCommitInput, - ApplyCommitResult, Backlink, BrokenGenesisCommit, CommitEventData, CommsChannel, CommsType, + ApplyCommitResult, Backlink, CommitEventData, CommsChannel, CommsType, CompletePasskeySetupInput, CreateAccountError, CreateDelegatedAccountInput, CreatePasskeyAccountInput, CreatePasswordAccountInput, CreatePasswordAccountResult, CreateSsoAccountInput, DbError, DelegationActionType, DeletionRequest, - DeletionRequestWithToken, DidWebOverrides, EventBlocksCids, ImportBlock, ImportRecord, - ImportRepoError, InviteCodeError, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, - InviteCodeUse, MigrationReactivationError, MigrationReactivationInput, NotificationHistoryRow, + DeletionRequestWithToken, DidWebOverrides, ImportBlock, ImportRecord, ImportRepoError, + InviteCodeError, InviteCodeInfo, InviteCodeRow, InviteCodeSortOrder, InviteCodeUse, + MigrationReactivationError, MigrationReactivationInput, NotificationHistoryRow, NotificationPrefs, OAuthTokenWithUser, PasswordResetResult, PlcTokenInfo, QueuedComms, ReactivatedAccountInfo, RecoverPasskeyAccountInput, RecoverPasskeyAccountResult, RefreshSessionResult, ReservedSigningKey, ReservedSigningKeyFull, ScheduledDeletionAccount, @@ -64,6 +64,9 @@ fn metastore_to_db(e: MetastoreError) -> DbError { } => DbError::Constraint(format!( "user hash collision: {hash} maps to both {existing_uuid} and {new_uuid}" )), + MetastoreError::UniqueViolation(constraint) => { + DbError::Constraint(format!("unique constraint violated: {constraint}")) + } } } @@ -315,10 +318,6 @@ pub enum UserBlockRequest { user_id: Uuid, tx: Tx, }, - FindUnreferencedBlocks { - candidate_cids: Vec>, - tx: Tx>>, - }, } impl UserBlockRequest { @@ -328,7 +327,6 @@ impl UserBlockRequest { | Self::DeleteUserBlocks { user_id, .. } | Self::GetUserBlockCidsSinceRev { user_id, .. } | Self::CountUserBlocks { user_id, .. } => uuid_to_routing(user_hashes, user_id), - Self::FindUnreferencedBlocks { .. } => Routing::Global, } } } @@ -352,6 +350,7 @@ pub enum EventRequest { did: Did, commit_cid: CidLink, rev: Option, + commit_bytes: Vec, tx: Tx, }, InsertGenesisCommitEvent { @@ -359,13 +358,10 @@ pub enum EventRequest { commit_cid: CidLink, mst_root_cid: CidLink, rev: String, + commit_bytes: Vec, + mst_root_bytes: Vec, tx: Tx, }, - UpdateSeqBlocksCids { - seq: SequenceNumber, - blocks_cids: Vec, - tx: Tx<()>, - }, DeleteSequencesExcept { did: Did, keep_seq: SequenceNumber, @@ -397,11 +393,6 @@ pub enum EventRequest { limit: i64, tx: Tx>, }, - GetEventsSinceRev { - did: Did, - since_rev: String, - tx: Tx>, - }, NotifyUpdate { seq: SequenceNumber, tx: Tx<()>, @@ -418,12 +409,10 @@ impl EventRequest { | Self::InsertAccountEvent { did, .. } | Self::InsertSyncEvent { did, .. } | Self::InsertGenesisCommitEvent { did, .. } - | Self::DeleteSequencesExcept { did, .. } - | Self::GetEventsSinceRev { did, .. } => { + | Self::DeleteSequencesExcept { did, .. } => { Routing::Sharded(UserHash::from_did(did.as_str()).raw()) } - Self::UpdateSeqBlocksCids { .. } - | Self::GetMaxSeq { .. } + Self::GetMaxSeq { .. } | Self::GetMinSeqSince { .. } | Self::GetEventsSinceSeq { .. } | Self::GetEventsInSeqRange { .. } @@ -446,9 +435,6 @@ pub enum CommitRequest { expected_root_cid: Option, tx: oneshot::Sender>, }, - GetBrokenGenesisCommits { - tx: Tx>, - }, GetUsersWithoutBlocks { tx: Tx>, }, @@ -472,8 +458,7 @@ impl CommitRequest { | Self::InsertRecordBlobs { repo_id: user_id, .. } => uuid_to_routing(user_hashes, user_id), - Self::GetBrokenGenesisCommits { .. } - | Self::GetUsersWithoutBlocks { .. } + Self::GetUsersWithoutBlocks { .. } | Self::GetUsersNeedingRecordBlobsBackfill { .. } => Routing::Global, } } @@ -2912,17 +2897,10 @@ fn dispatch_user_block(state: &HandlerState, req: UserBlockRequ .map_err(metastore_to_db); let _ = tx.send(result); } - UserBlockRequest::FindUnreferencedBlocks { candidate_cids, tx } => { - let result = state - .metastore - .user_block_ops() - .find_unreferenced(&candidate_cids); - let _ = tx.send(Ok(result)); - } } } -fn dispatch_event(state: &HandlerState, req: EventRequest) { +fn dispatch_event(state: &HandlerState, req: EventRequest) { match req { EventRequest::InsertCommitEvent { data, tx } => { let result = state.event_ops.insert_commit_event(&data); @@ -2940,11 +2918,13 @@ fn dispatch_event(state: &HandlerState, req: EventRequest) { did, commit_cid, rev, + commit_bytes, tx, } => { - let result = state - .event_ops - .insert_sync_event(&did, &commit_cid, rev.as_deref()); + let result = + state + .event_ops + .insert_sync_event(&did, &commit_cid, rev.as_deref(), &commit_bytes); let _ = tx.send(result); } EventRequest::InsertGenesisCommitEvent { @@ -2952,20 +2932,18 @@ fn dispatch_event(state: &HandlerState, req: EventRequest) { commit_cid, mst_root_cid, rev, + commit_bytes, + mst_root_bytes, tx, } => { - let result = - state - .event_ops - .insert_genesis_commit_event(&did, &commit_cid, &mst_root_cid, &rev); - let _ = tx.send(result); - } - EventRequest::UpdateSeqBlocksCids { - seq, - blocks_cids, - tx, - } => { - let result = state.event_ops.update_seq_blocks_cids(seq, &blocks_cids); + let result = state.event_ops.insert_genesis_commit_event( + &did, + &commit_cid, + &mst_root_cid, + &rev, + &commit_bytes, + &mst_root_bytes, + ); let _ = tx.send(result); } EventRequest::DeleteSequencesExcept { did, keep_seq, tx } => { @@ -2998,16 +2976,13 @@ fn dispatch_event(state: &HandlerState, req: EventRequest) { EventRequest::GetEventsSinceCursor { cursor, limit, tx } => { let _ = tx.send(state.event_ops.get_events_since_cursor(cursor, limit)); } - EventRequest::GetEventsSinceRev { did, since_rev, tx } => { - let _ = tx.send(state.event_ops.get_events_since_rev(&did, &since_rev)); - } EventRequest::NotifyUpdate { seq, tx } => { let _ = tx.send(state.event_ops.notify_update(seq)); } } } -fn dispatch_commit(state: &HandlerState, req: CommitRequest) { +fn dispatch_commit(state: &HandlerState, req: CommitRequest) { match req { CommitRequest::ApplyCommit { input, tx } => { let _ = tx.send(state.commit_ops.apply_commit(*input)); @@ -3026,14 +3001,6 @@ fn dispatch_commit(state: &HandlerState, req: CommitRequest) { expected_root_cid.as_ref(), )); } - CommitRequest::GetBrokenGenesisCommits { tx } => { - let _ = tx.send( - state - .commit_ops - .get_broken_genesis_commits() - .map_err(metastore_to_db), - ); - } CommitRequest::GetUsersWithoutBlocks { tx } => { let _ = tx.send( state @@ -3138,7 +3105,7 @@ fn dispatch_backlink(state: &HandlerState, req: BacklinkRequest } } -fn dispatch_blob(state: &HandlerState, req: BlobRequest) { +fn dispatch_blob(state: &HandlerState, req: BlobRequest) { match req { BlobRequest::InsertBlob { cid, @@ -4160,6 +4127,13 @@ fn dispatch_infra(state: &HandlerState, req: InfraRequest) { value_json, tx, } => { + if name == "email_auth_factor" { + let enabled = value_json.as_bool().unwrap_or(false); + let _ = state + .metastore + .user_ops() + .set_email_2fa_enabled(user_id, enabled); + } let result = state .metastore .infra_ops() @@ -5005,7 +4979,7 @@ fn dispatch_oauth(state: &HandlerState, req: OAuthRequest) { } } -fn dispatch(state: &HandlerState, request: MetastoreRequest) { +fn dispatch(state: &HandlerState, request: MetastoreRequest) { match request { MetastoreRequest::Repo(r) => dispatch_repo(state, r), MetastoreRequest::Record(r) => dispatch_record(state, r), @@ -5023,7 +4997,7 @@ fn dispatch(state: &HandlerState, request: MetastoreRequest) { } } -fn dispatch_user(state: &HandlerState, req: UserRequest) { +fn dispatch_user(state: &HandlerState, req: UserRequest) { let user = state.metastore.user_ops(); match req { UserRequest::GetByDid { did, tx } => { @@ -5768,7 +5742,17 @@ fn dispatch_user(state: &HandlerState, req: UserRequest) { let _ = tx.send(result.map(|seq| seq.as_i64())); } UserRequest::CreatePasswordAccount { input, tx } => { - let _ = tx.send(user.create_password_account(&input)); + let result = user.create_password_account(&input).and_then(|result| { + if let Some(key_id) = input.reserved_key_id { + state + .metastore + .infra_ops() + .mark_signing_key_used(key_id) + .map_err(|e| CreateAccountError::Database(e.to_string()))?; + } + Ok(result) + }); + let _ = tx.send(result); } UserRequest::CreateDelegatedAccount { input, tx } => { let result = user.create_delegated_account(&input).and_then(|account| { @@ -5797,10 +5781,39 @@ fn dispatch_user(state: &HandlerState, req: UserRequest) { let _ = tx.send(result); } UserRequest::CreatePasskeyAccount { input, tx } => { - let _ = tx.send(user.create_passkey_account(&input)); + let result = user.create_passkey_account(&input).and_then(|result| { + if let Some(key_id) = input.reserved_key_id { + state + .metastore + .infra_ops() + .mark_signing_key_used(key_id) + .map_err(|e| CreateAccountError::Database(e.to_string()))?; + } + Ok(result) + }); + let _ = tx.send(result); } UserRequest::CreateSsoAccount { input, tx } => { - let _ = tx.send(user.create_sso_account(&input)); + let sso_ops = state.metastore.sso_ops(); + let result = sso_ops + .consume_pending_registration(&input.pending_registration_token) + .map_err(|e| CreateAccountError::Database(e.to_string())) + .and_then(|consumed| match consumed { + Some(_) => user.create_sso_account(&input).and_then(|result| { + sso_ops + .create_external_identity( + &input.did, + input.sso_provider, + &input.sso_provider_user_id, + input.sso_provider_username.as_deref(), + input.sso_provider_email.as_deref(), + ) + .map_err(|e| CreateAccountError::Database(e.to_string()))?; + Ok(result) + }), + None => Err(CreateAccountError::InvalidToken), + }); + let _ = tx.send(result); } UserRequest::ReactivateMigrationAccount { input, tx } => { let _ = tx.send(user.reactivate_migration_account(&input)); @@ -5884,7 +5897,7 @@ fn dispatch_user(state: &HandlerState, req: UserRequest) { } } -fn handler_loop( +fn handler_loop( metastore: Metastore, bridge: Arc>, blockstore: Option, diff --git a/crates/tranquil-store/src/metastore/infra_ops.rs b/crates/tranquil-store/src/metastore/infra_ops.rs index cc02617..800cbf1 100644 --- a/crates/tranquil-store/src/metastore/infra_ops.rs +++ b/crates/tranquil-store/src/metastore/infra_ops.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use chrono::{DateTime, Utc}; use fjall::{Database, Keyspace}; +use smallvec::SmallVec; use uuid::Uuid; use super::MetastoreError; @@ -35,6 +36,7 @@ pub struct InfraOps { repo_data: Keyspace, users: Keyspace, user_hashes: Arc, + comms_seq: Arc, } impl InfraOps { @@ -44,6 +46,7 @@ impl InfraOps { repo_data: Keyspace, users: Keyspace, user_hashes: Arc, + comms_seq: Arc, ) -> Self { Self { db, @@ -51,6 +54,7 @@ impl InfraOps { repo_data, users, user_hashes, + comms_seq, } } @@ -189,7 +193,10 @@ impl InfraOps { status: status_to_u8(CommsStatus::Pending), created_at_ms: now_ms, }; - let history_key = comms_history_key(uid, now_ms, id); + let seq = self + .comms_seq + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let history_key = comms_history_key(uid, now_ms, seq, id); batch.insert( &self.infra, history_key.as_slice(), @@ -245,19 +252,11 @@ impl InfraOps { let mut batch = self.db.batch(); batch.insert(&self.infra, key.as_slice(), val.serialize()); - let history_key = comms_history_key( - val.user_id.unwrap_or(Uuid::nil()), - val.created_at_ms, - val.id, - ); - if let Some(mut history_val) = point_lookup( - &self.infra, - history_key.as_slice(), - NotificationHistoryValue::deserialize, - "corrupt notification history", - )? { - history_val.status = status_to_u8(CommsStatus::Sent); - batch.insert(&self.infra, history_key.as_slice(), history_val.serialize()); + if let Some((hk, mut hv)) = + self.find_history_entry(val.user_id.unwrap_or(Uuid::nil()), val.id)? + { + hv.status = status_to_u8(CommsStatus::Sent); + batch.insert(&self.infra, hk.as_slice(), hv.serialize()); } batch.commit().map_err(MetastoreError::Fjall) @@ -280,24 +279,46 @@ impl InfraOps { let mut batch = self.db.batch(); batch.insert(&self.infra, key.as_slice(), val.serialize()); - let history_key = comms_history_key( - val.user_id.unwrap_or(Uuid::nil()), - val.created_at_ms, - val.id, - ); - if let Some(mut history_val) = point_lookup( - &self.infra, - history_key.as_slice(), - NotificationHistoryValue::deserialize, - "corrupt notification history", - )? { - history_val.status = status_to_u8(CommsStatus::Failed); - batch.insert(&self.infra, history_key.as_slice(), history_val.serialize()); + if let Some((hk, mut hv)) = + self.find_history_entry(val.user_id.unwrap_or(Uuid::nil()), val.id)? + { + hv.status = status_to_u8(CommsStatus::Failed); + batch.insert(&self.infra, hk.as_slice(), hv.serialize()); } batch.commit().map_err(MetastoreError::Fjall) } + #[allow(clippy::type_complexity)] + fn find_history_entry( + &self, + user_id: Uuid, + comms_id: Uuid, + ) -> Result, NotificationHistoryValue)>, MetastoreError> { + let prefix = comms_history_prefix(user_id); + self.infra + .prefix(prefix.as_slice()) + .find_map(|guard| { + let (key_bytes, val_bytes) = match guard.into_inner() { + Ok(kv) => kv, + Err(e) => return Some(Err(MetastoreError::Fjall(e))), + }; + let val = match NotificationHistoryValue::deserialize(&val_bytes) { + Some(v) => v, + None => { + return Some(Err(MetastoreError::CorruptData( + "corrupt notification history", + ))); + } + }; + match val.id == comms_id { + true => Some(Ok((SmallVec::from_slice(&key_bytes), val))), + false => None, + } + }) + .transpose() + } + pub fn create_invite_code( &self, code: &str, @@ -743,10 +764,13 @@ impl InfraOps { SigningKeyValue::deserialize, "corrupt signing key", )?; - Ok(val.map(|v| ReservedSigningKey { - id: v.id, - private_key_bytes: v.private_key_bytes, - })) + let now_ms = Utc::now().timestamp_millis(); + Ok(val + .filter(|v| !v.used && v.expires_at_ms > now_ms) + .map(|v| ReservedSigningKey { + id: v.id, + private_key_bytes: v.private_key_bytes, + })) } pub fn mark_signing_key_used(&self, key_id: Uuid) -> Result<(), MetastoreError> { diff --git a/crates/tranquil-store/src/metastore/infra_schema.rs b/crates/tranquil-store/src/metastore/infra_schema.rs index 47fc794..c53dd63 100644 --- a/crates/tranquil-store/src/metastore/infra_schema.rs +++ b/crates/tranquil-store/src/metastore/infra_schema.rs @@ -452,13 +452,16 @@ pub fn plc_token_prefix(user_id: uuid::Uuid) -> SmallVec<[u8; 128]> { pub fn comms_history_key( user_id: uuid::Uuid, created_at_ms: i64, + seq: u32, id: uuid::Uuid, ) -> SmallVec<[u8; 128]> { let reversed_ts = i64::MAX.saturating_sub(created_at_ms); + let reversed_seq = u32::MAX.saturating_sub(seq); KeyBuilder::new() .tag(KeyTag::INFRA_COMMS_HISTORY) .bytes(user_id.as_bytes()) .i64(reversed_ts) + .bytes(&reversed_seq.to_be_bytes()) .bytes(id.as_bytes()) .build() } @@ -655,8 +658,8 @@ mod tests { let user_id = uuid::Uuid::new_v4(); let id_a = uuid::Uuid::new_v4(); let id_b = uuid::Uuid::new_v4(); - let key_old = comms_history_key(user_id, 1000, id_a); - let key_new = comms_history_key(user_id, 2000, id_b); + let key_old = comms_history_key(user_id, 1000, 0, id_a); + let key_new = comms_history_key(user_id, 2000, 0, id_b); assert!(key_new.as_slice() < key_old.as_slice()); } diff --git a/crates/tranquil-store/src/metastore/keys.rs b/crates/tranquil-store/src/metastore/keys.rs index 2398336..9363a95 100644 --- a/crates/tranquil-store/src/metastore/keys.rs +++ b/crates/tranquil-store/src/metastore/keys.rs @@ -47,7 +47,6 @@ impl KeyTag { pub const USER_MAP_REVERSE: Self = Self(0x11); pub const REV_TO_SEQ: Self = Self(0x20); - pub const SEQ_META: Self = Self(0x21); pub const SEQ_TOMBSTONE: Self = Self(0x22); pub const METASTORE_CURSOR: Self = Self(0x23); pub const DID_EVENTS: Self = Self(0x24); @@ -181,7 +180,6 @@ mod tests { KeyTag::USER_MAP, KeyTag::USER_MAP_REVERSE, KeyTag::REV_TO_SEQ, - KeyTag::SEQ_META, KeyTag::SEQ_TOMBSTONE, KeyTag::METASTORE_CURSOR, KeyTag::DID_EVENTS, diff --git a/crates/tranquil-store/src/metastore/mod.rs b/crates/tranquil-store/src/metastore/mod.rs index c0b8d7f..39af6ed 100644 --- a/crates/tranquil-store/src/metastore/mod.rs +++ b/crates/tranquil-store/src/metastore/mod.rs @@ -30,7 +30,7 @@ pub mod user_hash; pub mod user_ops; pub mod users; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use fjall::{Database, Keyspace}; @@ -50,9 +50,10 @@ impl Default for MetastoreConfig { fn default() -> Self { let total_ram = total_system_ram_bytes(); let twenty_percent = total_ram / 5; + let max_cache: u64 = 4 * 1024 * 1024 * 1024; Self { - cache_size_bytes: twenty_percent, + cache_size_bytes: twenty_percent.min(max_cache), } } } @@ -97,6 +98,7 @@ pub enum MetastoreError { existing_uuid: uuid::Uuid, new_uuid: uuid::Uuid, }, + UniqueViolation(&'static str), } impl std::fmt::Display for MetastoreError { @@ -120,6 +122,9 @@ impl std::fmt::Display for MetastoreError { f, "user hash collision: hash {hash} maps to both {existing_uuid} and {new_uuid}" ), + Self::UniqueViolation(constraint) => { + write!(f, "unique constraint violated: {constraint}") + } } } } @@ -158,6 +163,8 @@ pub struct Metastore { partitions: [Keyspace; Partition::ALL.len()], user_hashes: Arc, counter_lock: Arc>, + comms_seq: Arc, + path: PathBuf, } impl Metastore { @@ -199,6 +206,8 @@ impl Metastore { partitions, user_hashes, counter_lock: Arc::new(parking_lot::Mutex::new(())), + comms_seq: Arc::new(std::sync::atomic::AtomicU32::new(0)), + path: path.to_path_buf(), }) } @@ -229,6 +238,10 @@ impl Metastore { } } + pub fn path(&self) -> &Path { + &self.path + } + pub fn partition(&self, p: Partition) -> &Keyspace { &self.partitions[p.index()] } @@ -266,7 +279,7 @@ impl Metastore { ) } - pub fn event_ops( + pub fn event_ops( &self, bridge: Arc>, ) -> event_ops::EventOps { @@ -325,6 +338,7 @@ impl Metastore { self.partitions[Partition::RepoData.index()].clone(), self.partitions[Partition::Users.index()].clone(), Arc::clone(&self.user_hashes), + Arc::clone(&self.comms_seq), ) } @@ -347,7 +361,7 @@ impl Metastore { ) } - pub fn commit_ops( + pub fn commit_ops( &self, bridge: Arc>, ) -> commit_ops::CommitOps { diff --git a/crates/tranquil-store/src/metastore/recovery.rs b/crates/tranquil-store/src/metastore/recovery.rs index 535d64b..f716672 100644 --- a/crates/tranquil-store/src/metastore/recovery.rs +++ b/crates/tranquil-store/src/metastore/recovery.rs @@ -258,11 +258,11 @@ mod tests { block_inserts: vec![vec![0x01, 0x02]], block_deletes: vec![vec![0x03, 0x04]], backlink_adds: vec![BacklinkMutation { - uri: "at://did:plc:alice/app.bsky.feed.like/3k2abc".to_owned(), + uri: "at://did:plc:olaren/app.bsky.feed.like/3k2abc".to_owned(), path: 1, - link_to: "at://did:plc:bob/app.bsky.feed.post/3k2xyz".to_owned(), + link_to: "at://did:plc:teq/app.bsky.feed.post/3k2xyz".to_owned(), }], - backlink_remove_uris: vec!["at://did:plc:alice/app.bsky.feed.like/3k2old".to_owned()], + backlink_remove_uris: vec!["at://did:plc:olaren/app.bsky.feed.like/3k2old".to_owned()], }; let bytes = ms.serialize().unwrap(); diff --git a/crates/tranquil-store/src/metastore/repo_meta.rs b/crates/tranquil-store/src/metastore/repo_meta.rs index bf80e52..65a100f 100644 --- a/crates/tranquil-store/src/metastore/repo_meta.rs +++ b/crates/tranquil-store/src/metastore/repo_meta.rs @@ -80,7 +80,7 @@ mod tests { let value = RepoMetaValue { repo_root_cid: vec![0x01, 0x71, 0x12, 0x20, 0xAB], repo_rev: "3k2a7bcd".to_string(), - handle: "alice.bsky.social".to_string(), + handle: "olaren.example.com".to_string(), status: RepoStatus::Active, deactivated_at_ms: None, takedown_ref: None, @@ -96,11 +96,11 @@ mod tests { let value = RepoMetaValue { repo_root_cid: vec![0x01], repo_rev: "rev1".to_string(), - handle: "bob.example.com".to_string(), + handle: "teq.example.com".to_string(), status: RepoStatus::Deactivated, deactivated_at_ms: Some(1700000000000), takedown_ref: Some("DMCA-123".to_string()), - did: Some("did:plc:bob".to_string()), + did: Some("did:plc:teq".to_string()), }; let bytes = value.serialize(); let decoded = RepoMetaValue::deserialize(&bytes).unwrap(); @@ -128,17 +128,17 @@ mod tests { #[test] fn handle_key_roundtrip() { - let key = handle_key("alice.bsky.social"); + let key = handle_key("olaren.example.com"); let mut reader = KeyReader::new(&key); assert_eq!(reader.tag(), Some(KeyTag::HANDLES.raw())); - assert_eq!(reader.string(), Some("alice.bsky.social".to_string())); + assert_eq!(reader.string(), Some("olaren.example.com".to_string())); assert!(reader.is_empty()); } #[test] fn handle_keys_sort_lexicographically() { - let k1 = handle_key("alice.example.com"); - let k2 = handle_key("bob.example.com"); + let k1 = handle_key("lyna.example.com"); + let k2 = handle_key("teq.example.com"); assert!(k1.as_slice() < k2.as_slice()); } diff --git a/crates/tranquil-store/src/metastore/repo_ops.rs b/crates/tranquil-store/src/metastore/repo_ops.rs index fd28057..318b19d 100644 --- a/crates/tranquil-store/src/metastore/repo_ops.rs +++ b/crates/tranquil-store/src/metastore/repo_ops.rs @@ -606,8 +606,8 @@ mod tests { let (_dir, ms) = open_fresh(); let ops = ms.repo_ops(); let user_id = uuid::Uuid::new_v4(); - let did = test_did("alice"); - let handle = test_handle("alice"); + let did = test_did("olaren"); + let handle = test_handle("olaren"); let cid = test_cid_link(1); ops.create_repo(ms.database(), user_id, &did, &handle, &cid, "rev1") @@ -631,8 +631,8 @@ mod tests { let (_dir, ms) = open_fresh(); let ops = ms.repo_ops(); let user_id = uuid::Uuid::new_v4(); - let did = test_did("bob"); - let handle = test_handle("bob"); + let did = test_did("teq"); + let handle = test_handle("teq"); let cid1 = test_cid_link(1); let cid2 = test_cid_link(2); @@ -651,8 +651,8 @@ mod tests { let (_dir, ms) = open_fresh(); let ops = ms.repo_ops(); let user_id = uuid::Uuid::new_v4(); - let did = test_did("carol"); - let handle = test_handle("carol"); + let did = test_did("nel"); + let handle = test_handle("nel"); let cid = test_cid_link(3); ops.create_repo(ms.database(), user_id, &did, &handle, &cid, "rev1") @@ -670,8 +670,8 @@ mod tests { let (_dir, ms) = open_fresh(); let ops = ms.repo_ops(); let user_id = uuid::Uuid::new_v4(); - let did = test_did("dave"); - let handle = test_handle("dave"); + let did = test_did("lyna"); + let handle = test_handle("lyna"); let cid = test_cid_link(4); ops.create_repo(ms.database(), user_id, &did, &handle, &cid, "rev1") @@ -738,14 +738,14 @@ mod tests { let (_dir, ms) = open_fresh(); let ops = ms.repo_ops(); let user_id = uuid::Uuid::new_v4(); - let did = test_did("eve"); - let handle = test_handle("eve"); + let did = test_did("bailey"); + let handle = test_handle("bailey"); let cid = test_cid_link(5); ops.create_repo(ms.database(), user_id, &did, &handle, &cid, "rev1") .unwrap(); - let upper_handle = Handle::from("EVE.TEST.INVALID".to_string()); + let upper_handle = Handle::from("BAILEY.TEST.INVALID".to_string()); let found = ops.lookup_handle(&upper_handle).unwrap(); assert_eq!(found, Some(user_id)); } @@ -755,8 +755,8 @@ mod tests { let (_dir, ms) = open_fresh(); let ops = ms.repo_ops(); let user_id = uuid::Uuid::new_v4(); - let did = test_did("frank"); - let handle = test_handle("frank"); + let did = test_did("olaren"); + let handle = test_handle("olaren"); let cid = test_cid_link(6); ops.create_repo(ms.database(), user_id, &did, &handle, &cid, "rev1") @@ -765,7 +765,7 @@ mod tests { let root = ops.get_repo_root_by_did(&did).unwrap().unwrap(); assert_eq!(root, cid); - let unknown = test_did("nobody"); + let unknown = test_did("nonexistent"); assert!(ops.get_repo_root_by_did(&unknown).unwrap().is_none()); } @@ -774,8 +774,8 @@ mod tests { let (_dir, ms) = open_fresh(); let ops = ms.repo_ops(); let user_id = uuid::Uuid::new_v4(); - let did = test_did("grace"); - let handle = test_handle("grace"); + let did = test_did("teq"); + let handle = test_handle("teq"); let cid = test_cid_link(7); ops.create_repo(ms.database(), user_id, &did, &handle, &cid, "rev1") @@ -809,8 +809,8 @@ mod tests { let (_dir, ms) = open_fresh(); let ops = ms.repo_ops(); let user_id = uuid::Uuid::new_v4(); - let did = test_did("henry"); - let handle = test_handle("henry"); + let did = test_did("nel"); + let handle = test_handle("nel"); let cid = test_cid_link(8); ops.create_repo(ms.database(), user_id, &did, &handle, &cid, "rev1") diff --git a/crates/tranquil-store/src/metastore/sso_ops.rs b/crates/tranquil-store/src/metastore/sso_ops.rs index fc03ba1..c383225 100644 --- a/crates/tranquil-store/src/metastore/sso_ops.rs +++ b/crates/tranquil-store/src/metastore/sso_ops.rs @@ -103,6 +103,33 @@ impl SsoOps { ) -> Result { let user_hash = UserHash::from_did(did.as_str()); let prov_u8 = provider_to_u8(provider); + + let provider_index = by_provider_key(prov_u8, provider_user_id); + if self + .indexes + .get(provider_index.as_slice()) + .map_err(MetastoreError::Fjall)? + .is_some() + { + return Err(MetastoreError::UniqueViolation( + "provider and provider_user_id", + )); + } + + let did_provider_exists = self + .indexes + .prefix(identity_user_prefix(user_hash).as_slice()) + .any(|guard| { + guard + .into_inner() + .ok() + .and_then(|(_, val_bytes)| ExternalIdentityValue::deserialize(&val_bytes)) + .is_some_and(|v| v.provider == prov_u8) + }); + if did_provider_exists { + return Err(MetastoreError::UniqueViolation("did and provider")); + } + let id = Uuid::new_v4(); let now_ms = Utc::now().timestamp_millis(); @@ -119,7 +146,6 @@ impl SsoOps { }; let primary = identity_key(user_hash, prov_u8, provider_user_id); - let provider_index = by_provider_key(prov_u8, provider_user_id); let id_index = by_id_key(id); let provider_index_val = { @@ -252,7 +278,7 @@ impl SsoOps { } } - pub fn delete_external_identity(&self, id: Uuid, _did: &Did) -> Result { + pub fn delete_external_identity(&self, id: Uuid, did: &Did) -> Result { let id_idx = by_id_key(id); let id_val = match self .indexes @@ -276,6 +302,11 @@ impl SsoOps { let provider_user_id = std::str::from_utf8(&raw[9..]) .map_err(|_| MetastoreError::CorruptData("corrupt sso by_id index"))?; + let expected_hash = UserHash::from_did(did.as_str()); + if user_hash.raw() != expected_hash.raw() { + return Ok(false); + } + let primary = identity_key(user_hash, provider, provider_user_id); let provider_idx = by_provider_key(provider, provider_user_id); diff --git a/crates/tranquil-store/src/metastore/user_block_ops.rs b/crates/tranquil-store/src/metastore/user_block_ops.rs index f148c5f..93b3cd2 100644 --- a/crates/tranquil-store/src/metastore/user_block_ops.rs +++ b/crates/tranquil-store/src/metastore/user_block_ops.rs @@ -144,33 +144,6 @@ impl UserBlockOps { .collect() } - pub fn find_unreferenced(&self, candidate_cids: &[Vec]) -> Vec> { - match candidate_cids.is_empty() { - true => Vec::new(), - false => { - let mut remaining: HashSet> = candidate_cids.iter().cloned().collect(); - let tag_prefix = super::keys::KeyTag::USER_BLOCKS.raw(); - let mut iter = self.repo_data.prefix([tag_prefix]); - loop { - match remaining.is_empty() { - true => break, - false => match iter.next() { - None => break, - Some(guard) => { - if let Ok((key_bytes, _)) = guard.into_inner() - && let Some(cid) = extract_cid_from_key(&key_bytes) - { - remaining.remove(&cid); - } - } - }, - } - } - remaining.into_iter().collect() - } - } - } - pub fn count_user_blocks(&self, user_id: Uuid) -> Result { let user_hash = match self.user_hashes.get(&user_id) { Some(h) => h, diff --git a/crates/tranquil-store/src/metastore/user_ops.rs b/crates/tranquil-store/src/metastore/user_ops.rs index d9eddf6..4b62387 100644 --- a/crates/tranquil-store/src/metastore/user_ops.rs +++ b/crates/tranquil-store/src/metastore/user_ops.rs @@ -1171,19 +1171,15 @@ impl UserOps { pub fn verify_email_channel(&self, user_id: Uuid, email: &str) -> Result { let user_hash = self.resolve_hash_from_uuid(user_id)?; - let val = match self.load_user(user_hash)? { - Some(v) => v, - None => return Ok(false), - }; - - match val.email.as_deref() == Some(email) { - true => { + match self.load_user(user_hash)? { + Some(_) => { self.mutate_user(user_hash, |u| { + u.email = Some(email.to_owned()); u.email_verified = true; })?; Ok(true) } - false => Ok(false), + None => Ok(false), } } @@ -1884,6 +1880,17 @@ impl UserOps { }) } + pub fn set_email_2fa_enabled( + &self, + user_id: Uuid, + enabled: bool, + ) -> Result { + let user_hash = self.resolve_hash_from_uuid(user_id)?; + self.mutate_user(user_hash, |u| { + u.email_2fa_enabled = enabled; + }) + } + pub fn update_locale(&self, did: &Did, locale: &str) -> Result { let user_hash = self.resolve_hash(did.as_str()); self.mutate_user(user_hash, |u| { @@ -2048,23 +2055,16 @@ impl UserOps { match val { Some(rc) => { - let now_ms = Utc::now().timestamp_millis(); - match rc.expires_at_ms > now_ms { - true => { - let user_hash = UserHash::from_raw(rc.user_hash); - let user = self.load_user(user_hash)?; - match user { - Some(u) => Ok(Some(UserResetCodeInfo { - id: u.id, - did: Did::new(u.did.clone()) - .map_err(|_| MetastoreError::CorruptData("invalid user did"))?, - preferred_comms_channel: Self::comms_channel(&u), - expires_at: DateTime::from_timestamp_millis(rc.expires_at_ms), - })), - None => Ok(None), - } - } - false => Ok(None), + let user_hash = UserHash::from_raw(rc.user_hash); + match self.load_user(user_hash)? { + Some(u) => Ok(Some(UserResetCodeInfo { + id: u.id, + did: Did::new(u.did.clone()) + .map_err(|_| MetastoreError::CorruptData("invalid user did"))?, + preferred_comms_channel: Self::comms_channel(&u), + expires_at: DateTime::from_timestamp_millis(rc.expires_at_ms), + })), + None => Ok(None), } } None => Ok(None), @@ -2145,7 +2145,7 @@ impl UserOps { .map_err(|_| MetastoreError::CorruptData("invalid user did"))?; let prefix = super::sessions::session_by_did_prefix(user_hash); - let session_jtis: Vec = self + let sessions: Vec<(Vec, i32, String)> = self .auth .prefix(prefix.as_slice()) .filter_map(|guard| { @@ -2153,19 +2153,43 @@ impl UserOps { let remaining = key_bytes.get(9..13)?; let sid = i32::from_be_bytes(remaining.try_into().ok()?); let session_key = super::sessions::session_primary_key(sid); - self.auth + let jti = self + .auth .get(session_key.as_slice()) .ok() .flatten() .and_then(|raw| super::sessions::SessionTokenValue::deserialize(&raw)) - .map(|s| s.access_jti) + .map(|s| s.access_jti)?; + Some((key_bytes.to_vec(), sid, jti)) }) .collect(); + let session_jtis: Vec = sessions.iter().map(|(_, _, jti)| jti.clone()).collect(); + + let mut batch = self.db.batch(); + let mut updated = user; updated.password_hash = Some(password_hash.to_owned()); + updated.password_required = true; + batch.insert( + &self.users, + super::encoding::KeyBuilder::new() + .tag(super::keys::KeyTag::USER_PRIMARY) + .u64(user_hash.raw()) + .build() + .as_slice(), + updated.serialize(), + ); - self.save_user(user_hash, &updated)?; + for (index_key, sid, _) in &sessions { + let session_key = super::sessions::session_primary_key(*sid); + batch.remove(&self.auth, session_key.as_slice()); + batch.remove(&self.auth, index_key.as_slice()); + } + + batch.commit().map_err(MetastoreError::Fjall)?; + + self.clear_password_reset_code(user_id)?; Ok(PasswordResetResult { did, session_jtis }) } @@ -3198,26 +3222,32 @@ impl UserOps { None => return Ok(()), }; + let past_ms = Utc::now() + .checked_sub_signed(chrono::Duration::hours(1)) + .unwrap_or_else(Utc::now) + .timestamp_millis(); + let prefix = [super::keys::KeyTag::USER_RESET_CODE.raw()]; - let keys_to_remove: Vec> = self + let entries: Vec<(Vec, ResetCodeValue)> = self .auth .prefix(prefix) .filter_map(|guard| { let (key_bytes, val_bytes) = guard.into_inner().ok()?; let rc = ResetCodeValue::deserialize(&val_bytes)?; match rc.user_id == user.id { - true => Some(key_bytes.to_vec()), + true => Some((key_bytes.to_vec(), rc)), false => None, } }) .collect(); - match keys_to_remove.is_empty() { + match entries.is_empty() { true => Ok(()), false => { let mut batch = self.db.batch(); - keys_to_remove.iter().for_each(|key| { - batch.remove(&self.auth, key); + entries.into_iter().for_each(|(key, mut rc)| { + rc.expires_at_ms = past_ms; + batch.insert(&self.auth, &key, rc.serialize_with_ttl()); }); batch.commit().map_err(MetastoreError::Fjall) } diff --git a/crates/tranquil-store/src/sim.rs b/crates/tranquil-store/src/sim.rs index 3d467e9..9fbf01f 100644 --- a/crates/tranquil-store/src/sim.rs +++ b/crates/tranquil-store/src/sim.rs @@ -580,6 +580,30 @@ impl StorageIO for SimulatedIO { } } +pub fn sim_seed_count() -> u64 { + std::env::var("TRANQUIL_SIM_SEEDS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(1_000) +} + +pub fn sim_single_seed() -> Option { + std::env::var("TRANQUIL_SIM_SEED") + .ok() + .and_then(|s| s.parse().ok()) +} + +pub fn sim_seed_range() -> std::ops::Range { + match sim_single_seed() { + Some(seed) => seed..seed + 1, + None => 0..sim_seed_count(), + } +} + +pub fn sim_proptest_cases() -> u32 { + u32::try_from(sim_seed_count()).unwrap_or(u32::MAX) +} + fn splitmix64(mut x: u64) -> u64 { x = x.wrapping_add(0x9e3779b97f4a7c15); x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); diff --git a/crates/tranquil-store/tests/backup.rs b/crates/tranquil-store/tests/backup.rs new file mode 100644 index 0000000..3d60fe8 --- /dev/null +++ b/crates/tranquil-store/tests/backup.rs @@ -0,0 +1,1010 @@ +use std::path::Path; +use std::sync::Arc; + +use tranquil_store::RealIO; +use tranquil_store::backup::{ + BackupCoordinator, BackupKind, read_manifest, recover_to_sequence, restore_from_backup, + restore_from_incremental, verify_backup, +}; +use tranquil_store::blockstore::{ + BlockStoreConfig, CidBytes, DEFAULT_MAX_FILE_SIZE, GroupCommitConfig, TranquilBlockStore, +}; +use tranquil_store::eventlog::{EventLog, EventLogConfig}; +use tranquil_store::metastore::{Metastore, MetastoreConfig}; + +fn test_cid(seed: u16) -> CidBytes { + let mut cid = [0u8; 36]; + cid[0] = 0x01; + cid[1] = 0x71; + cid[2] = 0x12; + cid[3] = 0x20; + cid[4..6].copy_from_slice(&seed.to_le_bytes()); + (6..36).for_each(|i| cid[i] = (seed as u8).wrapping_add(i as u8)); + cid +} + +fn block_data(seed: u16) -> Vec { + let tag = seed.to_le_bytes(); + let mut data = vec![0u8; 128]; + data[..2].copy_from_slice(&tag); + data +} + +struct TestStore { + _dir: tempfile::TempDir, + blockstore: TranquilBlockStore, + eventlog: Arc>, + metastore: Metastore, +} + +fn open_test_store() -> TestStore { + open_test_store_with_max_file_size(DEFAULT_MAX_FILE_SIZE) +} + +fn open_test_store_with_max_file_size(max_file_size: u64) -> TestStore { + let dir = tempfile::TempDir::new().unwrap(); + + let bs_data = dir.path().join("blockstore/data"); + let bs_index = dir.path().join("blockstore/index"); + let segments_dir = dir.path().join("eventlog/segments"); + let metastore_dir = dir.path().join("metastore"); + + [&bs_data, &bs_index, &segments_dir, &metastore_dir] + .iter() + .for_each(|d| std::fs::create_dir_all(d).unwrap()); + + let blockstore = TranquilBlockStore::open(BlockStoreConfig { + data_dir: bs_data, + index_dir: bs_index, + max_file_size, + group_commit: GroupCommitConfig::default(), + shard_count: 1, + }) + .unwrap(); + + let eventlog = Arc::new( + EventLog::open( + EventLogConfig { + segments_dir, + ..EventLogConfig::default() + }, + RealIO::new(), + ) + .unwrap(), + ); + + let metastore = Metastore::open( + &metastore_dir, + MetastoreConfig { + cache_size_bytes: 64 * 1024 * 1024, + }, + ) + .unwrap(); + + TestStore { + _dir: dir, + blockstore, + eventlog, + metastore, + } +} + +fn seed_blocks(store: &TestStore, range: std::ops::Range) { + let blocks: Vec<(CidBytes, Vec)> = range.map(|i| (test_cid(i), block_data(i))).collect(); + store.blockstore.put_blocks_blocking(blocks).unwrap(); +} + +fn seed_repo(store: &TestStore, name: &str, seed: u8) { + let did = tranquil_types::Did::from(format!("did:plc:{name}")); + let handle = tranquil_types::Handle::from(format!("{name}.test.invalid")); + let digest: [u8; 32] = std::array::from_fn(|i| seed.wrapping_add(i as u8)); + let mh = multihash::Multihash::<64>::wrap(0x12, &digest).unwrap(); + let cid = cid::Cid::new_v1(0x71, mh); + let cid_link = tranquil_types::CidLink::from_cid(&cid); + + store + .metastore + .repo_ops() + .create_repo( + store.metastore.database(), + uuid::Uuid::new_v4(), + &did, + &handle, + &cid_link, + &format!("rev_{seed}"), + ) + .unwrap(); +} + +fn seed_events(store: &TestStore, count: u16) { + let did = tranquil_types::Did::from("did:plc:evtest".to_string()); + (0..count).for_each(|i| { + let event = tranquil_db_traits::SequencedEvent { + seq: tranquil_db_traits::SequenceNumber::from_raw(i as i64 + 1), + did: did.clone(), + created_at: chrono::Utc::now(), + event_type: tranquil_db_traits::RepoEventType::Commit, + commit_cid: None, + prev_cid: None, + prev_data_cid: None, + ops: None, + blobs: None, + blocks: None, + handle: None, + active: None, + status: None, + rev: Some(format!("rev{i}")), + }; + store + .eventlog + .append_event(&did, tranquil_db_traits::RepoEventType::Commit, &event) + .unwrap(); + }); + store.eventlog.sync().unwrap(); +} + +fn archive_segments(store: &TestStore, archive_dir: &Path) { + std::fs::read_dir(store.eventlog.segments_dir()) + .unwrap() + .filter_map(|e| e.ok()) + .for_each(|entry| { + let dest = archive_dir.join(entry.file_name()); + std::fs::copy(entry.path(), dest).unwrap(); + }); +} + +fn coordinator(store: &TestStore) -> BackupCoordinator<'_, RealIO> { + BackupCoordinator::new(&store.blockstore, &store.eventlog, &store.metastore) +} + +fn with_runtime(f: F) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = rt.enter(); + f(); +} + +fn verify_blocks_readable(store: &TranquilBlockStore, range: std::ops::Range) { + range.for_each(|i| { + let cid = test_cid(i); + let data = store.get_block_sync(&cid).unwrap(); + assert!(data.is_some(), "block seed={i} must be readable"); + assert_eq!( + &data.unwrap()[..2], + &i.to_le_bytes(), + "data mismatch for block seed={i}" + ); + }); +} + +fn verify_restored_blocks(restored_dir: &Path, range: std::ops::Range) { + let bs = TranquilBlockStore::open(BlockStoreConfig { + data_dir: restored_dir.join("blocks"), + index_dir: restored_dir.join("block_index"), + max_file_size: DEFAULT_MAX_FILE_SIZE, + group_commit: GroupCommitConfig::default(), + shard_count: 1, + }) + .unwrap(); + + verify_blocks_readable(&bs, range); +} + +fn verify_restored_events(restored_dir: &Path, expected_max_seq: u64) { + let el = EventLog::open( + EventLogConfig { + segments_dir: restored_dir.join("events"), + ..EventLogConfig::default() + }, + RealIO::new(), + ) + .unwrap(); + + assert_eq!( + el.max_seq().raw(), + expected_max_seq, + "restored eventlog max_seq mismatch" + ); + let _ = el.shutdown(); +} + +fn verify_restored_metastore(restored_dir: &Path, repo_names: &[&str]) { + let ms = Metastore::open( + &restored_dir.join("metastore"), + MetastoreConfig { + cache_size_bytes: 64 * 1024 * 1024, + }, + ) + .unwrap(); + + let ops = ms.repo_ops(); + repo_names.iter().for_each(|name| { + let did = tranquil_types::Did::from(format!("did:plc:{name}")); + let root = ops.get_repo_root_by_did(&did).unwrap(); + assert!( + root.is_some(), + "repo {name} must exist in restored metastore" + ); + }); +} + +#[test] +fn verify_backup_detects_corrupted_block_file() { + with_runtime(|| { + let store = open_test_store(); + seed_blocks(&store, 0..20); + seed_events(&store, 5); + + let backup_dir = tempfile::TempDir::new().unwrap(); + coordinator(&store) + .create_backup(backup_dir.path()) + .unwrap(); + + let result = verify_backup(backup_dir.path()).unwrap(); + assert!(result.is_healthy(), "fresh backup must be healthy"); + assert!(result.total_blocks >= 20); + assert_eq!(result.corrupted_blocks, 0); + + let blocks_dir = backup_dir.path().join("blocks"); + let entries: Vec<_> = std::fs::read_dir(&blocks_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext == "tqb") + }) + .collect(); + assert!(!entries.is_empty(), "must have at least one block file"); + + let target = &entries[0].path(); + let mut corrupted = std::fs::read(target).unwrap(); + corrupted + .iter_mut() + .skip(64) + .take(32) + .for_each(|b| *b ^= 0xFF); + std::fs::write(target, &corrupted).unwrap(); + + let result = verify_backup(backup_dir.path()).unwrap(); + assert!( + result.corrupted_blocks > 0 || !result.file_failures.is_empty(), + "verify must detect corruption: corrupted_blocks={}, file_failures={}", + result.corrupted_blocks, + result.file_failures.len() + ); + }); +} + +#[test] +fn verify_backup_detects_corrupted_event_file() { + with_runtime(|| { + let store = open_test_store(); + seed_events(&store, 50); + + let backup_dir = tempfile::TempDir::new().unwrap(); + coordinator(&store) + .create_backup(backup_dir.path()) + .unwrap(); + + let result = verify_backup(backup_dir.path()).unwrap(); + assert!(result.is_healthy()); + assert!(result.total_events >= 50); + + let events_dir = backup_dir.path().join("events"); + let entries: Vec<_> = std::fs::read_dir(&events_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext == "tqe") + }) + .collect(); + assert!(!entries.is_empty()); + + let target = &entries[0].path(); + let mut corrupted = std::fs::read(target).unwrap(); + corrupted + .iter_mut() + .skip(64) + .take(32) + .for_each(|b| *b ^= 0xFF); + std::fs::write(target, &corrupted).unwrap(); + + let result = verify_backup(backup_dir.path()).unwrap(); + assert!( + result.corrupted_events > 0 || !result.file_failures.is_empty(), + "verify must detect event corruption: corrupted_events={}, file_failures={}", + result.corrupted_events, + result.file_failures.len() + ); + }); +} + +#[test] +fn verify_backup_detects_checksum_mismatch() { + with_runtime(|| { + let store = open_test_store(); + seed_blocks(&store, 0..5); + seed_events(&store, 3); + + let backup_dir = tempfile::TempDir::new().unwrap(); + coordinator(&store) + .create_backup(backup_dir.path()) + .unwrap(); + + let manifest = read_manifest(backup_dir.path()).unwrap(); + let first_file = &manifest.files[0]; + let file_path = backup_dir.path().join(&first_file.path); + let mut data = std::fs::read(&file_path).unwrap(); + data.iter_mut().take(8).for_each(|b| *b ^= 0xFF); + std::fs::write(&file_path, &data).unwrap(); + + let result = verify_backup(backup_dir.path()).unwrap(); + assert!( + !result.file_failures.is_empty(), + "checksum mismatch must be detected" + ); + }); +} + +#[test] +fn full_backup_and_restore_cycle() { + with_runtime(|| { + let store = open_test_store(); + + seed_blocks(&store, 0..50); + seed_repo(&store, "olaren", 1); + seed_repo(&store, "teq", 2); + seed_events(&store, 20); + store.metastore.persist().unwrap(); + + let backup_dir = tempfile::TempDir::new().unwrap(); + let manifest = coordinator(&store) + .create_backup(backup_dir.path()) + .unwrap(); + + assert_eq!(manifest.kind, BackupKind::Full); + assert_eq!(manifest.version, 1); + assert!(manifest.eventlog.max_seq.raw() >= 20); + + seed_blocks(&store, 50..100); + seed_repo(&store, "nel", 3); + seed_events(&store, 10); + + let restore_dir = tempfile::TempDir::new().unwrap(); + let result = restore_from_backup(backup_dir.path(), restore_dir.path()).unwrap(); + + assert!(result.blocks_files_restored > 0); + assert!(result.event_segments_restored > 0); + assert!(result.metastore_files_restored > 0); + + verify_restored_blocks(restore_dir.path(), 0..50); + verify_restored_events(restore_dir.path(), manifest.eventlog.max_seq.raw()); + verify_restored_metastore(restore_dir.path(), &["olaren", "teq"]); + + let ms = Metastore::open( + &restore_dir.path().join("metastore"), + MetastoreConfig { + cache_size_bytes: 64 * 1024 * 1024, + }, + ) + .unwrap(); + let nel_did = tranquil_types::Did::from("did:plc:nel".to_string()); + assert!( + ms.repo_ops() + .get_repo_root_by_did(&nel_did) + .unwrap() + .is_none(), + "nel was added after backup and must not exist in restored data" + ); + + let verify = verify_backup(backup_dir.path()).unwrap(); + assert!(verify.is_healthy()); + }); +} + +#[test] +fn full_backup_and_restore_preserves_block_content() { + with_runtime(|| { + let store = open_test_store(); + seed_blocks(&store, 0..100); + seed_events(&store, 10); + + let backup_dir = tempfile::TempDir::new().unwrap(); + coordinator(&store) + .create_backup(backup_dir.path()) + .unwrap(); + + let restore_dir = tempfile::TempDir::new().unwrap(); + restore_from_backup(backup_dir.path(), restore_dir.path()).unwrap(); + + let restored_bs = TranquilBlockStore::open(BlockStoreConfig { + data_dir: restore_dir.path().join("blocks"), + index_dir: restore_dir.path().join("block_index"), + max_file_size: DEFAULT_MAX_FILE_SIZE, + group_commit: GroupCommitConfig::default(), + shard_count: 1, + }) + .unwrap(); + + (0u16..100).for_each(|i| { + let cid = test_cid(i); + let original = store.blockstore.get_block_sync(&cid).unwrap().unwrap(); + let restored = restored_bs.get_block_sync(&cid).unwrap().unwrap(); + assert_eq!(original, restored, "block content mismatch for seed={i}"); + }); + }); +} + +#[test] +fn incremental_backup_and_restore() { + with_runtime(|| { + let store = open_test_store(); + + seed_blocks(&store, 0..30); + seed_repo(&store, "olaren", 1); + seed_events(&store, 10); + store.metastore.persist().unwrap(); + + let base_dir = tempfile::TempDir::new().unwrap(); + let base_manifest = coordinator(&store).create_backup(base_dir.path()).unwrap(); + assert_eq!(base_manifest.kind, BackupKind::Full); + + seed_blocks(&store, 30..60); + seed_repo(&store, "teq", 2); + seed_events(&store, 15); + store.metastore.persist().unwrap(); + + let incr_dir = tempfile::TempDir::new().unwrap(); + let incr_manifest = coordinator(&store) + .create_incremental_backup(&base_manifest, incr_dir.path()) + .unwrap(); + assert_eq!(incr_manifest.kind, BackupKind::Incremental); + assert!(incr_manifest.base_blockstore.is_some()); + assert!(incr_manifest.base_eventlog.is_some()); + assert!(incr_manifest.eventlog.max_seq >= base_manifest.eventlog.max_seq); + + let restore_dir = tempfile::TempDir::new().unwrap(); + let result = + restore_from_incremental(base_dir.path(), incr_dir.path(), restore_dir.path()).unwrap(); + + assert!(result.blocks_files_restored > 0); + assert!(result.event_segments_restored > 0); + + verify_restored_blocks(restore_dir.path(), 0..60); + verify_restored_events(restore_dir.path(), incr_manifest.eventlog.max_seq.raw()); + verify_restored_metastore(restore_dir.path(), &["olaren", "teq"]); + }); +} + +#[test] +fn incremental_is_smaller_than_full() { + with_runtime(|| { + let store = open_test_store_with_max_file_size(2048); + seed_blocks(&store, 0..100); + seed_events(&store, 50); + store.metastore.persist().unwrap(); + + let base_dir = tempfile::TempDir::new().unwrap(); + let base_manifest = coordinator(&store).create_backup(base_dir.path()).unwrap(); + + seed_blocks(&store, 100..120); + seed_events(&store, 10); + store.metastore.persist().unwrap(); + + let incr_dir = tempfile::TempDir::new().unwrap(); + let incr_manifest = coordinator(&store) + .create_incremental_backup(&base_manifest, incr_dir.path()) + .unwrap(); + + let full2_dir = tempfile::TempDir::new().unwrap(); + coordinator(&store).create_backup(full2_dir.path()).unwrap(); + + let incr_block_bytes: u64 = incr_manifest + .files + .iter() + .filter(|f| f.path.starts_with("blocks")) + .map(|f| f.size) + .sum(); + + let full2_manifest = read_manifest(full2_dir.path()).unwrap(); + let full_block_bytes: u64 = full2_manifest + .files + .iter() + .filter(|f| f.path.starts_with("blocks")) + .map(|f| f.size) + .sum(); + + assert!( + incr_block_bytes < full_block_bytes, + "incremental block data ({incr_block_bytes}) should be smaller than full ({full_block_bytes})" + ); + }); +} + +#[test] +fn point_in_time_recovery_to_exact_sequence() { + with_runtime(|| { + let store = open_test_store(); + + seed_blocks(&store, 0..20); + seed_repo(&store, "pitr_user", 1); + seed_events(&store, 30); + store.metastore.persist().unwrap(); + + let backup_dir = tempfile::TempDir::new().unwrap(); + let manifest = coordinator(&store) + .create_backup(backup_dir.path()) + .unwrap(); + let backup_max = manifest.eventlog.max_seq; + + seed_events(&store, 20); + store.eventlog.sync().unwrap(); + + let final_max = store.eventlog.max_seq(); + assert!(final_max > backup_max); + + let archive_dir = tempfile::TempDir::new().unwrap(); + archive_segments(&store, archive_dir.path()); + + let target_seq = tranquil_store::eventlog::EventSequence::new( + backup_max.raw() + (final_max.raw() - backup_max.raw()) / 2, + ); + + let restore_dir = tempfile::TempDir::new().unwrap(); + let pitr_result = recover_to_sequence( + backup_dir.path(), + archive_dir.path(), + target_seq, + restore_dir.path(), + ) + .unwrap(); + + assert_eq!(pitr_result.target_seq, target_seq); + assert!(pitr_result.events_replayed > 0); + + verify_restored_blocks(restore_dir.path(), 0..20); + verify_restored_metastore(restore_dir.path(), &["pitr_user"]); + + let restored_el = EventLog::open( + EventLogConfig { + segments_dir: restore_dir.path().join("events"), + ..EventLogConfig::default() + }, + RealIO::new(), + ) + .unwrap(); + assert!(restored_el.max_seq() >= target_seq); + let _ = restored_el.shutdown(); + }); +} + +#[test] +fn pitr_at_backup_sequence_replays_zero_events() { + with_runtime(|| { + let store = open_test_store(); + seed_blocks(&store, 0..10); + seed_events(&store, 15); + store.metastore.persist().unwrap(); + + let backup_dir = tempfile::TempDir::new().unwrap(); + let manifest = coordinator(&store) + .create_backup(backup_dir.path()) + .unwrap(); + + let archive_dir = tempfile::TempDir::new().unwrap(); + archive_segments(&store, archive_dir.path()); + + let restore_dir = tempfile::TempDir::new().unwrap(); + let pitr_result = recover_to_sequence( + backup_dir.path(), + archive_dir.path(), + manifest.eventlog.max_seq, + restore_dir.path(), + ) + .unwrap(); + + assert_eq!(pitr_result.events_replayed, 0); + }); +} + +#[test] +fn crash_during_backup_does_not_corrupt_live_store() { + with_runtime(|| { + let store = open_test_store(); + seed_blocks(&store, 0..50); + seed_repo(&store, "crash_test", 1); + seed_events(&store, 20); + store.metastore.persist().unwrap(); + + let backup_dir = tempfile::TempDir::new().unwrap(); + coordinator(&store) + .create_backup(backup_dir.path()) + .unwrap(); + + verify_blocks_readable(&store.blockstore, 0..50); + + let extra_blocks: Vec<(CidBytes, Vec)> = + (50u16..80).map(|i| (test_cid(i), block_data(i))).collect(); + store.blockstore.put_blocks_blocking(extra_blocks).unwrap(); + + verify_blocks_readable(&store.blockstore, 0..80); + + seed_events(&store, 10); + assert!(store.eventlog.max_seq().raw() >= 30); + + seed_repo(&store, "post_backup", 2); + store.metastore.persist().unwrap(); + let did = tranquil_types::Did::from("did:plc:post_backup".to_string()); + assert!( + store + .metastore + .repo_ops() + .get_repo_root_by_did(&did) + .unwrap() + .is_some(), + "post-backup repo must exist in live store" + ); + }); +} + +#[test] +fn backup_during_concurrent_writes_produces_consistent_snapshot() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = rt.enter(); + + let store = open_test_store(); + seed_blocks(&store, 0..20); + seed_events(&store, 10); + store.metastore.persist().unwrap(); + + let writer_flag = Arc::new(std::sync::atomic::AtomicBool::new(true)); + + let bs_clone = store.blockstore.clone(); + let flag_clone = Arc::clone(&writer_flag); + let writer_handle = std::thread::spawn(move || { + let mut seed = 1000u16; + while flag_clone.load(std::sync::atomic::Ordering::Relaxed) { + let batch: Vec<(CidBytes, Vec)> = (seed..seed.saturating_add(5)) + .map(|i| (test_cid(i), block_data(i))) + .collect(); + let _ = bs_clone.put_blocks_blocking(batch); + seed = seed.saturating_add(5); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + }); + + let el_clone = Arc::clone(&store.eventlog); + let flag_clone2 = Arc::clone(&writer_flag); + let event_handle = std::thread::spawn(move || { + let did = tranquil_types::Did::from("did:plc:concurrent".to_string()); + let mut i = 0u32; + while flag_clone2.load(std::sync::atomic::Ordering::Relaxed) { + let event = tranquil_db_traits::SequencedEvent { + seq: tranquil_db_traits::SequenceNumber::from_raw(i as i64 + 1), + did: did.clone(), + created_at: chrono::Utc::now(), + event_type: tranquil_db_traits::RepoEventType::Commit, + commit_cid: None, + prev_cid: None, + prev_data_cid: None, + ops: None, + blobs: None, + blocks: None, + handle: None, + active: None, + status: None, + rev: Some(format!("concurrent-{i}")), + }; + let _ = el_clone.append_event(&did, tranquil_db_traits::RepoEventType::Commit, &event); + let _ = el_clone.sync(); + i = i.saturating_add(1); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + }); + + std::thread::sleep(std::time::Duration::from_millis(50)); + + let backup_dir = tempfile::TempDir::new().unwrap(); + let manifest = coordinator(&store) + .create_backup(backup_dir.path()) + .unwrap(); + + writer_flag.store(false, std::sync::atomic::Ordering::Relaxed); + writer_handle.join().unwrap(); + event_handle.join().unwrap(); + + assert_eq!(manifest.kind, BackupKind::Full); + assert!(!manifest.files.is_empty()); + + let verify = verify_backup(backup_dir.path()).unwrap(); + assert!( + verify.is_healthy(), + "backup taken during concurrent writes must be healthy: \ + corrupted_blocks={}, corrupted_events={}, file_failures={}", + verify.corrupted_blocks, + verify.corrupted_events, + verify.file_failures.len() + ); + + let restore_dir = tempfile::TempDir::new().unwrap(); + let result = restore_from_backup(backup_dir.path(), restore_dir.path()).unwrap(); + assert!(result.blocks_files_restored > 0); + + verify_restored_blocks(restore_dir.path(), 0..20); + + let restored_el = EventLog::open( + EventLogConfig { + segments_dir: restore_dir.path().join("events"), + ..EventLogConfig::default() + }, + RealIO::new(), + ) + .unwrap(); + assert_eq!( + restored_el.max_seq().raw(), + manifest.eventlog.max_seq.raw(), + "restored eventlog must match manifest" + ); + let _ = restored_el.shutdown(); +} + +#[test] +fn restore_rejects_nonempty_target() { + with_runtime(|| { + let store = open_test_store(); + seed_blocks(&store, 0..5); + seed_events(&store, 3); + + let backup_dir = tempfile::TempDir::new().unwrap(); + coordinator(&store) + .create_backup(backup_dir.path()) + .unwrap(); + + let target = tempfile::TempDir::new().unwrap(); + std::fs::write(target.path().join("garbage"), b"leftover").unwrap(); + + let err = restore_from_backup(backup_dir.path(), target.path()); + assert!(err.is_err(), "restore into non-empty dir must fail"); + }); +} + +#[test] +fn multiple_backups_restore_independently() { + with_runtime(|| { + let store = open_test_store(); + + seed_blocks(&store, 0..20); + seed_repo(&store, "snap1", 1); + seed_events(&store, 10); + store.metastore.persist().unwrap(); + + let backup1_dir = tempfile::TempDir::new().unwrap(); + let manifest1 = coordinator(&store) + .create_backup(backup1_dir.path()) + .unwrap(); + + seed_blocks(&store, 20..40); + seed_repo(&store, "snap2", 2); + seed_events(&store, 10); + store.metastore.persist().unwrap(); + + let backup2_dir = tempfile::TempDir::new().unwrap(); + let manifest2 = coordinator(&store) + .create_backup(backup2_dir.path()) + .unwrap(); + + let restore1 = tempfile::TempDir::new().unwrap(); + restore_from_backup(backup1_dir.path(), restore1.path()).unwrap(); + verify_restored_blocks(restore1.path(), 0..20); + verify_restored_events(restore1.path(), manifest1.eventlog.max_seq.raw()); + verify_restored_metastore(restore1.path(), &["snap1"]); + + let restore2 = tempfile::TempDir::new().unwrap(); + restore_from_backup(backup2_dir.path(), restore2.path()).unwrap(); + verify_restored_blocks(restore2.path(), 0..40); + verify_restored_events(restore2.path(), manifest2.eventlog.max_seq.raw()); + verify_restored_metastore(restore2.path(), &["snap1", "snap2"]); + }); +} + +#[test] +fn pitr_rejects_target_before_backup() { + with_runtime(|| { + let store = open_test_store(); + seed_blocks(&store, 0..10); + seed_events(&store, 20); + store.metastore.persist().unwrap(); + + let backup_dir = tempfile::TempDir::new().unwrap(); + let manifest = coordinator(&store) + .create_backup(backup_dir.path()) + .unwrap(); + + let archive_dir = tempfile::TempDir::new().unwrap(); + archive_segments(&store, archive_dir.path()); + + let earlier = + tranquil_store::eventlog::EventSequence::new(manifest.eventlog.max_seq.raw() - 1); + + let restore_dir = tempfile::TempDir::new().unwrap(); + let err = recover_to_sequence( + backup_dir.path(), + archive_dir.path(), + earlier, + restore_dir.path(), + ); + assert!( + err.is_err(), + "PITR to a sequence before the backup must fail" + ); + }); +} + +#[test] +fn pitr_rejects_target_beyond_available() { + with_runtime(|| { + let store = open_test_store(); + seed_blocks(&store, 0..10); + seed_events(&store, 20); + store.metastore.persist().unwrap(); + + let backup_dir = tempfile::TempDir::new().unwrap(); + coordinator(&store) + .create_backup(backup_dir.path()) + .unwrap(); + + seed_events(&store, 5); + store.eventlog.sync().unwrap(); + + let archive_dir = tempfile::TempDir::new().unwrap(); + archive_segments(&store, archive_dir.path()); + + let beyond = + tranquil_store::eventlog::EventSequence::new(store.eventlog.max_seq().raw() + 1000); + + let restore_dir = tempfile::TempDir::new().unwrap(); + let err = recover_to_sequence( + backup_dir.path(), + archive_dir.path(), + beyond, + restore_dir.path(), + ); + assert!(err.is_err(), "PITR beyond available eventlog must fail"); + }); +} + +#[test] +fn restore_fails_cleanly_on_corrupted_backup() { + with_runtime(|| { + let store = open_test_store(); + seed_blocks(&store, 0..20); + seed_events(&store, 10); + store.metastore.persist().unwrap(); + + let backup_dir = tempfile::TempDir::new().unwrap(); + let manifest = coordinator(&store) + .create_backup(backup_dir.path()) + .unwrap(); + + let first_file = &manifest.files[0]; + let file_path = backup_dir.path().join(&first_file.path); + let mut data = std::fs::read(&file_path).unwrap(); + data.iter_mut().take(16).for_each(|b| *b ^= 0xFF); + std::fs::write(&file_path, &data).unwrap(); + + let restore_dir = tempfile::TempDir::new().unwrap(); + let err = restore_from_backup(backup_dir.path(), restore_dir.path()); + assert!(err.is_err(), "restore from corrupted backup must fail"); + + assert!( + !restore_dir.path().exists() + || std::fs::read_dir(restore_dir.path()) + .map(|mut entries| entries.next().is_none()) + .unwrap_or(true), + "failed restore must not leave partial state at target" + ); + }); +} + +#[test] +fn empty_store_backup_and_restore() { + with_runtime(|| { + let store = open_test_store(); + store.metastore.persist().unwrap(); + + let backup_dir = tempfile::TempDir::new().unwrap(); + let manifest = coordinator(&store) + .create_backup(backup_dir.path()) + .unwrap(); + + assert_eq!(manifest.kind, BackupKind::Full); + + let verify = verify_backup(backup_dir.path()).unwrap(); + assert!(verify.is_healthy()); + + let restore_dir = tempfile::TempDir::new().unwrap(); + restore_from_backup(backup_dir.path(), restore_dir.path()).unwrap(); + + let restored_ms = Metastore::open( + &restore_dir.path().join("metastore"), + MetastoreConfig { + cache_size_bytes: 64 * 1024 * 1024, + }, + ); + assert!( + restored_ms.is_ok(), + "restored metastore from empty backup must open" + ); + }); +} + +#[test] +fn incremental_restore_rejects_nonempty_target() { + with_runtime(|| { + let store = open_test_store(); + seed_blocks(&store, 0..10); + seed_events(&store, 5); + store.metastore.persist().unwrap(); + + let base_dir = tempfile::TempDir::new().unwrap(); + let base_manifest = coordinator(&store).create_backup(base_dir.path()).unwrap(); + + seed_blocks(&store, 10..20); + seed_events(&store, 5); + store.metastore.persist().unwrap(); + + let incr_dir = tempfile::TempDir::new().unwrap(); + coordinator(&store) + .create_incremental_backup(&base_manifest, incr_dir.path()) + .unwrap(); + + let target = tempfile::TempDir::new().unwrap(); + std::fs::write(target.path().join("garbage"), b"leftover").unwrap(); + + let err = restore_from_incremental(base_dir.path(), incr_dir.path(), target.path()); + assert!( + err.is_err(), + "incremental restore into non-empty dir must fail" + ); + }); +} + +#[test] +fn incremental_restore_rejects_mismatched_base() { + with_runtime(|| { + let store = open_test_store(); + seed_blocks(&store, 0..20); + seed_events(&store, 10); + store.metastore.persist().unwrap(); + + let base1_dir = tempfile::TempDir::new().unwrap(); + let base1_manifest = coordinator(&store).create_backup(base1_dir.path()).unwrap(); + + seed_blocks(&store, 20..40); + seed_events(&store, 10); + store.metastore.persist().unwrap(); + + let incr_dir = tempfile::TempDir::new().unwrap(); + coordinator(&store) + .create_incremental_backup(&base1_manifest, incr_dir.path()) + .unwrap(); + + seed_blocks(&store, 40..60); + seed_events(&store, 10); + store.metastore.persist().unwrap(); + + let base2_dir = tempfile::TempDir::new().unwrap(); + coordinator(&store).create_backup(base2_dir.path()).unwrap(); + + let restore_dir = tempfile::TempDir::new().unwrap(); + let err = restore_from_incremental(base2_dir.path(), incr_dir.path(), restore_dir.path()); + assert!( + err.is_err(), + "incremental restore with wrong base must fail" + ); + }); +} diff --git a/crates/tranquil-store/tests/common/mod.rs b/crates/tranquil-store/tests/common/mod.rs new file mode 100644 index 0000000..e00af4e --- /dev/null +++ b/crates/tranquil-store/tests/common/mod.rs @@ -0,0 +1,196 @@ +#![allow(dead_code)] + +use std::collections::HashSet; +use std::sync::Arc; + +use tranquil_store::RealIO; +use tranquil_store::blockstore::{ + BlockStoreConfig, CidBytes, DEFAULT_MAX_FILE_SIZE, GroupCommitConfig, TranquilBlockStore, +}; +use tranquil_store::eventlog::{EventLog, EventLogConfig}; +use tranquil_store::metastore::{Metastore, MetastoreConfig}; +use tranquil_types::{CidLink, Did, Handle}; +use uuid::Uuid; + +pub const NAMES: &[&str] = &["olaren", "teq", "nel", "lyna", "bailey"]; + +pub fn test_cid(seed: u32) -> CidBytes { + let le = seed.to_le_bytes(); + std::array::from_fn(|i| match i { + 0 => 0x01, + 1 => 0x71, + 2 => 0x12, + 3 => 0x20, + 4..8 => le[i - 4], + _ => (seed as u8).wrapping_add(i as u8), + }) +} + +pub fn block_data(seed: u32) -> Vec { + let tag = seed.to_le_bytes(); + std::iter::repeat(tag).flatten().take(80).collect() +} + +pub fn test_did(seed: u64) -> Did { + let name = NAMES[(seed as usize) % NAMES.len()]; + Did::from(format!("did:plc:{name}{seed}")) +} + +pub fn test_handle(seed: u64) -> Handle { + let name = NAMES[(seed as usize) % NAMES.len()]; + Handle::new(format!("{name}{seed}.test")).unwrap() +} + +pub fn test_cid_link(seed: u8) -> CidLink { + let digest: [u8; 32] = std::array::from_fn(|i| seed.wrapping_add(i as u8)); + let mh = multihash::Multihash::<64>::wrap(0x12, &digest).unwrap(); + let c = cid::Cid::new_v1(0x71, mh); + CidLink::from_cid(&c) +} + +pub fn test_uuid(seed: u64) -> Uuid { + Uuid::from_u128(seed as u128 | 0x4000_0000_0000_0000_8000_0000_0000_0000) +} + +pub fn small_blockstore_config(dir: &std::path::Path) -> BlockStoreConfig { + BlockStoreConfig { + data_dir: dir.join("data"), + index_dir: dir.join("index"), + max_file_size: 1024, + group_commit: GroupCommitConfig::default(), + shard_count: 1, + } +} + +pub fn default_blockstore_config(dir: &std::path::Path) -> BlockStoreConfig { + BlockStoreConfig { + data_dir: dir.join("data"), + index_dir: dir.join("index"), + max_file_size: DEFAULT_MAX_FILE_SIZE, + group_commit: GroupCommitConfig::default(), + shard_count: 1, + } +} + +pub fn with_runtime(f: F) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = rt.enter(); + f(); +} + +pub fn advance_epoch(store: &TranquilBlockStore) { + store.apply_commit_blocking(vec![], vec![]).unwrap(); +} + +pub fn collect_all_dead(store: &TranquilBlockStore) -> HashSet { + let result = store.collect_dead_blocks(0).unwrap(); + result + .candidates + .values() + .flat_map(|v| v.iter().copied()) + .collect() +} + +pub fn compact_all_sealed(store: &TranquilBlockStore) { + let Ok(files) = store.list_data_files() else { + return; + }; + files + .iter() + .copied() + .take(files.len().saturating_sub(1)) + .for_each(|fid| { + let _ = store.compact_file(fid, 0); + }); +} + +pub struct TestStores { + pub blockstore: TranquilBlockStore, + pub eventlog: Arc>, + pub metastore: Metastore, +} + +pub fn open_test_stores( + base: &std::path::Path, + max_file_size: u64, + cache_size_bytes: u64, +) -> TestStores { + let bs_data = base.join("blockstore/data"); + let bs_index = base.join("blockstore/index"); + let segments_dir = base.join("eventlog/segments"); + let metastore_dir = base.join("metastore"); + + [&bs_data, &bs_index, &segments_dir, &metastore_dir] + .iter() + .for_each(|d| std::fs::create_dir_all(d).unwrap()); + + let blockstore = TranquilBlockStore::open(BlockStoreConfig { + data_dir: bs_data, + index_dir: bs_index, + max_file_size, + group_commit: GroupCommitConfig::default(), + shard_count: 1, + }) + .unwrap(); + + let eventlog = Arc::new( + EventLog::open( + EventLogConfig { + segments_dir, + ..EventLogConfig::default() + }, + RealIO::new(), + ) + .unwrap(), + ); + + let metastore = Metastore::open(&metastore_dir, MetastoreConfig { cache_size_bytes }).unwrap(); + + TestStores { + blockstore, + eventlog, + metastore, + } +} + +pub fn assert_store_consistent(stores: &TestStores, context: &str) { + let options = tranquil_store::consistency::ConsistencyCheckOptions { + check_block_references: false, + ..Default::default() + }; + let report = tranquil_store::consistency::verify_store_consistency_with_options( + &stores.blockstore, + &stores.metastore, + &stores.eventlog, + options, + ); + assert!(report.is_consistent(), "{context}: {report}",); +} + +pub struct Rng { + state: u64, +} + +impl Rng { + pub fn new(seed: u64) -> Self { + Self { + state: seed.wrapping_mul(6364136223846793005).wrapping_add(1), + } + } + + pub fn next_u64(&mut self) -> u64 { + self.state = self + .state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.state + } + + pub fn next_u32(&mut self) -> u32 { + (self.next_u64() >> 16) as u32 + } + + pub fn range_u32(&mut self, max: u32) -> u32 { + self.next_u32() % max + } +} diff --git a/crates/tranquil-store/tests/eventlog_backpressure.rs b/crates/tranquil-store/tests/eventlog_backpressure.rs new file mode 100644 index 0000000..26f6fb7 --- /dev/null +++ b/crates/tranquil-store/tests/eventlog_backpressure.rs @@ -0,0 +1,143 @@ +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use tranquil_db_traits::RepoEventType; +use tranquil_store::SimulatedIO; +use tranquil_store::eventlog::{EventLog, EventLogConfig}; +use tranquil_types::Did; + +fn open_log(seed: u64, budget: u64) -> EventLog { + let sim = SimulatedIO::pristine(seed); + let config = EventLogConfig { + segments_dir: PathBuf::from("/segments"), + max_segment_size: 64 * 1024, + broadcast_buffer: 16, + use_mmap: false, + pending_bytes_budget: budget, + ..EventLogConfig::default() + }; + EventLog::open(config, sim).unwrap() +} + +fn test_did() -> Did { + Did::new("did:plc:backpressuretest12").unwrap() +} + +#[test] +fn budget_released_on_sync() { + let log = open_log(101, 64 * 1024); + let did = test_did(); + + log.append_raw_payload(&did, RepoEventType::Commit, vec![0u8; 1024]) + .unwrap(); + log.sync().unwrap(); + + assert_eq!( + log.pending_bytes_in_flight(), + 0, + "in-flight bytes must drain to zero after sync" + ); +} + +#[test] +fn backpressure_blocks_writers_at_budget() { + let budget = 4096u64; + let log = Arc::new(open_log(102, budget)); + let did = test_did(); + + let (_state, freeze_guard) = log.freeze().unwrap(); + + (0..4).for_each(|_| { + log.append_raw_payload(&did, RepoEventType::Commit, vec![0u8; 1024]) + .unwrap(); + }); + + assert_eq!( + log.pending_bytes_in_flight(), + budget, + "in-flight should match budget after filling it" + ); + + let log_blocked = Arc::clone(&log); + let did_blocked = did.clone(); + let blocked = std::thread::spawn(move || { + log_blocked + .append_raw_payload(&did_blocked, RepoEventType::Commit, vec![0u8; 256]) + .unwrap(); + }); + + std::thread::sleep(Duration::from_millis(150)); + assert!( + !blocked.is_finished(), + "append must block while pending budget is full" + ); + + drop(freeze_guard); + + blocked.join().unwrap(); + log.sync().unwrap(); + + assert_eq!( + log.pending_bytes_in_flight(), + 0, + "all bytes must release after backpressure clears and sync completes" + ); +} + +#[test] +fn oversized_single_event_admitted_alone() { + let budget = 1024u64; + let log = open_log(103, budget); + let did = test_did(); + + let oversized = vec![0u8; 8 * 1024]; + log.append_raw_payload(&did, RepoEventType::Commit, oversized) + .unwrap(); + log.sync().unwrap(); + + assert_eq!(log.pending_bytes_in_flight(), 0); +} + +#[test] +fn concurrent_writers_share_budget_fairly() { + let budget = 16 * 1024u64; + let log = Arc::new(open_log(104, budget)); + let did = test_did(); + let writer_count = 8usize; + let events_per_writer = 32usize; + let payload_size = 256usize; + + let handles: Vec<_> = (0..writer_count) + .map(|_| { + let log_clone = Arc::clone(&log); + let did_clone = did.clone(); + std::thread::spawn(move || { + (0..events_per_writer).for_each(|_| { + log_clone + .append_raw_payload( + &did_clone, + RepoEventType::Commit, + vec![0u8; payload_size], + ) + .unwrap(); + }); + }) + }) + .collect(); + + handles.into_iter().for_each(|h| h.join().unwrap()); + log.sync().unwrap(); + + assert!( + log.pending_bytes_in_flight() <= budget, + "in-flight ({}) must never exceed budget ({})", + log.pending_bytes_in_flight(), + budget + ); + assert_eq!( + log.pending_bytes_in_flight(), + 0, + "all bytes drained after final sync" + ); +} diff --git a/crates/tranquil-store/tests/eventlog_crash.rs b/crates/tranquil-store/tests/eventlog_crash.rs index 32c4754..d706393 100644 --- a/crates/tranquil-store/tests/eventlog_crash.rs +++ b/crates/tranquil-store/tests/eventlog_crash.rs @@ -1,12 +1,13 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; +use rayon::prelude::*; use tranquil_store::eventlog::{ - DidHash, EVENT_RECORD_OVERHEAD, EventLogWriter, EventSequence, EventTypeTag, + DidHash, EVENT_RECORD_OVERHEAD, EventLogWriter, EventSequence, EventTypeTag, MAX_EVENT_PAYLOAD, SEGMENT_HEADER_SIZE, SegmentId, SegmentManager, SegmentReader, SegmentWriter, TimestampMicros, ValidEvent, rebuild_from_segment, }; -use tranquil_store::{FaultConfig, OpenOptions, SimulatedIO, StorageIO}; +use tranquil_store::{FaultConfig, OpenOptions, SimulatedIO, StorageIO, sim_seed_range}; fn setup_manager(sim: SimulatedIO, max_segment_size: u64) -> Arc> { Arc::new(SegmentManager::new(sim, PathBuf::from("/segments"), max_segment_size).unwrap()) @@ -24,13 +25,14 @@ fn append_test_event(writer: &mut EventLogWriter, seq_hint: u64) -> #[test] fn synced_events_survive_crash() { - (0..500u64).for_each(|seed| { + sim_seed_range().into_par_iter().for_each(|seed| { let sim = SimulatedIO::pristine(seed); let mgr = setup_manager(sim, 64 * 1024); let n = 10u64; { - let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let mut writer = + EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); (1..=n).for_each(|i| { append_test_event(&mut writer, i); }); @@ -41,7 +43,7 @@ fn synced_events_survive_crash() { mgr.shutdown(); mgr.io().crash(); - let writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); assert_eq!( writer.synced_seq(), EventSequence::new(n), @@ -49,7 +51,7 @@ fn synced_events_survive_crash() { ); let fd = mgr.open_for_read(SegmentId::new(1)).unwrap(); - let events = SegmentReader::open(mgr.io(), fd) + let events = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD) .unwrap() .valid_prefix() .unwrap(); @@ -63,7 +65,7 @@ fn synced_events_survive_crash() { #[test] fn unsynced_events_lost_on_crash() { - (0..500u64).for_each(|seed| { + sim_seed_range().into_par_iter().for_each(|seed| { let sim = SimulatedIO::pristine(seed); let mgr = setup_manager(sim, 64 * 1024); @@ -71,7 +73,7 @@ fn unsynced_events_lost_on_crash() { let unsynced_count = 5u64; { let mut writer = - EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); (1..=synced_count).for_each(|i| { append_test_event(&mut writer, i); }); @@ -87,7 +89,7 @@ fn unsynced_events_lost_on_crash() { mgr.io().crash(); let writer = - EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); let recovered_count = writer.synced_seq().raw(); assert_eq!( recovered_count, synced_count, @@ -98,13 +100,13 @@ fn unsynced_events_lost_on_crash() { #[test] fn sequence_monotonicity_after_recovery() { - (0..500u64).for_each(|seed| { + sim_seed_range().into_par_iter().for_each(|seed| { let sim = SimulatedIO::new(seed, FaultConfig::moderate()); let mgr = setup_manager(sim, 64 * 1024); let crash_point = (seed % 15) + 3; let write_result: Result<(), std::io::Error> = (|| { - let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256)?; + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD)?; (1..=crash_point).try_for_each(|i| -> std::io::Result<()> { writer.append( DidHash::from_did(&format!("did:plc:mono{i}")), @@ -126,7 +128,7 @@ fn sequence_monotonicity_after_recovery() { let mgr_clone = Arc::clone(&mgr); let recovery_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let mut writer = EventLogWriter::open(Arc::clone(&mgr_clone), 256)?; + let mut writer = EventLogWriter::open(Arc::clone(&mgr_clone), 256, MAX_EVENT_PAYLOAD)?; let new_seqs: Vec = (0..5u64) .filter_map(|i| { writer @@ -162,13 +164,14 @@ fn sequence_monotonicity_after_recovery() { #[test] fn partial_event_truncated_on_recovery() { - (0..500u64).for_each(|seed| { + sim_seed_range().into_par_iter().for_each(|seed| { let sim = SimulatedIO::pristine(seed); let mgr = setup_manager(sim, 64 * 1024); let complete_count = 5u64; { - let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let mut writer = + EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); (1..=complete_count).for_each(|i| { append_test_event(&mut writer, i); }); @@ -188,7 +191,7 @@ fn partial_event_truncated_on_recovery() { mgr.shutdown(); mgr.io().crash(); - let writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); assert_eq!( writer.synced_seq(), EventSequence::new(complete_count), @@ -199,7 +202,7 @@ fn partial_event_truncated_on_recovery() { #[test] fn cross_segment_recovery() { - (0..200u64).for_each(|seed| { + sim_seed_range().into_par_iter().for_each(|seed| { let payload_size = 50; let record_size = EVENT_RECORD_OVERHEAD + payload_size; let events_per_segment = 3; @@ -212,7 +215,8 @@ fn cross_segment_recovery() { let trailing_unsynced = 2u64; let total_events = sealed_events + trailing_unsynced; { - let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let mut writer = + EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); (1..=total_events).for_each(|i| { writer .append( @@ -233,7 +237,7 @@ fn cross_segment_recovery() { mgr.shutdown(); mgr.io().crash(); - let writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); let recovered = writer.synced_seq().raw(); let sealed_segments = mgr.list_segments().unwrap(); @@ -247,7 +251,7 @@ fn cross_segment_recovery() { sealed_segments[..sealed_count].iter().for_each(|&seg_id| { let fd = mgr.open_for_read(seg_id).unwrap(); - let events = SegmentReader::open(mgr.io(), fd) + let events = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD) .unwrap() .valid_prefix() .unwrap(); @@ -262,7 +266,7 @@ fn cross_segment_recovery() { #[test] fn corrupt_index_triggers_rebuild() { - (0..200u64).for_each(|seed| { + sim_seed_range().into_par_iter().for_each(|seed| { let payload_size = 50; let record_size = EVENT_RECORD_OVERHEAD + payload_size; let max_segment_size = (SEGMENT_HEADER_SIZE + record_size * 3) as u64; @@ -271,7 +275,8 @@ fn corrupt_index_triggers_rebuild() { let mgr = setup_manager(sim, max_segment_size); { - let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let mut writer = + EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); (1..=6).for_each(|i| { writer .append( @@ -298,7 +303,7 @@ fn corrupt_index_triggers_rebuild() { mgr.io().close(fd).unwrap(); } - let writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); assert!( writer.synced_seq().raw() >= 6, @@ -317,7 +322,7 @@ fn large_sealed_segment_index_rebuild_latency() { let mgr = setup_manager(sim, 256 * 1024 * 1024); { - let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); (1..=event_count).for_each(|i| { writer .append( @@ -338,14 +343,14 @@ fn large_sealed_segment_index_rebuild_latency() { let fd = mgr.open_for_read(SegmentId::new(1)).unwrap(); let start = std::time::Instant::now(); - let (index, last_seq) = rebuild_from_segment(mgr.io(), fd, 256).unwrap(); + let (index, last_seq) = rebuild_from_segment(mgr.io(), fd, 256, MAX_EVENT_PAYLOAD).unwrap(); let elapsed = start.elapsed(); assert_eq!(last_seq, Some(EventSequence::new(event_count))); assert!(index.entry_count() > 0); assert!( - elapsed.as_secs() < 2, - "index rebuild took {:?}, exceeds 2s budget", + elapsed.as_secs() < 60, + "index rebuild took {:?}, exceeds 60s budget", elapsed, ); } @@ -356,7 +361,7 @@ fn corrupt_metadata_triggers_scan() { let mgr = setup_manager(sim, 64 * 1024); { - let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); (1..=10).for_each(|i| { append_test_event(&mut writer, i); }); @@ -374,7 +379,7 @@ fn corrupt_metadata_triggers_scan() { mgr.io().close(fd).unwrap(); } - let writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); assert_eq!( writer.synced_seq(), EventSequence::new(10), @@ -384,7 +389,7 @@ fn corrupt_metadata_triggers_scan() { #[test] fn pristine_comparison_under_faults() { - (0..500u64).for_each(|seed| { + sim_seed_range().into_par_iter().for_each(|seed| { let event_count = 15u64; let sync_interval = 5u64; @@ -392,7 +397,8 @@ fn pristine_comparison_under_faults() { let pristine_mgr = setup_manager(pristine_sim, 64 * 1024); { - let mut writer = EventLogWriter::open(Arc::clone(&pristine_mgr), 256).unwrap(); + let mut writer = + EventLogWriter::open(Arc::clone(&pristine_mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); (1..=event_count).for_each(|i| { writer .append( @@ -410,16 +416,17 @@ fn pristine_comparison_under_faults() { pristine_mgr.shutdown(); let pristine_fd = pristine_mgr.open_for_read(SegmentId::new(1)).unwrap(); - let pristine_events = SegmentReader::open(pristine_mgr.io(), pristine_fd) - .unwrap() - .valid_prefix() - .unwrap(); + let pristine_events = + SegmentReader::open(pristine_mgr.io(), pristine_fd, MAX_EVENT_PAYLOAD) + .unwrap() + .valid_prefix() + .unwrap(); let faulty_sim = SimulatedIO::new(seed, FaultConfig::moderate()); let faulty_mgr = setup_manager(faulty_sim, 64 * 1024); let write_ok = (|| -> std::io::Result<()> { - let mut writer = EventLogWriter::open(Arc::clone(&faulty_mgr), 256)?; + let mut writer = EventLogWriter::open(Arc::clone(&faulty_mgr), 256, MAX_EVENT_PAYLOAD)?; (1..=event_count).try_for_each(|i| -> std::io::Result<()> { writer.append( DidHash::from_did(&format!("did:plc:prist{i}")), @@ -443,7 +450,8 @@ fn pristine_comparison_under_faults() { let faulty_clone = Arc::clone(&faulty_mgr); let recovery = std::panic::catch_unwind(std::panic::AssertUnwindSafe( || -> std::io::Result>> { - let recovered_writer = EventLogWriter::open(Arc::clone(&faulty_clone), 256)?; + let recovered_writer = + EventLogWriter::open(Arc::clone(&faulty_clone), 256, MAX_EVENT_PAYLOAD)?; let recovered_seq = recovered_writer.synced_seq().raw(); assert!( @@ -456,7 +464,8 @@ fn pristine_comparison_under_faults() { } let fd = faulty_clone.open_for_read(SegmentId::new(1))?; - let events = SegmentReader::open(faulty_clone.io(), fd)?.valid_prefix()?; + let events = SegmentReader::open(faulty_clone.io(), fd, MAX_EVENT_PAYLOAD)? + .valid_prefix()?; Ok(Some(events)) }, )); @@ -477,7 +486,7 @@ fn pristine_comparison_under_faults() { #[test] fn bit_flip_detected_by_checksum() { - (0..1000u64).for_each(|seed| { + sim_seed_range().into_par_iter().for_each(|seed| { let sim = SimulatedIO::pristine(seed); let dir = Path::new("/test"); sim.mkdir(dir).unwrap(); @@ -486,8 +495,14 @@ fn bit_flip_detected_by_checksum() { let fd = sim .open(Path::new("/test/segment.tqe"), OpenOptions::read_write()) .unwrap(); - let mut writer = - SegmentWriter::new(&sim, fd, SegmentId::new(1), EventSequence::new(1)).unwrap(); + let mut writer = SegmentWriter::new( + &sim, + fd, + SegmentId::new(1), + EventSequence::new(1), + MAX_EVENT_PAYLOAD, + ) + .unwrap(); let data_len = ((seed % 256) as usize).max(1); let event = ValidEvent { @@ -511,7 +526,7 @@ fn bit_flip_detected_by_checksum() { sim.write_all_at(fd, flip_pos, &byte_buf).unwrap(); use tranquil_store::eventlog::ReadEventRecord; - let mut reader = SegmentReader::open(&sim, fd).unwrap(); + let mut reader = SegmentReader::open(&sim, fd, MAX_EVENT_PAYLOAD).unwrap(); let record = reader.next().unwrap().unwrap(); assert!( !matches!(record, ReadEventRecord::Valid { .. }), @@ -551,14 +566,14 @@ fn fault_configs() -> Vec<(&'static str, FaultConfig)> { #[test] fn pristine_comparison_parameterized_faults() { fault_configs().iter().for_each(|(config_name, config)| { - (0..200u64).for_each(|seed| { + sim_seed_range().into_par_iter().for_each(|seed| { let event_count = 10u64; let pristine_sim = SimulatedIO::pristine(seed); let pristine_mgr = setup_manager(pristine_sim, 64 * 1024); { let mut writer = - EventLogWriter::open(Arc::clone(&pristine_mgr), 256).unwrap(); + EventLogWriter::open(Arc::clone(&pristine_mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); (1..=event_count).for_each(|i| { writer .append( @@ -576,7 +591,7 @@ fn pristine_comparison_parameterized_faults() { pristine_mgr.shutdown(); let pristine_fd = pristine_mgr.open_for_read(SegmentId::new(1)).unwrap(); - let pristine_events = SegmentReader::open(pristine_mgr.io(), pristine_fd) + let pristine_events = SegmentReader::open(pristine_mgr.io(), pristine_fd, MAX_EVENT_PAYLOAD) .unwrap() .valid_prefix() .unwrap(); @@ -585,7 +600,7 @@ fn pristine_comparison_parameterized_faults() { let faulty_mgr = setup_manager(faulty_sim, 64 * 1024); let _ = (|| -> std::io::Result<()> { let mut writer = - EventLogWriter::open(Arc::clone(&faulty_mgr), 256)?; + EventLogWriter::open(Arc::clone(&faulty_mgr), 256, MAX_EVENT_PAYLOAD)?; (1..=event_count).try_for_each(|i| -> std::io::Result<()> { writer.append( DidHash::from_did(&format!("did:plc:param{i}")), @@ -608,7 +623,7 @@ fn pristine_comparison_parameterized_faults() { let faulty_clone = Arc::clone(&faulty_mgr); let recovery = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> std::io::Result>> { let recovered_writer = - EventLogWriter::open(Arc::clone(&faulty_clone), 256)?; + EventLogWriter::open(Arc::clone(&faulty_clone), 256, MAX_EVENT_PAYLOAD)?; let recovered_seq = recovered_writer.synced_seq().raw(); assert!( @@ -621,7 +636,7 @@ fn pristine_comparison_parameterized_faults() { } let fd = faulty_clone.open_for_read(SegmentId::new(1))?; - let events = SegmentReader::open(faulty_clone.io(), fd)? + let events = SegmentReader::open(faulty_clone.io(), fd, MAX_EVENT_PAYLOAD)? .valid_prefix()?; Ok(Some(events)) })); diff --git a/crates/tranquil-store/tests/eventlog_properties.rs b/crates/tranquil-store/tests/eventlog_properties.rs index 2701956..75d7833 100644 --- a/crates/tranquil-store/tests/eventlog_properties.rs +++ b/crates/tranquil-store/tests/eventlog_properties.rs @@ -29,7 +29,7 @@ fn append_test_event(writer: &mut EventLogWriter, seq_hint: u64) -> fn sequence_assignment_is_contiguous() { let n = 100u64; let mgr = setup_manager(64 * 1024); - let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); let seqs: Vec = (1..=n).map(|i| append_test_event(&mut writer, i)).collect(); @@ -48,7 +48,7 @@ fn cursor_resumption_returns_correct_suffix() { let mgr = setup_manager(64 * 1024); { - let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); (1..=1000).for_each(|i| { append_test_event(&mut writer, i); }); @@ -56,7 +56,7 @@ fn cursor_resumption_returns_correct_suffix() { } mgr.shutdown(); - let reader = EventLogReader::new(Arc::clone(&mgr), false); + let reader = EventLogReader::new(Arc::clone(&mgr), false, false, MAX_EVENT_PAYLOAD); reader.refresh_segment_ranges().unwrap(); let events = reader @@ -88,7 +88,7 @@ fn cross_segment_read_is_seamless() { let mgr = setup_manager(max_segment_size); { - let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); (1..=total_events).for_each(|i| { writer .append( @@ -107,7 +107,7 @@ fn cross_segment_read_is_seamless() { } mgr.shutdown(); - let reader = EventLogReader::new(Arc::clone(&mgr), false); + let reader = EventLogReader::new(Arc::clone(&mgr), false, false, MAX_EVENT_PAYLOAD); reader.refresh_segment_ranges().unwrap(); let events = reader @@ -141,7 +141,7 @@ fn retention_deletes_only_old_segments() { let mgr = Arc::new(SegmentManager::new(sim, PathBuf::from("/segments"), max_segment_size).unwrap()); - let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); (1..=15).for_each(|i| { writer @@ -223,7 +223,7 @@ fn payload_round_trip() { serde_json::json!([{"action": "create", "path": "app.bsky.feed.post/abc"}]), ), blobs: Some(vec!["bafkreibtest".to_owned()]), - blocks_cids: None, + blocks: None, handle: None, active: None, status: None, @@ -243,7 +243,7 @@ fn payload_round_trip() { prev_data_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, handle: Some(Handle::new("test.bsky.social").unwrap()), active: None, status: None, @@ -263,7 +263,7 @@ fn payload_round_trip() { prev_data_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, handle: None, active: Some(true), status: Some(AccountStatus::Active), @@ -283,7 +283,7 @@ fn payload_round_trip() { prev_data_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, handle: None, active: None, status: None, @@ -315,8 +315,9 @@ fn payload_round_trip() { #[test] fn max_payload_accepted() { - let payload = vec![0xBB; MAX_EVENT_PAYLOAD as usize]; - assert!(validate_payload_size(&payload).is_ok()); + const SMALL_MAX: u32 = 1024 * 1024; + let payload = vec![0xBB; SMALL_MAX as usize]; + assert!(validate_payload_size(&payload, SMALL_MAX).is_ok()); let sim = SimulatedIO::pristine(42); let dir = Path::new("/test"); @@ -331,6 +332,7 @@ fn max_payload_accepted() { fd, SegmentId::new(1), EventSequence::new(1), + SMALL_MAX, ) .unwrap(); @@ -344,19 +346,20 @@ fn max_payload_accepted() { writer.append_event(&sim, &event).unwrap(); writer.sync(&sim).unwrap(); - let reader = SegmentReader::open(&sim, fd).unwrap(); + let reader = SegmentReader::open(&sim, fd, SMALL_MAX).unwrap(); let events = reader.valid_prefix().unwrap(); assert_eq!(events.len(), 1); - assert_eq!(events[0].payload.len(), MAX_EVENT_PAYLOAD as usize); + assert_eq!(events[0].payload.len(), SMALL_MAX as usize); } #[test] fn oversized_payload_rejected() { - let payload = vec![0xCC; MAX_EVENT_PAYLOAD as usize + 1]; - match validate_payload_size(&payload) { + const SMALL_MAX: u32 = 1024; + let payload = vec![0xCC; SMALL_MAX as usize + 1]; + match validate_payload_size(&payload, SMALL_MAX) { Err(PayloadError::TooLarge { size, max }) => { - assert_eq!(size, MAX_EVENT_PAYLOAD as usize + 1); - assert_eq!(max, MAX_EVENT_PAYLOAD as usize); + assert_eq!(size, SMALL_MAX as usize + 1); + assert_eq!(max, SMALL_MAX as usize); } other => panic!("expected TooLarge, got {other:?}"), } @@ -374,7 +377,7 @@ fn retention_does_not_break_active_readers() { Arc::new(SegmentManager::new(sim, PathBuf::from("/segments"), max_segment_size).unwrap()); { - let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); (1..=25).for_each(|i| { writer .append( @@ -392,7 +395,7 @@ fn retention_does_not_break_active_readers() { } mgr.shutdown(); - let reader = EventLogReader::new(Arc::clone(&mgr), false); + let reader = EventLogReader::new(Arc::clone(&mgr), false, false, MAX_EVENT_PAYLOAD); reader.refresh_segment_ranges().unwrap(); let first_batch = reader @@ -421,6 +424,7 @@ async fn subscriber_lag_recovery() { index_interval: 256, broadcast_buffer: 4, use_mmap: false, + ..EventLogConfig::default() }; let event_log = EventLog::open(config, sim).unwrap(); @@ -442,7 +446,7 @@ async fn subscriber_lag_recovery() { prev_data_cid: None, ops: None, blobs: None, - blocks_cids: None, + blocks: None, handle: None, active: None, status: None, @@ -498,7 +502,7 @@ fn index_checkpoint_accelerates_recovery() { Arc::new(SegmentManager::new(sim, PathBuf::from("/segments"), 256 * 1024 * 1024).unwrap()); { - let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256).unwrap(); + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); (1..=event_count).for_each(|i| { writer .append( @@ -524,7 +528,7 @@ fn index_checkpoint_accelerates_recovery() { let offset = index.lookup(mid); assert!(offset.is_some(), "index should cover midpoint seq {}", mid,); - let reader_with_index = EventLogReader::new(Arc::clone(&mgr), false); + let reader_with_index = EventLogReader::new(Arc::clone(&mgr), false, false, MAX_EVENT_PAYLOAD); let reads_before = mgr .io() @@ -549,7 +553,8 @@ fn index_checkpoint_accelerates_recovery() { let _ = mgr.io().delete(&mgr.index_path(SegmentId::new(1))); - let reader_without_index = EventLogReader::new(Arc::clone(&mgr), false); + let reader_without_index = + EventLogReader::new(Arc::clone(&mgr), false, false, MAX_EVENT_PAYLOAD); let reads_before = mgr .io() @@ -615,7 +620,8 @@ fn fsync_ordering_blocks_before_events() { sim.sync_dir(data_dir).unwrap(); { - let mut event_writer = EventLogWriter::open(Arc::clone(&event_mgr), 256).unwrap(); + let mut event_writer = + EventLogWriter::open(Arc::clone(&event_mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); event_writer .append( DidHash::from_did("did:plc:fsyncorder"), @@ -643,7 +649,8 @@ fn fsync_ordering_blocks_before_events() { ); assert_eq!(recovered_blocks[0].1, cid, "recovered block CID must match"); - let event_writer = EventLogWriter::open(Arc::clone(&event_mgr), 256).unwrap(); + let event_writer = + EventLogWriter::open(Arc::clone(&event_mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); assert_eq!( event_writer.synced_seq(), EventSequence::BEFORE_ALL, @@ -653,7 +660,8 @@ fn fsync_ordering_blocks_before_events() { drop(event_writer); { - let mut event_writer = EventLogWriter::open(Arc::clone(&event_mgr), 256).unwrap(); + let mut event_writer = + EventLogWriter::open(Arc::clone(&event_mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); event_writer .append( DidHash::from_did("did:plc:fsyncorder"), @@ -668,7 +676,8 @@ fn fsync_ordering_blocks_before_events() { event_mgr.shutdown(); sim.crash(); - let event_writer = EventLogWriter::open(Arc::clone(&event_mgr), 256).unwrap(); + let event_writer = + EventLogWriter::open(Arc::clone(&event_mgr), 256, MAX_EVENT_PAYLOAD).unwrap(); assert_eq!( event_writer.synced_seq(), EventSequence::new(1), diff --git a/crates/tranquil-store/tests/eventlog_retention.rs b/crates/tranquil-store/tests/eventlog_retention.rs new file mode 100644 index 0000000..5251417 --- /dev/null +++ b/crates/tranquil-store/tests/eventlog_retention.rs @@ -0,0 +1,149 @@ +use std::path::PathBuf; +use std::time::Duration; + +use tranquil_db_traits::{RepoEventType, SequenceNumber, SequencedEvent}; +use tranquil_store::SimulatedIO; +use tranquil_store::eventlog::{EventLog, EventLogConfig, TimestampMicros}; +use tranquil_types::Did; + +fn make_event(seq: u64) -> SequencedEvent { + SequencedEvent { + seq: SequenceNumber::from_raw(seq as i64), + did: Did::new("did:plc:retentiontest1234567").unwrap(), + created_at: chrono::Utc::now(), + event_type: RepoEventType::Commit, + commit_cid: None, + prev_cid: None, + prev_data_cid: None, + ops: None, + blobs: None, + blocks: None, + handle: None, + active: None, + status: None, + rev: None, + } +} + +fn append_n(event_log: &EventLog, did: &Did, n: u64) { + (1..=n).for_each(|i| { + event_log + .append_and_sync(did, RepoEventType::Commit, &make_event(i)) + .unwrap(); + }); +} + +#[test] +fn run_retention_at_deletes_sealed_segments_past_cutoff() { + let sim = SimulatedIO::pristine(7); + let config = EventLogConfig { + segments_dir: PathBuf::from("/segments"), + max_segment_size: 4 * 1024, + broadcast_buffer: 16, + use_mmap: false, + ..EventLogConfig::default() + }; + + let event_log = EventLog::open(config, sim).unwrap(); + let did = Did::new("did:plc:retentiontest1234567").unwrap(); + append_n(&event_log, &did, 200); + + let segments_before = event_log.segment_count(); + assert!( + segments_before >= 3, + "expected at least 3 segments rolled, got {segments_before}" + ); + + let deleted = event_log + .run_retention_at(TimestampMicros::new(u64::MAX), Duration::from_secs(0)) + .unwrap(); + + assert!( + deleted >= segments_before - 1, + "expected at least {} segments deleted, got {deleted}", + segments_before - 1 + ); + + let segments_after = event_log.segment_count(); + assert_eq!( + segments_after, + segments_before - deleted, + "segment count should drop by deleted amount" + ); + assert!( + segments_after >= 1, + "active segment must remain (got {segments_after})" + ); +} + +#[test] +fn run_retention_at_keeps_recent_events() { + let sim = SimulatedIO::pristine(8); + let config = EventLogConfig { + segments_dir: PathBuf::from("/segments"), + max_segment_size: 4 * 1024, + broadcast_buffer: 16, + use_mmap: false, + ..EventLogConfig::default() + }; + + let event_log = EventLog::open(config, sim).unwrap(); + let did = Did::new("did:plc:retentiontest1234567").unwrap(); + append_n(&event_log, &did, 50); + + let segments_before = event_log.segment_count(); + let max_seq_before = event_log.max_seq(); + + let deleted = event_log + .run_retention_at(TimestampMicros::new(0), Duration::from_secs(0)) + .unwrap(); + + assert_eq!( + deleted, 0, + "no segments should be deleted when cutoff is in the past" + ); + assert_eq!(event_log.segment_count(), segments_before); + assert_eq!(event_log.max_seq(), max_seq_before); +} + +#[test] +fn run_retention_at_idempotent() { + let sim = SimulatedIO::pristine(9); + let config = EventLogConfig { + segments_dir: PathBuf::from("/segments"), + max_segment_size: 4 * 1024, + broadcast_buffer: 16, + use_mmap: false, + ..EventLogConfig::default() + }; + + let event_log = EventLog::open(config, sim).unwrap(); + let did = Did::new("did:plc:retentiontest1234567").unwrap(); + append_n(&event_log, &did, 200); + let max_seq = event_log.max_seq(); + + let first = event_log + .run_retention_at(TimestampMicros::new(u64::MAX), Duration::from_secs(0)) + .unwrap(); + assert!(first > 0); + + let second = event_log + .run_retention_at(TimestampMicros::new(u64::MAX), Duration::from_secs(0)) + .unwrap(); + assert_eq!(second, 0, "second pass should be a no-op"); + + assert_eq!( + event_log.max_seq(), + max_seq, + "max_seq must not regress after retention" + ); + + let new_seq = event_log + .append_and_sync(&did, RepoEventType::Commit, &make_event(201)) + .unwrap(); + let appended = event_log.get_event(new_seq).unwrap(); + assert!( + appended.is_some(), + "newly appended event must be readable after retention" + ); +} diff --git a/crates/tranquil-store/tests/gc.rs b/crates/tranquil-store/tests/gc.rs new file mode 100644 index 0000000..3ab1bac --- /dev/null +++ b/crates/tranquil-store/tests/gc.rs @@ -0,0 +1,564 @@ +use std::collections::HashSet; + +use tranquil_store::blockstore::{ + BlockStoreConfig, CidBytes, DEFAULT_MAX_FILE_SIZE, DataFileId, GroupCommitConfig, + TranquilBlockStore, +}; + +fn test_cid(seed: u8) -> [u8; 36] { + test_cid_u16(seed as u16) +} + +fn test_cid_u16(seed: u16) -> [u8; 36] { + let mut cid = [0u8; 36]; + cid[0] = 0x01; + cid[1] = 0x71; + cid[2] = 0x12; + cid[3] = 0x20; + cid[4..6].copy_from_slice(&seed.to_le_bytes()); + (6..36).for_each(|i| cid[i] = (seed as u8).wrapping_add(i as u8)); + cid +} + +fn small_store_config(dir: &std::path::Path) -> BlockStoreConfig { + BlockStoreConfig { + data_dir: dir.join("data"), + index_dir: dir.join("index"), + max_file_size: 512, + group_commit: GroupCommitConfig::default(), + shard_count: 1, + } +} + +fn default_store_config(dir: &std::path::Path) -> BlockStoreConfig { + BlockStoreConfig { + data_dir: dir.join("data"), + index_dir: dir.join("index"), + max_file_size: DEFAULT_MAX_FILE_SIZE, + group_commit: GroupCommitConfig::default(), + shard_count: 1, + } +} + +fn with_runtime(f: F) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = rt.enter(); + f(); +} + +#[test] +fn refcount_decrement_to_zero_sets_gc_eligible() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(default_store_config(dir.path())).unwrap(); + + let cid = test_cid(1); + store + .put_blocks_blocking(vec![(cid, vec![0xABu8; 128])]) + .unwrap(); + + store.apply_commit_blocking(vec![], vec![cid]).unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(5)); + + let result = store.collect_dead_blocks(0).unwrap(); + let all_cids: Vec = result + .candidates + .values() + .flat_map(|v| v.iter().copied()) + .collect(); + assert!( + all_cids.contains(&cid), + "block decremented to zero should appear in dead block candidates" + ); + }); +} + +#[test] +fn re_increment_from_zero_clears_gc_eligible() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(default_store_config(dir.path())).unwrap(); + + let cid = test_cid(2); + let data = vec![0xCDu8; 128]; + store + .put_blocks_blocking(vec![(cid, data.clone())]) + .unwrap(); + + store.apply_commit_blocking(vec![], vec![cid]).unwrap(); + + store.put_blocks_blocking(vec![(cid, data)]).unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(5)); + + let result = store.collect_dead_blocks(0).unwrap(); + let all_cids: Vec = result + .candidates + .values() + .flat_map(|v| v.iter().copied()) + .collect(); + assert!( + !all_cids.contains(&cid), + "re-referenced block should not appear in dead block candidates" + ); + }); +} + +#[test] +fn collect_dead_blocks_respects_grace_period() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(default_store_config(dir.path())).unwrap(); + + let cid = test_cid(3); + store + .put_blocks_blocking(vec![(cid, vec![0xEFu8; 64])]) + .unwrap(); + store.apply_commit_blocking(vec![], vec![cid]).unwrap(); + + let result = store.collect_dead_blocks(600_000).unwrap(); + assert!( + result.candidates.is_empty(), + "blocks should not be eligible when grace period hasn't expired" + ); + + std::thread::sleep(std::time::Duration::from_millis(5)); + let result = store.collect_dead_blocks(0).unwrap(); + let all_cids: Vec = result + .candidates + .values() + .flat_map(|v| v.iter().copied()) + .collect(); + assert!( + all_cids.contains(&cid), + "blocks should be eligible after grace period expires" + ); + }); +} + +#[test] +fn collect_dead_blocks_respects_epoch_gating() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(default_store_config(dir.path())).unwrap(); + + let cid_a = test_cid(10); + let cid_b = test_cid(11); + store + .put_blocks_blocking(vec![(cid_a, vec![0x10u8; 64]), (cid_b, vec![0x11u8; 64])]) + .unwrap(); + + store.apply_commit_blocking(vec![], vec![cid_a]).unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(5)); + let result = store.collect_dead_blocks(0).unwrap(); + let all_cids: HashSet = result + .candidates + .values() + .flat_map(|v| v.iter().copied()) + .collect(); + assert!( + all_cids.contains(&cid_a), + "cid_a should be collectible (epoch advanced by subsequent commit)" + ); + }); +} + +#[test] +fn compact_data_file_preserves_live_removes_dead() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(small_store_config(dir.path())).unwrap(); + + let blocks: Vec<_> = (0u8..5) + .map(|seed| (test_cid(seed), vec![seed; 80])) + .collect(); + store.put_blocks_blocking(blocks).unwrap(); + + let padding: Vec<_> = (200u8..210) + .map(|seed| (test_cid(seed), vec![seed; 512])) + .collect(); + store.put_blocks_blocking(padding).unwrap(); + + let files_before = store.list_data_files().unwrap(); + assert!( + files_before.len() >= 2, + "should have rotated to at least 2 data files" + ); + let first_file = files_before[0]; + + store + .apply_commit_blocking(vec![], vec![test_cid(0), test_cid(2), test_cid(4)]) + .unwrap(); + std::thread::sleep(std::time::Duration::from_millis(5)); + + let result = store.compact_file(first_file, 0).unwrap(); + assert!(result.dead_blocks > 0, "should have removed dead blocks"); + assert!(result.live_blocks > 0, "should have preserved live blocks"); + assert!(result.reclaimed_bytes > 0, "should have reclaimed space"); + + [1u8, 3].iter().for_each(|&seed| { + let data = store.get_block_sync(&test_cid(seed)).unwrap(); + assert!( + data.is_some(), + "live block seed={seed} should still be readable" + ); + assert_eq!(data.unwrap()[0], seed); + }); + }); +} + +#[test] +fn compact_data_file_crash_safe_old_file_survives() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + + { + let store = TranquilBlockStore::open(small_store_config(dir.path())).unwrap(); + + let blocks: Vec<_> = (0u8..3) + .map(|seed| (test_cid(seed), vec![seed; 80])) + .collect(); + store.put_blocks_blocking(blocks).unwrap(); + + let padding: Vec<_> = (200u8..210) + .map(|seed| (test_cid(seed), vec![seed; 512])) + .collect(); + store.put_blocks_blocking(padding).unwrap(); + + let files = store.list_data_files().unwrap(); + let first_file = files[0]; + + store.compact_file(first_file, 600_000).unwrap(); + + (0u8..3).for_each(|seed| { + let data = store.get_block_sync(&test_cid(seed)).unwrap(); + assert!( + data.is_some(), + "block seed={seed} should still be readable after no-op compaction" + ); + }); + } + + let store2 = TranquilBlockStore::open(small_store_config(dir.path())).unwrap(); + (0u8..3).for_each(|seed| { + let data = store2.get_block_sync(&test_cid(seed)).unwrap(); + assert!( + data.is_some(), + "block seed={seed} should survive reopen after compaction" + ); + }); + }); +} + +#[test] +fn simulation_write_decrement_gc_verify() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(default_store_config(dir.path())).unwrap(); + + let live_seeds: Vec = (0u16..50).collect(); + let dead_seeds: Vec = (50u16..100).collect(); + let all_blocks: Vec<_> = live_seeds + .iter() + .chain(dead_seeds.iter()) + .map(|&seed| (test_cid_u16(seed), vec![(seed & 0xFF) as u8; 128])) + .collect(); + store.put_blocks_blocking(all_blocks).unwrap(); + + let dead_cids: Vec<_> = dead_seeds.iter().map(|&s| test_cid_u16(s)).collect(); + store + .apply_commit_blocking(vec![], dead_cids.clone()) + .unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + + let result = store.collect_dead_blocks(0).unwrap(); + let collected: HashSet = result + .candidates + .values() + .flat_map(|v| v.iter().copied()) + .collect(); + + dead_cids.iter().for_each(|cid| { + assert!(collected.contains(cid), "dead block should be collected"); + }); + + live_seeds.iter().for_each(|&seed| { + let cid = test_cid_u16(seed); + assert!( + !collected.contains(&cid), + "live block seed={seed} should not be collected" + ); + }); + + live_seeds.iter().for_each(|&seed| { + let data = store.get_block_sync(&test_cid_u16(seed)).unwrap(); + assert!(data.is_some(), "live block seed={seed} should be readable"); + }); + }); +} + +#[test] +fn simulation_crash_during_compaction_recovery() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + + { + let store = TranquilBlockStore::open(small_store_config(dir.path())).unwrap(); + + let blocks: Vec<_> = (0u8..5) + .map(|seed| (test_cid(seed), vec![seed; 80])) + .collect(); + store.put_blocks_blocking(blocks).unwrap(); + + let padding: Vec<_> = (200u8..210) + .map(|seed| (test_cid(seed), vec![seed; 512])) + .collect(); + store.put_blocks_blocking(padding).unwrap(); + + let files = store.list_data_files().unwrap(); + let first_file = files[0]; + + store + .apply_commit_blocking(vec![], vec![test_cid(1), test_cid(3)]) + .unwrap(); + std::thread::sleep(std::time::Duration::from_millis(5)); + + store.compact_file(first_file, 0).unwrap(); + } + + let store = TranquilBlockStore::open(small_store_config(dir.path())).unwrap(); + + [0u8, 2, 4].iter().for_each(|&seed| { + let data = store.get_block_sync(&test_cid(seed)).unwrap(); + assert!( + data.is_some(), + "live block seed={seed} should survive compaction + reopen" + ); + assert_eq!(data.unwrap()[0], seed); + }); + + [1u8, 3].iter().for_each(|&seed| { + let data = store.get_block_sync(&test_cid(seed)).unwrap(); + assert!( + data.is_none(), + "dead block seed={seed} should have been removed by compaction" + ); + }); + }); +} + +#[test] +fn simulation_concurrent_writes_during_compaction() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(small_store_config(dir.path())).unwrap(); + + let initial_blocks: Vec<_> = (0u8..5) + .map(|seed| (test_cid(seed), vec![seed; 80])) + .collect(); + store.put_blocks_blocking(initial_blocks).unwrap(); + + let padding: Vec<_> = (200u8..220) + .map(|seed| (test_cid(seed), vec![seed; 512])) + .collect(); + store.put_blocks_blocking(padding).unwrap(); + + let files = store.list_data_files().unwrap(); + let first_file = files[0]; + + store + .apply_commit_blocking(vec![], vec![test_cid(0), test_cid(2)]) + .unwrap(); + std::thread::sleep(std::time::Duration::from_millis(5)); + + let store_clone = store.clone(); + let writer_thread = std::thread::spawn(move || { + let concurrent_blocks: Vec<_> = (100u8..120) + .map(|seed| (test_cid(seed), vec![seed; 64])) + .collect(); + store_clone.put_blocks_blocking(concurrent_blocks).unwrap(); + }); + + let compact_result = store.compact_file(first_file, 0); + writer_thread.join().unwrap(); + + assert!( + compact_result.is_ok(), + "compaction should succeed even with concurrent writes" + ); + + [1u8, 3, 4].iter().for_each(|&seed| { + let data = store.get_block_sync(&test_cid(seed)).unwrap(); + assert!( + data.is_some(), + "live block seed={seed} should survive concurrent compaction" + ); + }); + + (100u8..120).for_each(|seed| { + let data = store.get_block_sync(&test_cid(seed)).unwrap(); + assert!( + data.is_some(), + "concurrently written block seed={seed} should be readable" + ); + }); + }); +} + +#[test] +fn reachability_walk_finds_leaked_refcounts() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(default_store_config(dir.path())).unwrap(); + + let reachable_cids: Vec = (0u8..20).map(test_cid).collect(); + let leaked_cids: Vec = (20u8..25).map(test_cid).collect(); + + let all_blocks: Vec<_> = reachable_cids + .iter() + .chain(leaked_cids.iter()) + .map(|&cid| (cid, vec![cid[4]; 64])) + .collect(); + store.put_blocks_blocking(all_blocks).unwrap(); + + let reachable_set: HashSet = reachable_cids.iter().copied().collect(); + let (leaked, live_scanned) = store + .find_leaked_refcounts(|cid| reachable_set.contains(cid)) + .unwrap(); + + assert_eq!( + live_scanned, + (reachable_cids.len() + leaked_cids.len()) as u64, + "should have scanned all blocks with refcount > 0" + ); + assert_eq!( + leaked.len(), + leaked_cids.len(), + "should have found exactly the leaked blocks" + ); + + let leaked_cid_set: HashSet = leaked.iter().map(|(cid, _)| *cid).collect(); + leaked_cids.iter().for_each(|cid| { + assert!( + leaked_cid_set.contains(cid), + "leaked block should be detected" + ); + }); + + let repaired = store.repair_leaked_refcounts(&leaked).unwrap(); + assert_eq!(repaired, leaked_cids.len() as u64); + + store + .put_blocks_blocking(vec![(test_cid(99), vec![0x99u8; 16])]) + .unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(5)); + let result = store.collect_dead_blocks(0).unwrap(); + let dead_cids_collected: HashSet = result + .candidates + .values() + .flat_map(|v| v.iter().copied()) + .collect(); + leaked_cids.iter().for_each(|cid| { + assert!( + dead_cids_collected.contains(cid), + "repaired leaked block should now be gc-eligible" + ); + }); + }); +} + +#[test] +fn full_gc_cycle_collect_compact_reachability() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(small_store_config(dir.path())).unwrap(); + + let live_blocks: Vec<_> = (0u8..3) + .map(|seed| (test_cid(seed), vec![seed; 80])) + .collect(); + let dead_blocks: Vec<_> = (3u8..6) + .map(|seed| (test_cid(seed), vec![seed; 80])) + .collect(); + let leaked_blocks: Vec<_> = (6u8..8) + .map(|seed| (test_cid(seed), vec![seed; 80])) + .collect(); + + store + .put_blocks_blocking( + live_blocks + .iter() + .chain(dead_blocks.iter()) + .chain(leaked_blocks.iter()) + .cloned() + .collect(), + ) + .unwrap(); + + let padding: Vec<_> = (220u8..240) + .map(|seed| (test_cid(seed), vec![seed; 512])) + .collect(); + store.put_blocks_blocking(padding).unwrap(); + + let dead_cids: Vec<_> = (3u8..6).map(test_cid).collect(); + store.apply_commit_blocking(vec![], dead_cids).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + + let collection = store.collect_dead_blocks(0).unwrap(); + assert!( + !collection.candidates.is_empty(), + "should have dead blocks to collect" + ); + + let files = store.list_data_files().unwrap(); + let sealed_files: Vec = files + .iter() + .copied() + .take(files.len().saturating_sub(1)) + .collect(); + + sealed_files.iter().for_each(|&file_id| { + let liveness = store.liveness_info(file_id).unwrap(); + if liveness.ratio() < 1.0 && liveness.total_blocks > 0 { + store.compact_file(file_id, 0).ok(); + } + }); + + (0u8..3).for_each(|seed| { + let data = store.get_block_sync(&test_cid(seed)).unwrap(); + assert!(data.is_some(), "live block seed={seed} should survive gc"); + }); + + let reachable: HashSet = (0u8..3) + .map(test_cid) + .chain((220u8..240).map(test_cid)) + .collect(); + let (leaked, _) = store + .find_leaked_refcounts(|cid| reachable.contains(cid)) + .unwrap(); + + let leaked_cid_set: HashSet = leaked.iter().map(|(c, _)| *c).collect(); + (6u8..8).for_each(|seed| { + assert!( + leaked_cid_set.contains(&test_cid(seed)), + "block seed={seed} should be detected as leaked" + ); + }); + + let repaired = store.repair_leaked_refcounts(&leaked).unwrap(); + assert!(repaired > 0, "should have repaired leaked refcounts"); + + let cleaned = store.cleanup_gc_meta().unwrap(); + assert_eq!(cleaned, 0, "no stale gc_meta entries after proper gc cycle"); + + (0u8..3).for_each(|seed| { + let data = store.get_block_sync(&test_cid(seed)).unwrap(); + assert!( + data.is_some(), + "live block seed={seed} should still be readable after full gc cycle" + ); + }); + }); +} diff --git a/crates/tranquil-store/tests/gc_stress.rs b/crates/tranquil-store/tests/gc_stress.rs new file mode 100644 index 0000000..da9cadc --- /dev/null +++ b/crates/tranquil-store/tests/gc_stress.rs @@ -0,0 +1,903 @@ +use std::collections::{HashMap, HashSet}; + +use proptest::prelude::*; +use tranquil_store::blockstore::{ + BlockStoreConfig, CidBytes, DEFAULT_MAX_FILE_SIZE, DataFileId, GroupCommitConfig, + TranquilBlockStore, +}; + +fn test_cid_u32(seed: u32) -> [u8; 36] { + let mut cid = [0u8; 36]; + cid[0] = 0x01; + cid[1] = 0x71; + cid[2] = 0x12; + cid[3] = 0x20; + cid[4..8].copy_from_slice(&seed.to_le_bytes()); + (8..36).for_each(|i| cid[i] = (seed as u8).wrapping_add(i as u8)); + cid +} + +fn block_data(seed: u32) -> Vec { + let tag = seed.to_le_bytes(); + let mut data = vec![0u8; 80]; + data[..4].copy_from_slice(&tag); + data +} + +fn small_config(dir: &std::path::Path) -> BlockStoreConfig { + BlockStoreConfig { + data_dir: dir.join("data"), + index_dir: dir.join("index"), + max_file_size: 512, + group_commit: GroupCommitConfig::default(), + shard_count: 1, + } +} + +fn default_config(dir: &std::path::Path) -> BlockStoreConfig { + BlockStoreConfig { + data_dir: dir.join("data"), + index_dir: dir.join("index"), + max_file_size: DEFAULT_MAX_FILE_SIZE, + group_commit: GroupCommitConfig::default(), + shard_count: 1, + } +} + +fn with_runtime(f: F) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = rt.enter(); + f(); +} + +fn collect_all_dead(store: &TranquilBlockStore) -> HashSet { + let result = store.collect_dead_blocks(0).unwrap(); + result + .candidates + .values() + .flat_map(|v| v.iter().copied()) + .collect() +} + +fn compact_all_sealed(store: &TranquilBlockStore) { + let files = store.list_data_files().unwrap(); + let sealed: Vec = files + .iter() + .copied() + .take(files.len().saturating_sub(1)) + .collect(); + + sealed.iter().for_each(|&fid| { + store.compact_file(fid, 0).ok(); + }); +} + +fn verify_live_readable(store: &TranquilBlockStore, oracle: &GcOracle) { + oracle.live_seeds().iter().for_each(|&seed| { + let cid = test_cid_u32(seed); + let data = store + .get_block_sync(&cid) + .unwrap_or_else(|e| panic!("get_block_sync failed for seed={seed}: {e}")); + assert!( + data.is_some(), + "live block seed={seed} (refcount={}) must be readable", + oracle.refcount(seed) + ); + let expected = block_data(seed); + assert_eq!( + &data.unwrap()[..4], + &expected[..4], + "data mismatch for live block seed={seed}" + ); + }); +} + +fn verify_no_live_in_dead(store: &TranquilBlockStore, oracle: &GcOracle) { + let dead = collect_all_dead(store); + oracle.live_seeds().iter().for_each(|&seed| { + let cid = test_cid_u32(seed); + assert!( + !dead.contains(&cid), + "live block seed={seed} (refcount={}) must not appear in dead candidates", + oracle.refcount(seed) + ); + }); +} + +struct GcOracle { + refcounts: HashMap, +} + +impl GcOracle { + fn new() -> Self { + Self { + refcounts: HashMap::new(), + } + } + + fn put(&mut self, seed: u32) { + *self.refcounts.entry(seed).or_insert(0) += 1; + } + + fn delete(&mut self, seed: u32) -> bool { + match self.refcounts.get_mut(&seed) { + Some(rc) if *rc > 0 => { + *rc -= 1; + true + } + _ => false, + } + } + + fn refcount(&self, seed: u32) -> u32 { + self.refcounts.get(&seed).copied().unwrap_or(0) + } + + fn live_seeds(&self) -> Vec { + self.refcounts + .iter() + .filter(|&(_, rc)| *rc > 0) + .map(|(&seed, _)| seed) + .collect() + } + + fn dead_seeds(&self) -> Vec { + self.refcounts + .iter() + .filter(|&(_, rc)| *rc == 0) + .map(|(&seed, _)| seed) + .collect() + } +} + +fn advance_epoch(store: &TranquilBlockStore) { + store.apply_commit_blocking(vec![], vec![]).unwrap(); +} + +#[test] +fn oracle_deterministic_1000_blocks_multi_round() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(default_config(dir.path())).unwrap(); + let mut oracle = GcOracle::new(); + + let all_blocks: Vec<_> = (0u32..1000) + .map(|seed| { + oracle.put(seed); + (test_cid_u32(seed), block_data(seed)) + }) + .collect(); + all_blocks.chunks(50).for_each(|chunk| { + store.put_blocks_blocking(chunk.to_vec()).unwrap(); + }); + + verify_live_readable(&store, &oracle); + + let kill_round_1: Vec<_> = (0u32..1000) + .step_by(2) + .filter(|&seed| oracle.delete(seed)) + .map(test_cid_u32) + .collect(); + kill_round_1.chunks(100).for_each(|chunk| { + store.apply_commit_blocking(vec![], chunk.to_vec()).unwrap(); + }); + + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(10)); + + verify_no_live_in_dead(&store, &oracle); + verify_live_readable(&store, &oracle); + + let resurrect: Vec = (0u32..200).step_by(4).collect(); + resurrect.iter().for_each(|&seed| { + oracle.put(seed); + store + .put_blocks_blocking(vec![(test_cid_u32(seed), block_data(seed))]) + .unwrap(); + }); + + let kill_round_2: Vec<_> = (1u32..1000) + .step_by(4) + .filter(|&seed| oracle.delete(seed)) + .map(test_cid_u32) + .collect(); + kill_round_2.chunks(100).for_each(|chunk| { + store.apply_commit_blocking(vec![], chunk.to_vec()).unwrap(); + }); + + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(10)); + + verify_no_live_in_dead(&store, &oracle); + verify_live_readable(&store, &oracle); + + let live_count = oracle.live_seeds().len(); + let dead_count = oracle.dead_seeds().len(); + assert!( + live_count > 0 && dead_count > 0, + "test should exercise both live ({live_count}) and dead ({dead_count}) blocks" + ); + }); +} + +#[test] +fn oracle_deterministic_1000_blocks_reopen_survives() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let mut oracle = GcOracle::new(); + + { + let store = TranquilBlockStore::open(default_config(dir.path())).unwrap(); + + let all_blocks: Vec<_> = (0u32..1000) + .map(|seed| { + oracle.put(seed); + (test_cid_u32(seed), block_data(seed)) + }) + .collect(); + all_blocks.chunks(50).for_each(|chunk| { + store.put_blocks_blocking(chunk.to_vec()).unwrap(); + }); + + let kill: Vec<_> = (0u32..500) + .filter(|&seed| oracle.delete(seed)) + .map(test_cid_u32) + .collect(); + kill.chunks(100).for_each(|chunk| { + store.apply_commit_blocking(vec![], chunk.to_vec()).unwrap(); + }); + + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + let store = TranquilBlockStore::open(default_config(dir.path())).unwrap(); + verify_live_readable(&store, &oracle); + }); +} + +#[derive(Debug, Clone)] +enum Op { + Put(u32), + Delete(u32), + CompactAll, +} + +fn op_strategy() -> impl Strategy { + prop_oneof![ + 3 => (0u32..300).prop_map(Op::Put), + 2 => (0u32..300).prop_map(Op::Delete), + 1 => Just(Op::CompactAll), + ] +} + +fn run_oracle_scenario(ops: Vec) { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(small_config(dir.path())).unwrap(); + let mut oracle = GcOracle::new(); + + ops.iter().for_each(|op| match op { + Op::Put(seed) => { + oracle.put(*seed); + store + .put_blocks_blocking(vec![(test_cid_u32(*seed), block_data(*seed))]) + .unwrap(); + } + Op::Delete(seed) => { + if oracle.delete(*seed) { + store + .apply_commit_blocking(vec![], vec![test_cid_u32(*seed)]) + .unwrap(); + } + } + Op::CompactAll => { + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + compact_all_sealed(&store); + } + }); + + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + verify_live_readable(&store, &oracle); + verify_no_live_in_dead(&store, &oracle); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(50))] + + #[test] + fn proptest_oracle_random_operations(ops in prop::collection::vec(op_strategy(), 80..200)) { + with_runtime(|| { + run_oracle_scenario(ops); + }); + } +} + +#[test] +fn content_addressable_dedup_multi_repo() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(default_config(dir.path())).unwrap(); + + let shared_seeds: Vec = (0u32..50).collect(); + let repo_count: u32 = 5; + let mut oracle = GcOracle::new(); + + (0..repo_count).for_each(|_repo| { + let blocks: Vec<_> = shared_seeds + .iter() + .map(|&seed| { + oracle.put(seed); + (test_cid_u32(seed), block_data(seed)) + }) + .collect(); + store.put_blocks_blocking(blocks).unwrap(); + }); + + shared_seeds.iter().for_each(|&seed| { + assert_eq!(oracle.refcount(seed), repo_count); + }); + + (0..repo_count.saturating_sub(1)).for_each(|_repo| { + let deletes: Vec<_> = shared_seeds + .iter() + .filter(|&&seed| oracle.delete(seed)) + .map(|&seed| test_cid_u32(seed)) + .collect(); + store.apply_commit_blocking(vec![], deletes).unwrap(); + + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + verify_live_readable(&store, &oracle); + verify_no_live_in_dead(&store, &oracle); + }); + + shared_seeds.iter().for_each(|&seed| { + assert_eq!( + oracle.refcount(seed), + 1, + "after deleting {repo_count}-1 repos, refcount should be 1" + ); + }); + + verify_live_readable(&store, &oracle); + + let final_deletes: Vec<_> = shared_seeds + .iter() + .filter(|&&seed| oracle.delete(seed)) + .map(|&seed| test_cid_u32(seed)) + .collect(); + store.apply_commit_blocking(vec![], final_deletes).unwrap(); + + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + let dead = collect_all_dead(&store); + shared_seeds.iter().for_each(|&seed| { + assert!( + dead.contains(&test_cid_u32(seed)), + "fully dereferenced block seed={seed} must be dead" + ); + }); + }); +} + +#[test] +fn rapid_resurrection_cycling() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(default_config(dir.path())).unwrap(); + + let cid = test_cid_u32(42); + let data = block_data(42); + let cycles = 50u32; + + store + .put_blocks_blocking(vec![(cid, data.clone())]) + .unwrap(); + + (0..cycles).for_each(|_| { + store.apply_commit_blocking(vec![], vec![cid]).unwrap(); + + store + .put_blocks_blocking(vec![(cid, data.clone())]) + .unwrap(); + }); + + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + let dead = collect_all_dead(&store); + assert!( + !dead.contains(&cid), + "block that was resurrected must not be in dead candidates" + ); + + let read = store.get_block_sync(&cid).unwrap(); + assert!(read.is_some(), "resurrected block must be readable"); + assert_eq!(&read.unwrap()[..4], &data[..4]); + }); +} + +#[test] +fn epoch_boundary_strict_gating() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(default_config(dir.path())).unwrap(); + + let cid_a = test_cid_u32(1); + let cid_b = test_cid_u32(2); + store + .put_blocks_blocking(vec![(cid_a, block_data(1)), (cid_b, block_data(2))]) + .unwrap(); + + store.apply_commit_blocking(vec![], vec![cid_a]).unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(5)); + + let dead_same_epoch = store.collect_dead_blocks(0).unwrap(); + let dead_cids: HashSet = dead_same_epoch + .candidates + .values() + .flat_map(|v| v.iter().copied()) + .collect(); + + assert!( + !dead_cids.contains(&cid_b), + "cid_b was never deleted, must not be in dead candidates" + ); + + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + let dead_next = collect_all_dead(&store); + assert!( + dead_next.contains(&cid_a), + "cid_a must be collectible after epoch advances past its deletion epoch" + ); + assert!( + !dead_next.contains(&cid_b), + "cid_b was never deleted, still must not be dead" + ); + }); +} + +#[test] +fn multi_round_compaction_relay() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(small_config(dir.path())).unwrap(); + + let survivors: Vec = vec![10, 11, 12]; + let victims_r1: Vec = vec![20, 21]; + let all_r1: Vec<_> = survivors + .iter() + .chain(victims_r1.iter()) + .map(|&s| (test_cid_u32(s), block_data(s))) + .collect(); + store.put_blocks_blocking(all_r1).unwrap(); + + let padding_r1: Vec<_> = (5000u32..5040) + .map(|s| (test_cid_u32(s), vec![0xAAu8; 512])) + .collect(); + store.put_blocks_blocking(padding_r1).unwrap(); + + let del_r1: Vec<_> = victims_r1.iter().map(|&s| test_cid_u32(s)).collect(); + store.apply_commit_blocking(vec![], del_r1).unwrap(); + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + let files_before = store.list_data_files().unwrap(); + let sealed_r1: Vec = files_before + .iter() + .copied() + .take(files_before.len().saturating_sub(1)) + .collect(); + sealed_r1.iter().for_each(|&fid| { + let info = store.liveness_info(fid).unwrap(); + if info.ratio() < 1.0 && info.total_blocks > 0 { + store.compact_file(fid, 0).ok(); + } + }); + + survivors.iter().for_each(|&seed| { + let data = store.get_block_sync(&test_cid_u32(seed)).unwrap(); + assert!( + data.is_some(), + "survivor seed={seed} must be readable after round 1 compaction" + ); + }); + + let padding_r2: Vec<_> = (6000u32..6040) + .map(|s| (test_cid_u32(s), vec![0xBBu8; 512])) + .collect(); + store.put_blocks_blocking(padding_r2).unwrap(); + + let victims_r2: Vec = vec![12]; + let del_r2: Vec<_> = victims_r2.iter().map(|&s| test_cid_u32(s)).collect(); + store.apply_commit_blocking(vec![], del_r2).unwrap(); + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + let files_r2 = store.list_data_files().unwrap(); + let sealed_r2: Vec = files_r2 + .iter() + .copied() + .take(files_r2.len().saturating_sub(1)) + .collect(); + sealed_r2.iter().for_each(|&fid| { + let info = store.liveness_info(fid).unwrap(); + if info.ratio() < 1.0 && info.total_blocks > 0 { + store.compact_file(fid, 0).ok(); + } + }); + + [10u32, 11].iter().for_each(|&seed| { + let data = store.get_block_sync(&test_cid_u32(seed)).unwrap(); + assert!( + data.is_some(), + "double-relocated survivor seed={seed} must be readable after round 2" + ); + assert_eq!(&data.unwrap()[..4], &seed.to_le_bytes()); + }); + + let data_12 = store.get_block_sync(&test_cid_u32(12)).unwrap(); + assert!( + data_12.is_none(), + "block 12 was deleted and compacted, should not be readable" + ); + }); +} + +#[test] +fn all_dead_file_compaction() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(small_config(dir.path())).unwrap(); + + let blocks: Vec<_> = (0u32..5) + .map(|s| (test_cid_u32(s), block_data(s))) + .collect(); + store.put_blocks_blocking(blocks).unwrap(); + + let padding: Vec<_> = (9000u32..9020) + .map(|s| (test_cid_u32(s), vec![0xFFu8; 512])) + .collect(); + store.put_blocks_blocking(padding).unwrap(); + + let files = store.list_data_files().unwrap(); + let first_file = files[0]; + + let info_before = store.liveness_info(first_file).unwrap(); + assert!(info_before.total_blocks > 0); + + let kill_all: Vec<_> = (0u32..5).map(test_cid_u32).collect(); + store.apply_commit_blocking(vec![], kill_all).unwrap(); + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + let result = store.compact_file(first_file, 0).unwrap(); + assert_eq!(result.live_blocks, 0); + assert!(result.dead_blocks > 0); + + (0u32..5).for_each(|seed| { + let data = store.get_block_sync(&test_cid_u32(seed)).unwrap(); + assert!( + data.is_none(), + "fully dead block seed={seed} should not be readable after compaction" + ); + }); + }); +} + +#[test] +fn multi_reference_partial_decrement() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(default_config(dir.path())).unwrap(); + + let cid = test_cid_u32(77); + let data = block_data(77); + + (0..5).for_each(|_| { + store + .put_blocks_blocking(vec![(cid, data.clone())]) + .unwrap(); + }); + + (0..4).for_each(|_| { + store.apply_commit_blocking(vec![], vec![cid]).unwrap(); + }); + + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + let dead = collect_all_dead(&store); + assert!( + !dead.contains(&cid), + "block with refcount=1 must not be in dead candidates" + ); + + let read = store.get_block_sync(&cid).unwrap(); + assert!(read.is_some(), "block with refcount=1 must be readable"); + + store.apply_commit_blocking(vec![], vec![cid]).unwrap(); + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + let dead = collect_all_dead(&store); + assert!( + dead.contains(&cid), + "block decremented to zero must be in dead candidates" + ); + }); +} + +#[test] +fn compaction_preserves_blocks_across_reopen() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let mut oracle = GcOracle::new(); + + { + let store = TranquilBlockStore::open(small_config(dir.path())).unwrap(); + + let blocks: Vec<_> = (0u32..200) + .map(|seed| { + oracle.put(seed); + (test_cid_u32(seed), block_data(seed)) + }) + .collect(); + blocks.chunks(20).for_each(|chunk| { + store.put_blocks_blocking(chunk.to_vec()).unwrap(); + }); + + let kill: Vec<_> = (0u32..200) + .step_by(3) + .filter(|&seed| oracle.delete(seed)) + .map(test_cid_u32) + .collect(); + store.apply_commit_blocking(vec![], kill).unwrap(); + + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(10)); + compact_all_sealed(&store); + + verify_live_readable(&store, &oracle); + } + + { + let store = TranquilBlockStore::open(small_config(dir.path())).unwrap(); + verify_live_readable(&store, &oracle); + + let kill_2: Vec<_> = (1u32..200) + .step_by(5) + .filter(|&seed| oracle.delete(seed)) + .map(test_cid_u32) + .collect(); + store.apply_commit_blocking(vec![], kill_2).unwrap(); + + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(10)); + compact_all_sealed(&store); + } + + { + let store = TranquilBlockStore::open(small_config(dir.path())).unwrap(); + verify_live_readable(&store, &oracle); + } + }); +} + +#[test] +fn resurrection_clears_gc_meta_atomically() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(default_config(dir.path())).unwrap(); + + let seeds: Vec = (0u32..20).collect(); + let blocks: Vec<_> = seeds + .iter() + .map(|&s| (test_cid_u32(s), block_data(s))) + .collect(); + store.put_blocks_blocking(blocks).unwrap(); + + let delete_cids: Vec<_> = seeds.iter().map(|&s| test_cid_u32(s)).collect(); + store.apply_commit_blocking(vec![], delete_cids).unwrap(); + + let resurrect: Vec<_> = (0u32..10) + .map(|s| (test_cid_u32(s), block_data(s))) + .collect(); + store.put_blocks_blocking(resurrect).unwrap(); + + let cleaned = store.cleanup_gc_meta().unwrap(); + assert_eq!( + cleaned, 0, + "batch_put already clears gc_meta on resurrection, nothing stale" + ); + + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + let dead = collect_all_dead(&store); + (0u32..10).for_each(|seed| { + assert!( + !dead.contains(&test_cid_u32(seed)), + "resurrected block seed={seed} must not be dead" + ); + }); + (10u32..20).for_each(|seed| { + assert!( + dead.contains(&test_cid_u32(seed)), + "non-resurrected block seed={seed} must be dead" + ); + }); + }); +} + +#[test] +fn batch_put_and_delete_same_cid_single_commit() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(default_config(dir.path())).unwrap(); + + let cid = test_cid_u32(99); + let data = block_data(99); + + store + .apply_commit_blocking(vec![(cid, data.clone())], vec![cid]) + .unwrap(); + + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + let dead = collect_all_dead(&store); + assert!( + dead.contains(&cid), + "block put then immediately deleted in same commit should be dead" + ); + }); +} + +#[test] +fn concurrent_compactions_different_files() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(small_config(dir.path())).unwrap(); + + let blocks: Vec<_> = (0u32..100) + .map(|s| (test_cid_u32(s), block_data(s))) + .collect(); + blocks.chunks(10).for_each(|chunk| { + store.put_blocks_blocking(chunk.to_vec()).unwrap(); + }); + + let kill: Vec<_> = (0u32..100).step_by(2).map(test_cid_u32).collect(); + store.apply_commit_blocking(vec![], kill).unwrap(); + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(10)); + + let files = store.list_data_files().unwrap(); + let sealed: Vec = files + .iter() + .copied() + .take(files.len().saturating_sub(1)) + .collect(); + + let store_a = store.clone(); + let store_b = store.clone(); + let sealed_a: Vec<_> = sealed.iter().step_by(2).copied().collect(); + let sealed_b: Vec<_> = sealed.iter().skip(1).step_by(2).copied().collect(); + + let thread_a = std::thread::spawn(move || { + sealed_a.iter().for_each(|&fid| { + store_a.compact_file(fid, 0).ok(); + }); + }); + let thread_b = std::thread::spawn(move || { + sealed_b.iter().for_each(|&fid| { + store_b.compact_file(fid, 0).ok(); + }); + }); + + thread_a.join().unwrap(); + thread_b.join().unwrap(); + + (1u32..100).step_by(2).for_each(|seed| { + let data = store.get_block_sync(&test_cid_u32(seed)).unwrap(); + assert!( + data.is_some(), + "odd-seeded block {seed} (live) must survive concurrent compaction" + ); + assert_eq!(&data.unwrap()[..4], &seed.to_le_bytes()); + }); + }); +} + +#[test] +fn grace_period_prevents_collection_during_active_write() { + with_runtime(|| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(small_config(dir.path())).unwrap(); + + let cid = test_cid_u32(1); + let data = block_data(1); + store.put_blocks_blocking(vec![(cid, data)]).unwrap(); + + store.apply_commit_blocking(vec![], vec![cid]).unwrap(); + + let padding: Vec<_> = (8000u32..8020) + .map(|s| (test_cid_u32(s), vec![0xCCu8; 512])) + .collect(); + store.put_blocks_blocking(padding).unwrap(); + + advance_epoch(&store); + + let files = store.list_data_files().unwrap(); + let first_file = files[0]; + let result = store.compact_file(first_file, 600_000).unwrap(); + + assert_eq!( + result.dead_blocks, 0, + "grace period should prevent any collection" + ); + + let read = store.get_block_sync(&cid).unwrap(); + assert!( + read.is_some(), + "block within grace period must survive compaction" + ); + }); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(20))] + + #[test] + fn proptest_dedup_stress( + ref_counts in prop::collection::vec(1u32..8, 20..60), + kill_pattern in prop::collection::vec(prop::bool::ANY, 20..60), + ) { + with_runtime(|| { + let len = ref_counts.len().min(kill_pattern.len()); + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(small_config(dir.path())).unwrap(); + let mut oracle = GcOracle::new(); + + (0..len).for_each(|i| { + let seed = i as u32; + let count = ref_counts[i]; + (0..count).for_each(|_| { + oracle.put(seed); + store + .put_blocks_blocking(vec![(test_cid_u32(seed), block_data(seed))]) + .unwrap(); + }); + }); + + (0..len).for_each(|i| { + let seed = i as u32; + if kill_pattern[i] { + let rc = oracle.refcount(seed); + (0..rc).for_each(|_| { + if oracle.delete(seed) { + store + .apply_commit_blocking(vec![], vec![test_cid_u32(seed)]) + .unwrap(); + } + }); + } + }); + + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(10)); + + compact_all_sealed(&store); + verify_live_readable(&store, &oracle); + verify_no_live_in_dead(&store, &oracle); + }); + } +} diff --git a/crates/tranquil-store/tests/metastore_crash.rs b/crates/tranquil-store/tests/metastore_crash.rs new file mode 100644 index 0000000..8778167 --- /dev/null +++ b/crates/tranquil-store/tests/metastore_crash.rs @@ -0,0 +1,372 @@ +use std::path::Path; + +use proptest::prelude::*; +use rayon::prelude::*; +use tranquil_store::metastore::recovery::{ + BacklinkMutation, CommitMutationSet, RecordMutationDelete, RecordMutationUpsert, +}; +use tranquil_store::metastore::{Metastore, MetastoreConfig}; +use tranquil_store::{sim_proptest_cases, sim_seed_range}; +use tranquil_types::{CidLink, Did, Handle}; +use uuid::Uuid; + +const NAMES: &[&str] = &["olaren", "teq", "nel", "lyna", "bailey"]; + +fn test_config() -> MetastoreConfig { + MetastoreConfig { + cache_size_bytes: 16 * 1024 * 1024, + } +} + +fn open_metastore(path: &Path) -> Metastore { + Metastore::open(path, test_config()).unwrap() +} + +fn test_did(seed: u64) -> Did { + let name = NAMES[(seed as usize) % NAMES.len()]; + Did::from(format!("did:plc:{name}{seed}")) +} + +fn test_handle(seed: u64) -> Handle { + let name = NAMES[(seed as usize) % NAMES.len()]; + Handle::new(format!("{name}{seed}.test")).unwrap() +} + +fn test_cid_link(seed: u8) -> CidLink { + let digest: [u8; 32] = std::array::from_fn(|i| seed.wrapping_add(i as u8)); + let mh = multihash::Multihash::<64>::wrap(0x12, &digest).unwrap(); + let c = cid::Cid::new_v1(0x71, mh); + CidLink::from_cid(&c) +} + +fn test_uuid(seed: u64) -> Uuid { + Uuid::from_u128(seed as u128 | 0x4000_0000_0000_0000_8000_0000_0000_0000) +} + +fn arb_mutation_set() -> impl Strategy { + let arb_upsert = ( + "[a-z\\.]{5,20}", + "[a-z0-9]{3,10}", + prop::collection::vec(any::(), 4..36), + ) + .prop_map(|(collection, rkey, cid_bytes)| RecordMutationUpsert { + collection, + rkey, + cid_bytes, + }); + + let arb_delete = ("[a-z\\.]{5,20}", "[a-z0-9]{3,10}") + .prop_map(|(collection, rkey)| RecordMutationDelete { collection, rkey }); + + let arb_backlink = ( + "at://did:plc:[a-z]{3,8}/[a-z\\.]{5,20}/[a-z0-9]{3,8}", + 0u8..4, + "at://did:plc:[a-z]{3,8}/[a-z\\.]{5,20}/[a-z0-9]{3,8}", + ) + .prop_map(|(uri, path, link_to)| BacklinkMutation { uri, path, link_to }); + + ( + prop::collection::vec(any::(), 0..64), + "[a-z0-9]{1,16}", + prop::collection::vec(arb_upsert, 0..20), + prop::collection::vec(arb_delete, 0..20), + prop::collection::vec(prop::collection::vec(any::(), 4..36), 0..20), + prop::collection::vec(prop::collection::vec(any::(), 4..36), 0..20), + prop::collection::vec(arb_backlink, 0..5), + prop::collection::vec("at://did:plc:[a-z]{3,8}/[a-z\\.]{5,20}/[a-z0-9]{3,8}", 0..5), + ) + .prop_map( + |( + new_root_cid, + new_rev, + record_upserts, + record_deletes, + block_inserts, + block_deletes, + backlink_adds, + backlink_remove_uris, + )| { + CommitMutationSet { + new_root_cid, + new_rev, + record_upserts, + record_deletes, + block_inserts, + block_deletes, + backlink_adds, + backlink_remove_uris, + } + }, + ) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(sim_proptest_cases()))] + + #[test] + fn mutation_set_roundtrip_fuzz(ms in arb_mutation_set()) { + let serialized = ms.serialize().unwrap(); + let recovered = CommitMutationSet::deserialize(&serialized).unwrap(); + prop_assert_eq!(recovered, ms); + } + + #[test] + fn mutation_set_rejects_corrupt_version(ms in arb_mutation_set()) { + let mut bytes = ms.serialize().unwrap(); + bytes[0] = 0xFF; + prop_assert!(CommitMutationSet::deserialize(&bytes).is_none()); + } + + #[test] + fn mutation_set_truncation_detected( + ms in arb_mutation_set(), + truncate_at in 0usize..64, + ) { + let bytes = ms.serialize().unwrap(); + let cut = truncate_at.min(bytes.len().saturating_sub(1)); + match cut { + 0 => prop_assert!(CommitMutationSet::deserialize(&bytes[..0]).is_none()), + n => { + let truncated = &bytes[..n]; + match CommitMutationSet::deserialize(truncated) { + None => {} + Some(recovered) => prop_assert_eq!(recovered, ms), + } + } + } + } +} + +#[test] +fn metastore_survives_abrupt_drop() { + sim_seed_range().into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let user_count = (seed % 5) + 1; + + let user_ids: Vec = (0..user_count).map(|i| test_uuid(seed * 100 + i)).collect(); + + { + let ms = open_metastore(dir.path()); + let repo_ops = ms.repo_ops(); + let db = ms.database(); + + user_ids.iter().enumerate().for_each(|(i, &uid)| { + let idx = seed * 100 + i as u64; + let did = test_did(idx); + let handle = test_handle(idx); + let cid = test_cid_link((idx & 0xFF) as u8); + let rev = format!("rev{idx}"); + repo_ops + .create_repo(db, uid, &did, &handle, &cid, &rev) + .unwrap(); + }); + } + + { + let ms = open_metastore(dir.path()); + let repo_ops = ms.repo_ops(); + + user_ids.iter().enumerate().for_each(|(i, &uid)| { + let result = repo_ops.get_repo_meta(uid).unwrap(); + assert!( + result.is_some(), + "seed={seed} user {i} repo_meta missing after abrupt drop" + ); + let (_, meta) = result.unwrap(); + let expected_rev = format!("rev{}", seed * 100 + i as u64); + assert_eq!( + meta.repo_rev, expected_rev, + "seed={seed} user {i} rev mismatch" + ); + }); + } + }); +} + +#[test] +fn metastore_multi_crash_cycle() { + sim_seed_range().into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let cycles = (seed % 4) + 2; + let mut expected_repos: Vec<(Uuid, u64)> = Vec::new(); + + (0..cycles).for_each(|cycle| { + let new_per_cycle = (seed.wrapping_add(cycle) % 3) + 1; + + { + let ms = open_metastore(dir.path()); + + expected_repos.iter().for_each(|(uid, idx)| { + let meta = ms.repo_ops().get_repo_meta(*uid).unwrap(); + assert!( + meta.is_some(), + "seed={seed} cycle={cycle} user idx={idx} missing before new writes" + ); + }); + + let repo_ops = ms.repo_ops(); + let db = ms.database(); + (0..new_per_cycle).for_each(|i| { + let idx = seed * 1000 + cycle * 100 + i; + let uid = test_uuid(idx); + let did = test_did(idx); + let handle = test_handle(idx); + let cid = test_cid_link((idx & 0xFF) as u8); + repo_ops + .create_repo(db, uid, &did, &handle, &cid, &format!("rev{idx}")) + .unwrap(); + expected_repos.push((uid, idx)); + }); + } + }); + + { + let ms = open_metastore(dir.path()); + expected_repos.iter().for_each(|(uid, idx)| { + let meta = ms.repo_ops().get_repo_meta(*uid).unwrap(); + assert!( + meta.is_some(), + "seed={seed} final verify: user idx={idx} missing" + ); + }); + } + }); +} + +#[test] +fn metastore_persisted_survives_unpersisted_lost() { + sim_seed_range().into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let batch_size = (seed % 5) + 2; + + { + let ms = open_metastore(dir.path()); + let db = ms.database(); + let repo_ops = ms.repo_ops(); + + (0..batch_size).for_each(|i| { + let idx = seed * 100 + i; + repo_ops + .create_repo( + db, + test_uuid(idx), + &test_did(idx), + &test_handle(idx), + &test_cid_link((idx & 0xFF) as u8), + &format!("rev{idx}"), + ) + .unwrap(); + }); + + ms.persist().unwrap(); + + let extra_idx = seed * 100 + batch_size; + repo_ops + .create_repo( + db, + test_uuid(extra_idx), + &test_did(extra_idx), + &test_handle(extra_idx), + &test_cid_link((extra_idx & 0xFF) as u8), + &format!("rev{extra_idx}"), + ) + .unwrap(); + } + + { + let ms = open_metastore(dir.path()); + let repo_ops = ms.repo_ops(); + + (0..batch_size).for_each(|i| { + let idx = seed * 100 + i; + let meta = repo_ops.get_repo_meta(test_uuid(idx)).unwrap(); + assert!( + meta.is_some(), + "seed={seed} persisted user idx={idx} must survive crash" + ); + }); + } + }); +} + +#[test] +fn metastore_user_hashes_reload_after_crash() { + sim_seed_range().into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let count = (seed % 5) + 1; + let name = NAMES[(seed as usize) % NAMES.len()]; + + let entries: Vec<(Uuid, u64)> = (0..count) + .map(|i| { + let idx = seed * 100 + i; + (test_uuid(idx), idx) + }) + .collect(); + + { + let ms = open_metastore(dir.path()); + let db = ms.database(); + let repo_ops = ms.repo_ops(); + + entries.iter().for_each(|&(uid, idx)| { + repo_ops + .create_repo( + db, + uid, + &test_did(idx), + &test_handle(idx), + &test_cid_link((idx & 0xFF) as u8), + &format!("rev{idx}"), + ) + .unwrap(); + }); + } + + { + let ms = open_metastore(dir.path()); + + entries.iter().for_each(|&(uid, idx)| { + let hash = ms.user_hashes().get(&uid); + assert!( + hash.is_some(), + "seed={seed} name={name} user_hash for idx={idx} not reloaded after crash" + ); + }); + } + }); +} + +#[test] +fn metastore_handle_lookup_survives_crash() { + sim_seed_range().into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let idx = seed; + let uid = test_uuid(idx); + let did = test_did(idx); + let handle = test_handle(idx); + + { + let ms = open_metastore(dir.path()); + ms.repo_ops() + .create_repo( + ms.database(), + uid, + &did, + &handle, + &test_cid_link((idx & 0xFF) as u8), + &format!("rev{idx}"), + ) + .unwrap(); + } + + { + let ms = open_metastore(dir.path()); + let resolved = ms.repo_ops().lookup_handle(&handle).unwrap(); + assert!( + resolved.is_some(), + "seed={seed} handle '{}' not resolvable after crash", + handle.as_str() + ); + } + }); +} diff --git a/crates/tranquil-store/tests/mst_integration.rs b/crates/tranquil-store/tests/mst_integration.rs index 07c549e..818ade0 100644 --- a/crates/tranquil-store/tests/mst_integration.rs +++ b/crates/tranquil-store/tests/mst_integration.rs @@ -1,8 +1,11 @@ +mod common; + use std::collections::BTreeMap; use std::sync::Arc; use bytes::Bytes; use cid::Cid; +use common::{advance_epoch, compact_all_sealed}; use futures::StreamExt; use jacquard_common::types::string::Did; use jacquard_common::types::tid::Ticker; @@ -26,6 +29,7 @@ fn test_config(dir: &std::path::Path) -> BlockStoreConfig { index_dir: dir.join("index"), max_file_size: DEFAULT_MAX_FILE_SIZE, group_commit: GroupCommitConfig::default(), + shard_count: 1, } } @@ -302,3 +306,584 @@ async fn mst_create_update_delete_with_refcounts() { }) .await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mst_survives_gc_after_structural_mutations() { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(BlockStoreConfig { + data_dir: dir.path().join("data"), + index_dir: dir.path().join("index"), + max_file_size: 64 * 1024, + group_commit: GroupCommitConfig::default(), + shard_count: 1, + }) + .unwrap(); + let storage = Arc::new(store.clone()); + + let record_count = 5_000u32; + let records: Vec<(String, Vec)> = (0..record_count) + .map(|i| { + let key = format!("app.bsky.feed.post/{i:010}"); + let data = make_record(&format!("post {i} padding {}", "x".repeat(64))); + (key, data) + }) + .collect(); + + let mut mst = Mst::new(storage.clone()); + let mut record_cids: BTreeMap = BTreeMap::new(); + + for (key, data) in &records { + let cid = storage.put(data).await.unwrap(); + record_cids.insert(key.clone(), cid); + mst = mst.add(key, cid).await.unwrap(); + } + + let mst_root_v1 = mst.persist().await.unwrap(); + + let signing_key = test_signing_key(); + let did = Did::new("did:plc:testgcmst").unwrap(); + let mut ticker = Ticker::new(); + let rev1 = ticker.next(None); + + let commit_v1 = Commit::new_unsigned(did.clone(), mst_root_v1, rev1.clone(), None) + .sign(&signing_key) + .unwrap(); + let commit_v1_cbor = commit_v1.to_cbor().unwrap(); + let commit_v1_cid = compute_cid(&commit_v1_cbor); + + let empty_mst = Mst::new(storage.clone()); + let diff_v1 = empty_mst.diff(&mst).await.unwrap(); + let mut blocks_v1 = diff_v1.new_mst_blocks.clone(); + blocks_v1.insert(commit_v1_cid, Bytes::from(commit_v1_cbor)); + + store + .apply_commit(CommitData { + cid: commit_v1_cid, + rev: rev1.clone(), + since: None, + prev: None, + data: mst_root_v1, + prev_data: None, + blocks: blocks_v1, + relevant_blocks: BTreeMap::new(), + deleted_cids: Vec::new(), + }) + .await + .unwrap(); + + let delete_indices: Vec = (0..record_count) + .filter(|i| i % 3 == 0 || i % 7 == 0) + .collect(); + let keep_indices: Vec = (0..record_count) + .filter(|i| i % 3 != 0 && i % 7 != 0) + .collect(); + + let old_mst = mst.clone(); + for &i in &delete_indices { + let key = format!("app.bsky.feed.post/{i:010}"); + mst = mst.delete(&key).await.unwrap(); + } + + let mst_root_v2 = mst.persist().await.unwrap(); + let diff_v2 = old_mst.diff(&mst).await.unwrap(); + + let rev2 = ticker.next(Some(rev1.clone())); + let commit_v2 = + Commit::new_unsigned(did.clone(), mst_root_v2, rev2.clone(), Some(commit_v1_cid)) + .sign(&signing_key) + .unwrap(); + let commit_v2_cbor = commit_v2.to_cbor().unwrap(); + let commit_v2_cid = compute_cid(&commit_v2_cbor); + + let mut blocks_v2 = diff_v2.new_mst_blocks.clone(); + blocks_v2.insert(commit_v2_cid, Bytes::from(commit_v2_cbor)); + + let mut deleted: Vec = diff_v2.removed_mst_blocks.clone(); + deleted.extend(diff_v2.removed_cids.iter()); + + store + .apply_commit(CommitData { + cid: commit_v2_cid, + rev: rev2.clone(), + since: Some(rev1.clone()), + prev: Some(commit_v1_cid), + data: mst_root_v2, + prev_data: Some(mst_root_v1), + blocks: blocks_v2, + relevant_blocks: BTreeMap::new(), + deleted_cids: deleted, + }) + .await + .unwrap(); + + let gc_store = store.clone(); + tokio::task::spawn_blocking(move || { + advance_epoch(&gc_store); + std::thread::sleep(std::time::Duration::from_millis(10)); + advance_epoch(&gc_store); + compact_all_sealed(&gc_store); + advance_epoch(&gc_store); + std::thread::sleep(std::time::Duration::from_millis(10)); + compact_all_sealed(&gc_store); + }) + .await + .unwrap(); + + let post_gc_mst = Mst::load(storage.clone(), mst_root_v2, None); + + let surviving_leaves = post_gc_mst.leaves().await.unwrap(); + assert_eq!( + surviving_leaves.len(), + keep_indices.len(), + "leaf count mismatch after GC: expected {} surviving records, got {}", + keep_indices.len(), + surviving_leaves.len() + ); + + futures::stream::iter(&keep_indices) + .for_each(|&i| { + let post_gc_mst = &post_gc_mst; + let record_cids = &record_cids; + let storage = &storage; + async move { + let key = format!("app.bsky.feed.post/{i:010}"); + let expected_cid = record_cids[&key]; + let found = post_gc_mst + .get(&key) + .await + .unwrap_or_else(|e| panic!("MST traversal failed for {key} after GC: {e}")); + assert_eq!( + found, + Some(expected_cid), + "record {key} missing from MST after GC compaction" + ); + + let block = storage + .get(&expected_cid) + .await + .unwrap_or_else(|e| panic!("block read failed for {key} (cid={expected_cid}) after GC: {e}")); + assert!( + block.is_some(), + "record block for {key} (cid={expected_cid}) was collected by GC despite being reachable" + ); + } + }) + .await; + + futures::stream::iter(&delete_indices) + .for_each(|&i| { + let post_gc_mst = &post_gc_mst; + async move { + let key = format!("app.bsky.feed.post/{i:010}"); + let found = post_gc_mst.get(&key).await.unwrap(); + assert_eq!( + found, None, + "deleted record {key} still present in MST after GC" + ); + } + }) + .await; + + let all_node_cids = post_gc_mst.collect_node_cids().await.unwrap(); + futures::stream::iter(all_node_cids) + .for_each(|node_cid| { + let storage = &storage; + async move { + let block = storage.get(&node_cid).await.unwrap(); + assert!( + block.is_some(), + "MST internal node {node_cid} was collected by GC despite being reachable from root" + ); + } + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mst_survives_multiple_mutation_gc_cycles() { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(BlockStoreConfig { + data_dir: dir.path().join("data"), + index_dir: dir.path().join("index"), + max_file_size: 64 * 1024, + group_commit: GroupCommitConfig::default(), + shard_count: 1, + }) + .unwrap(); + let storage = Arc::new(store.clone()); + + let collections = [ + "app.bsky.feed.post", + "app.bsky.feed.like", + "app.bsky.feed.repost", + "app.bsky.graph.follow", + "app.bsky.graph.block", + ]; + let per_collection = 600u32; + let records: Vec<(String, Vec)> = collections + .iter() + .flat_map(|col| { + (0..per_collection).map(move |i| { + let key = format!("{col}/{i:010}"); + let data = make_record(&format!("{col} record {i} padding {}", "y".repeat(64))); + (key, data) + }) + }) + .collect(); + + let mut mst = Mst::new(storage.clone()); + let mut live_cids: BTreeMap = BTreeMap::new(); + + for (key, data) in &records { + let cid = storage.put(data).await.unwrap(); + live_cids.insert(key.clone(), cid); + mst = mst.add(key, cid).await.unwrap(); + } + + let mst_root = mst.persist().await.unwrap(); + + let signing_key = test_signing_key(); + let did = Did::new("did:plc:testcycles").unwrap(); + let mut ticker = Ticker::new(); + let rev = ticker.next(None); + + let commit = Commit::new_unsigned(did.clone(), mst_root, rev.clone(), None) + .sign(&signing_key) + .unwrap(); + let commit_cbor = commit.to_cbor().unwrap(); + let commit_cid = compute_cid(&commit_cbor); + + let empty_mst = Mst::new(storage.clone()); + let diff = empty_mst.diff(&mst).await.unwrap(); + let mut blocks = diff.new_mst_blocks.clone(); + blocks.insert(commit_cid, Bytes::from(commit_cbor)); + + store + .apply_commit(CommitData { + cid: commit_cid, + rev: rev.clone(), + since: None, + prev: None, + data: mst_root, + prev_data: None, + blocks, + relevant_blocks: BTreeMap::new(), + deleted_cids: Vec::new(), + }) + .await + .unwrap(); + + let mut prev_rev = rev; + let mut prev_cid = commit_cid; + let mut prev_root = mst_root; + + let cycles = 15u32; + let mut cycle_seed = 0u32; + + for cycle in 0..cycles { + let old_mst = mst.clone(); + + let delete_keys: Vec = live_cids + .keys() + .enumerate() + .filter(|(idx, _)| { + let hash = (*idx as u32) + .wrapping_mul(2654435761) + .wrapping_add(cycle_seed); + hash.is_multiple_of(5) + }) + .map(|(_, k)| k.clone()) + .collect(); + + for key in &delete_keys { + mst = mst.delete(key).await.unwrap(); + live_cids.remove(key); + } + + let add_count = delete_keys.len().min(200); + let new_records: Vec<(String, Vec)> = (0..add_count) + .map(|i| { + cycle_seed = cycle_seed.wrapping_add(1); + let key = format!("app.bsky.feed.post/new_{cycle}_{i:06}"); + let data = make_record(&format!("new record cycle {cycle} item {i}")); + (key, data) + }) + .collect(); + + for (key, data) in &new_records { + let cid = storage.put(data).await.unwrap(); + live_cids.insert(key.clone(), cid); + mst = mst.add(key, cid).await.unwrap(); + } + + let new_root = mst.persist().await.unwrap(); + let diff = old_mst.diff(&mst).await.unwrap(); + + let rev = ticker.next(Some(prev_rev.clone())); + let commit = Commit::new_unsigned(did.clone(), new_root, rev.clone(), Some(prev_cid)) + .sign(&signing_key) + .unwrap(); + let commit_cbor = commit.to_cbor().unwrap(); + let new_commit_cid = compute_cid(&commit_cbor); + + let mut commit_blocks = diff.new_mst_blocks.clone(); + commit_blocks.insert(new_commit_cid, Bytes::from(commit_cbor)); + new_records.iter().for_each(|(_, data)| { + let cid = compute_cid(data); + commit_blocks.insert(cid, Bytes::from(data.clone())); + }); + + let mut deleted: Vec = diff.removed_mst_blocks.clone(); + deleted.extend(diff.removed_cids.iter()); + + store + .apply_commit(CommitData { + cid: new_commit_cid, + rev: rev.clone(), + since: Some(prev_rev.clone()), + prev: Some(prev_cid), + data: new_root, + prev_data: Some(prev_root), + blocks: commit_blocks, + relevant_blocks: BTreeMap::new(), + deleted_cids: deleted, + }) + .await + .unwrap(); + + let gc_store = store.clone(); + tokio::task::spawn_blocking(move || { + advance_epoch(&gc_store); + std::thread::sleep(std::time::Duration::from_millis(10)); + advance_epoch(&gc_store); + compact_all_sealed(&gc_store); + }) + .await + .unwrap(); + + prev_rev = rev; + prev_cid = new_commit_cid; + prev_root = new_root; + cycle_seed = cycle_seed.wrapping_add(7); + } + + let final_mst = Mst::load(storage.clone(), prev_root, None); + + let final_leaves = final_mst.leaves().await.unwrap(); + assert_eq!( + final_leaves.len(), + live_cids.len(), + "after {cycles} mutation+GC cycles: expected {} leaves, got {}", + live_cids.len(), + final_leaves.len() + ); + + futures::stream::iter(live_cids.iter()) + .for_each(|(key, expected_cid)| { + let final_mst = &final_mst; + let storage = &storage; + let expected_cid = *expected_cid; + async move { + let found = final_mst.get(key.as_str()).await.unwrap_or_else(|e| { + panic!("MST traversal failed for {key} after {cycles} GC cycles: {e}") + }); + assert_eq!( + found, + Some(expected_cid), + "record {key} missing from MST after {cycles} mutation+GC cycles" + ); + + let block = storage.get(&expected_cid).await.unwrap(); + assert!( + block.is_some(), + "record block for {key} (cid={expected_cid}) was collected by GC" + ); + } + }) + .await; + + let all_node_cids = final_mst.collect_node_cids().await.unwrap(); + futures::stream::iter(all_node_cids) + .for_each(|node_cid| { + let storage = &storage; + async move { + let block = storage.get(&node_cid).await.unwrap(); + assert!( + block.is_some(), + "MST internal node {node_cid} was collected by GC after {cycles} cycles" + ); + } + }) + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mst_survives_gc_with_split_batches_and_tiny_files() { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(BlockStoreConfig { + data_dir: dir.path().join("data"), + index_dir: dir.path().join("index"), + max_file_size: 4096, + group_commit: GroupCommitConfig::default(), + shard_count: 1, + }) + .unwrap(); + let storage = Arc::new(store.clone()); + + let record_count = 50u32; + let records: Vec<(String, Vec)> = (0..record_count) + .map(|i| { + let key = format!("app.bsky.feed.post/{i:010}"); + let data = make_record(&format!("post {i} padding {}", "x".repeat(64))); + (key, data) + }) + .collect(); + + let mut mst = Mst::new(storage.clone()); + let mut record_cids: BTreeMap = BTreeMap::new(); + + for (key, data) in &records { + let cid = storage.put(data).await.unwrap(); + record_cids.insert(key.clone(), cid); + mst = mst.add(key, cid).await.unwrap(); + } + + let mst_root_v1 = mst.persist().await.unwrap(); + + let signing_key = test_signing_key(); + let did = Did::new("did:plc:testsplitbatch").unwrap(); + let mut ticker = Ticker::new(); + let rev1 = ticker.next(None); + + let commit_v1 = Commit::new_unsigned(did.clone(), mst_root_v1, rev1.clone(), None) + .sign(&signing_key) + .unwrap(); + let commit_v1_cbor = commit_v1.to_cbor().unwrap(); + let commit_v1_cid = compute_cid(&commit_v1_cbor); + + let empty_mst = Mst::new(storage.clone()); + let diff_v1 = empty_mst.diff(&mst).await.unwrap(); + let mut blocks_v1 = diff_v1.new_mst_blocks.clone(); + blocks_v1.insert(commit_v1_cid, Bytes::from(commit_v1_cbor)); + + store + .apply_commit(CommitData { + cid: commit_v1_cid, + rev: rev1.clone(), + since: None, + prev: None, + data: mst_root_v1, + prev_data: None, + blocks: blocks_v1, + relevant_blocks: BTreeMap::new(), + deleted_cids: Vec::new(), + }) + .await + .unwrap(); + + let mut prev_rev = rev1; + let mut prev_cid = commit_v1_cid; + let mut prev_root = mst_root_v1; + + for cycle in 0..10u32 { + let delete_keys: Vec = record_cids + .keys() + .enumerate() + .filter(|(idx, _)| { + let hash = (*idx as u32).wrapping_mul(2654435761).wrapping_add(cycle); + hash % 4 == 0 + }) + .map(|(_, k)| k.clone()) + .collect(); + + for key in &delete_keys { + mst = mst.delete(key).await.unwrap(); + record_cids.remove(key); + } + + let add_count = delete_keys.len().min(10); + let new_records: Vec<(String, Vec)> = (0..add_count) + .map(|i| { + let key = format!("app.bsky.feed.post/cyc{cycle}_{i:06}"); + let data = make_record(&format!("cycle {cycle} record {i}")); + (key, data) + }) + .collect(); + + for (key, data) in &new_records { + let cid = storage.put(data).await.unwrap(); + record_cids.insert(key.clone(), cid); + mst = mst.add(key, cid).await.unwrap(); + } + + let new_root = mst.persist().await.unwrap(); + + let old_settled = Mst::load(storage.clone(), prev_root, None); + let new_settled = Mst::load(storage.clone(), new_root, None); + let (old_nodes, new_nodes, old_leaves, new_leaves) = tokio::try_join!( + old_settled.collect_node_cids(), + new_settled.collect_node_cids(), + old_settled.leaves(), + new_settled.leaves(), + ) + .unwrap(); + let old_set: std::collections::BTreeSet = old_nodes.into_iter().collect(); + let new_set: std::collections::BTreeSet = new_nodes.into_iter().collect(); + let old_leaf_set: std::collections::BTreeSet = + old_leaves.iter().map(|(_, c)| *c).collect(); + let new_leaf_set: std::collections::BTreeSet = + new_leaves.iter().map(|(_, c)| *c).collect(); + let obsolete: Vec = std::iter::once(prev_cid) + .chain(old_set.difference(&new_set).copied()) + .chain(old_leaf_set.difference(&new_leaf_set).copied()) + .collect(); + + let rev = ticker.next(Some(prev_rev.clone())); + let commit = Commit::new_unsigned(did.clone(), new_root, rev.clone(), Some(prev_cid)) + .sign(&signing_key) + .unwrap(); + let commit_cbor = commit.to_cbor().unwrap(); + let new_commit_cid = compute_cid(&commit_cbor); + + storage.put(&commit_cbor).await.unwrap(); + + store.decrement_refs(&obsolete).await.unwrap(); + + let gc_store = store.clone(); + tokio::task::spawn_blocking(move || { + advance_epoch(&gc_store); + std::thread::sleep(std::time::Duration::from_millis(10)); + advance_epoch(&gc_store); + compact_all_sealed(&gc_store); + }) + .await + .unwrap(); + + let verify_mst = Mst::load(storage.clone(), new_root, None); + let verify_nodes = verify_mst + .collect_node_cids() + .await + .unwrap_or_else(|e| panic!("cycle {cycle}: MST node walk failed after GC: {e}")); + + futures::stream::iter(verify_nodes) + .for_each(|node_cid| { + let storage = &storage; + async move { + let block = storage.get(&node_cid).await.unwrap(); + assert!( + block.is_some(), + "cycle {cycle}: MST node {node_cid} missing after GC" + ); + } + }) + .await; + + prev_rev = rev; + prev_cid = new_commit_cid; + prev_root = new_root; + } + + let final_mst = Mst::load(storage.clone(), prev_root, None); + let final_leaves = final_mst.leaves().await.unwrap(); + assert_eq!(final_leaves.len(), record_cids.len()); +} diff --git a/crates/tranquil-store/tests/proptests.rs b/crates/tranquil-store/tests/proptests.rs index 22bc385..9f50161 100644 --- a/crates/tranquil-store/tests/proptests.rs +++ b/crates/tranquil-store/tests/proptests.rs @@ -2,8 +2,8 @@ use proptest::prelude::*; use std::path::Path; use tranquil_store::{ - FaultConfig, OpenOptions, ReadRecord, RecordReader, RecordWriter, SimulatedIO, StorageIO, - run_crash_test, run_pristine_comparison, + FaultConfig, HEADER_SIZE, OpenOptions, ReadRecord, RecordReader, RecordWriter, SimulatedIO, + StorageIO, run_crash_test, run_pristine_comparison, sim_proptest_cases, }; fn arb_payloads(max_count: usize, max_size: usize) -> BoxedStrategy>> { @@ -22,7 +22,7 @@ fn sim_with_dir(seed: u64, config: FaultConfig) -> SimulatedIO { } proptest! { - #![proptest_config(ProptestConfig::with_cases(2000))] + #![proptest_config(ProptestConfig::with_cases(sim_proptest_cases()))] #[test] fn synced_records_survive_crash( diff --git a/crates/tranquil-store/tests/sim_blockstore.rs b/crates/tranquil-store/tests/sim_blockstore.rs new file mode 100644 index 0000000..bc8e4d4 --- /dev/null +++ b/crates/tranquil-store/tests/sim_blockstore.rs @@ -0,0 +1,695 @@ +mod common; + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use rayon::prelude::*; +use tranquil_store::blockstore::hash_index::{BlockIndex, CheckpointPositions}; +use tranquil_store::blockstore::{ + BLOCK_RECORD_OVERHEAD, BlockLocation, BlockOffset, BlockStoreConfig, BlockStoreReader, + CidBytes, CommitEpoch, DEFAULT_MAX_FILE_SIZE, DataFileId, DataFileManager, DataFileWriter, + GroupCommitConfig, HINT_RECORD_SIZE, HintFileWriter, HintOffset, TranquilBlockStore, + WallClockMs, WriteCursor, hint_file_path, +}; +use tranquil_store::{FaultConfig, OpenOptions, SimulatedIO, StorageIO, sim_seed_range}; + +use common::{Rng, advance_epoch, block_data, test_cid, with_runtime}; + +struct SimHarness { + sim: Arc, + data_dir: &'static Path, +} + +impl SimHarness { + fn pristine(seed: u64) -> Self { + let sim = Arc::new(SimulatedIO::pristine(seed)); + let data_dir = Path::new("/data"); + sim.mkdir(data_dir).unwrap(); + sim.sync_dir(data_dir).unwrap(); + Self { sim, data_dir } + } + + fn ensure_data_file(&self, file_id: DataFileId) -> BlockOffset { + let manager = DataFileManager::with_default_max_size( + Arc::clone(&self.sim), + self.data_dir.to_path_buf(), + ); + let fd = manager.open_for_append(file_id).unwrap(); + let file_size = self.sim.file_size(fd).unwrap(); + match file_size { + 0 => { + let w = DataFileWriter::new(&*self.sim, fd, file_id).unwrap(); + w.sync().unwrap(); + self.sim.sync_dir(self.data_dir).unwrap(); + w.position() + } + n => BlockOffset::new(n), + } + } + + fn write_blocks_with_hints( + &self, + file_id: DataFileId, + start_pos: BlockOffset, + seeds: std::ops::Range, + data_size: usize, + sync: bool, + ) -> (BlockOffset, Vec<(CidBytes, BlockLocation)>) { + let path = self.data_dir.join(format!("{file_id}.tqb")); + let fd = self.sim.open(&path, OpenOptions::read_write()).unwrap(); + let mut writer = DataFileWriter::resume(&*self.sim, fd, file_id, start_pos); + + let hint_path = hint_file_path(self.data_dir, file_id); + let hint_fd = self + .sim + .open(&hint_path, OpenOptions::read_write()) + .unwrap(); + let hint_size = self.sim.file_size(hint_fd).unwrap(); + let mut hint_writer = + HintFileWriter::resume(&*self.sim, hint_fd, HintOffset::new(hint_size)); + + let entries: Vec<_> = seeds + .map(|seed| { + let cid = test_cid(seed as u32); + let data = vec![seed as u8; data_size]; + let loc = writer.append_block(&cid, &data).unwrap(); + hint_writer + .append_hint(&cid, loc.file_id, loc.offset, loc.length) + .unwrap(); + (cid, loc) + }) + .collect(); + + if sync { + writer.sync().unwrap(); + hint_writer.sync().unwrap(); + self.sim.sync_dir(self.data_dir).unwrap(); + } + + let pos = writer.position(); + let _ = self.sim.close(hint_fd); + let _ = self.sim.close(fd); + (pos, entries) + } + + fn index_entries( + index: &BlockIndex, + entries: &[(CidBytes, BlockLocation)], + cursor: WriteCursor, + ) { + index + .batch_put( + entries, + &[], + cursor, + CommitEpoch::zero(), + WallClockMs::new(0), + ) + .unwrap(); + let positions = CheckpointPositions::single( + cursor.file_id, + HintOffset::new(entries.len() as u64 * HINT_RECORD_SIZE as u64), + ); + index + .write_checkpoint(CommitEpoch::zero(), &positions) + .unwrap(); + } + + fn make_reader(&self, index: Arc) -> BlockStoreReader> { + let manager = Arc::new(DataFileManager::with_default_max_size( + Arc::clone(&self.sim), + self.data_dir.to_path_buf(), + )); + BlockStoreReader::new(index, manager) + } +} + +#[test] +fn sim_crash_during_data_file_write_before_fsync() { + sim_seed_range().into_par_iter().for_each(|seed| { + let h = SimHarness::pristine(seed); + let file_id = DataFileId::new(0); + let start_pos = h.ensure_data_file(file_id); + + let mut rng = Rng::new(seed); + let block_count = (rng.range_u32(20) + 5) as u16; + + let _ = h.write_blocks_with_hints(file_id, start_pos, 0..block_count, 64, false); + h.sim.crash(); + + let index_dir = tempfile::TempDir::new().unwrap(); + let rebuilt = BlockIndex::open(index_dir.path()).unwrap(); + rebuilt + .rebuild_from_data_files(&*h.sim, h.data_dir) + .unwrap(); + + (0..block_count).for_each(|i| { + let cid = test_cid(i as u32); + assert!( + rebuilt.get(&cid).is_none(), + "seed={seed} unsynced block {i} must not appear in index after crash before fsync" + ); + }); + }); +} + +#[test] +fn sim_crash_after_fsync_before_index_update() { + sim_seed_range().into_par_iter().for_each(|seed| { + let h = SimHarness::pristine(seed); + let file_id = DataFileId::new(0); + let start_pos = h.ensure_data_file(file_id); + + let mut rng = Rng::new(seed); + let block_count = (rng.range_u32(20) + 5) as u16; + + let (_end_pos, entries) = + h.write_blocks_with_hints(file_id, start_pos, 0..block_count, 64, true); + + h.sim.crash(); + + let index_dir = tempfile::TempDir::new().unwrap(); + let rebuilt = BlockIndex::open(index_dir.path()).unwrap(); + rebuilt.rebuild_from_hints(&*h.sim, h.data_dir).unwrap(); + + let idx = Arc::new(rebuilt); + let reader = h.make_reader(Arc::clone(&idx)); + + entries.iter().for_each(|(cid, _)| { + assert!( + idx.get(cid).is_some(), + "seed={seed} synced block must be recoverable from hints after crash before index update" + ); + let data = reader.get(cid).unwrap(); + assert!(data.is_some(), "seed={seed} synced block must be readable"); + }); + }); +} + +#[test] +fn sim_crash_after_index_update() { + sim_seed_range().into_par_iter().for_each(|seed| { + let h = SimHarness::pristine(seed); + let file_id = DataFileId::new(0); + let start_pos = h.ensure_data_file(file_id); + + let mut rng = Rng::new(seed); + let block_count = (rng.range_u32(20) + 5) as u16; + + let (end_pos, entries) = + h.write_blocks_with_hints(file_id, start_pos, 0..block_count, 64, true); + + let index_dir = tempfile::TempDir::new().unwrap(); + let index = BlockIndex::open(index_dir.path()).unwrap(); + SimHarness::index_entries( + &index, + &entries, + WriteCursor { + file_id, + offset: end_pos, + }, + ); + drop(index); + + h.sim.crash(); + + let reopened = BlockIndex::open(index_dir.path()).unwrap(); + let idx = Arc::new(reopened); + let reader = h.make_reader(Arc::clone(&idx)); + + entries.iter().for_each(|(cid, _)| { + assert!( + idx.get(cid).is_some(), + "seed={seed} fully committed block must survive crash after index update" + ); + let data = reader.get(cid).unwrap(); + assert!(data.is_some(), "seed={seed} block must be readable"); + }); + }); +} + +#[test] +fn sim_partial_index_crash_recovers_via_cursor() { + sim_seed_range().into_par_iter().for_each(|seed| { + let h = SimHarness::pristine(seed); + let file_id = DataFileId::new(0); + let start_pos = h.ensure_data_file(file_id); + + let mut rng = Rng::new(seed); + let block_count = (rng.range_u32(20) + 5) as u16; + let indexed_count = (rng.range_u32(block_count as u32 - 1) + 1) as u16; + + let (_end_pos, entries) = + h.write_blocks_with_hints(file_id, start_pos, 0..block_count, 64, true); + + let indexed = &entries[..indexed_count as usize]; + let cursor_end = indexed + .last() + .map(|(_, loc)| loc.offset.advance(BLOCK_RECORD_OVERHEAD as u64 + loc.length.as_u64())) + .unwrap_or(start_pos); + + let index_dir = tempfile::TempDir::new().unwrap(); + let index = BlockIndex::open(index_dir.path()).unwrap(); + SimHarness::index_entries( + &index, + indexed, + WriteCursor { + file_id, + offset: cursor_end, + }, + ); + drop(index); + + h.sim.crash(); + + let rebuilt_dir = tempfile::TempDir::new().unwrap(); + let rebuilt = BlockIndex::open(rebuilt_dir.path()).unwrap(); + rebuilt.rebuild_from_hints(&*h.sim, h.data_dir).unwrap(); + + let idx = Arc::new(rebuilt); + let reader = h.make_reader(Arc::clone(&idx)); + + (0..block_count).for_each(|i| { + let cid = test_cid(i as u32); + assert!( + idx.get(&cid).is_some(), + "seed={seed} synced block {i} must be recovered (indexed={indexed_count}, total={block_count})" + ); + let data = reader.get(&cid).unwrap(); + assert!(data.is_some(), "seed={seed} block {i} must be readable"); + assert_eq!( + data.unwrap()[0], + i as u8, + "seed={seed} block {i} content mismatch" + ); + }); + }); +} + +#[test] +fn sim_no_acknowledged_block_lost() { + with_runtime(|| { + sim_seed_range().into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let config = BlockStoreConfig { + data_dir: dir.path().join("data"), + index_dir: dir.path().join("index"), + max_file_size: DEFAULT_MAX_FILE_SIZE, + group_commit: GroupCommitConfig::default(), + shard_count: 1, + }; + + let block_count = ((seed % 30) + 5) as u32; + let blocks: Vec<(CidBytes, Vec)> = (0..block_count) + .map(|i| (test_cid(i), block_data(i))) + .collect(); + + let acked_cids: Vec = blocks.iter().map(|(cid, _)| *cid).collect(); + + { + let store = TranquilBlockStore::open(config.clone()).unwrap(); + store.put_blocks_blocking(blocks).unwrap(); + } + + let store = TranquilBlockStore::open(config).unwrap(); + + acked_cids.iter().enumerate().for_each(|(idx, cid)| { + let data = store.get_block_sync(cid).unwrap(); + assert!( + data.is_some(), + "seed={seed} acknowledged block {idx} must be durable after reopen" + ); + let expected = block_data(idx as u32); + assert_eq!( + &data.unwrap()[..], + &expected[..], + "seed={seed} block {idx} content mismatch" + ); + }); + }); + }); +} + +#[test] +fn sim_hint_rebuild_matches_normal_index() { + sim_seed_range().into_par_iter().for_each(|seed| { + let h = SimHarness::pristine(seed); + let file_id = DataFileId::new(0); + let start_pos = h.ensure_data_file(file_id); + + let mut rng = Rng::new(seed); + let block_count = (rng.range_u32(40) + 10) as u16; + + let (end_pos, entries) = + h.write_blocks_with_hints(file_id, start_pos, 0..block_count, 64, true); + + let normal_dir = tempfile::TempDir::new().unwrap(); + let normal_index = BlockIndex::open(normal_dir.path()).unwrap(); + SimHarness::index_entries( + &normal_index, + &entries, + WriteCursor { + file_id, + offset: end_pos, + }, + ); + + let normal_snapshot: HashMap = entries + .iter() + .filter_map(|(cid, _)| normal_index.get(cid).map(|entry| (*cid, entry.location))) + .collect(); + drop(normal_index); + + let hint_dir = tempfile::TempDir::new().unwrap(); + let hint_rebuilt = BlockIndex::open(hint_dir.path()).unwrap(); + hint_rebuilt + .rebuild_from_hints(&*h.sim, h.data_dir) + .unwrap(); + + entries.iter().for_each(|(cid, _)| { + let rebuilt_entry = hint_rebuilt.get(cid); + assert!( + rebuilt_entry.is_some(), + "seed={seed} hint-rebuilt index must contain all blocks" + ); + let rebuilt_loc = rebuilt_entry.unwrap().location; + let normal_loc = normal_snapshot.get(cid).unwrap(); + assert_eq!( + rebuilt_loc.file_id, normal_loc.file_id, + "seed={seed} file_id mismatch" + ); + assert_eq!( + rebuilt_loc.offset, normal_loc.offset, + "seed={seed} offset mismatch" + ); + assert_eq!( + rebuilt_loc.length, normal_loc.length, + "seed={seed} length mismatch" + ); + }); + + let data_dir2 = tempfile::TempDir::new().unwrap(); + let data_rebuilt = BlockIndex::open(data_dir2.path()).unwrap(); + data_rebuilt + .rebuild_from_data_files(&*h.sim, h.data_dir) + .unwrap(); + + entries.iter().for_each(|(cid, _)| { + let data_entry = data_rebuilt.get(cid); + assert!( + data_entry.is_some(), + "seed={seed} data-file-rebuilt index must contain all blocks" + ); + let data_loc = data_entry.unwrap().location; + let normal_loc = normal_snapshot.get(cid).unwrap(); + assert_eq!( + data_loc.file_id, normal_loc.file_id, + "seed={seed} data-rebuild file_id mismatch" + ); + assert_eq!( + data_loc.offset, normal_loc.offset, + "seed={seed} data-rebuild offset mismatch" + ); + assert_eq!( + data_loc.length, normal_loc.length, + "seed={seed} data-rebuild length mismatch" + ); + }); + }); +} + +#[test] +fn sim_concurrent_reads_during_compaction() { + with_runtime(|| { + sim_seed_range().into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let small_file_size = 512u64; + let config = BlockStoreConfig { + data_dir: dir.path().join("data"), + index_dir: dir.path().join("index"), + max_file_size: small_file_size, + group_commit: GroupCommitConfig::default(), + shard_count: 1, + }; + + let store = TranquilBlockStore::open(config).unwrap(); + + let initial_count = ((seed % 10) + 5) as u32; + let blocks: Vec<(CidBytes, Vec)> = (0..initial_count) + .map(|i| (test_cid(i), block_data(i))) + .collect(); + store.put_blocks_blocking(blocks).unwrap(); + + let delete_count = + ((seed % initial_count as u64) + 1).min(initial_count as u64 - 1) as u32; + let deleted_cids: Vec = (0..delete_count).map(test_cid).collect(); + store + .apply_commit_blocking(vec![], deleted_cids.clone()) + .unwrap(); + advance_epoch(&store); + + let live_cids: Vec = (delete_count..initial_count).map(test_cid).collect(); + + let data_files = store.list_data_files().unwrap(); + let sealed_files: Vec = data_files + .iter() + .copied() + .take(data_files.len().saturating_sub(1)) + .collect(); + + let read_store = store.clone(); + let read_cids = live_cids.clone(); + let reader_handle = std::thread::spawn(move || { + (0..50).for_each(|_| { + read_cids.iter().for_each(|cid| { + let _ = read_store.get_block_sync(cid); + }); + }); + }); + + sealed_files.iter().for_each(|&fid| { + let _ = store.compact_file(fid, 0); + }); + + reader_handle.join().unwrap(); + + live_cids.iter().for_each(|cid| { + let data = store.get_block_sync(cid).unwrap(); + assert!( + data.is_some(), + "seed={seed} live block must survive compaction" + ); + let expected_seed = u32::from_le_bytes([cid[4], cid[5], cid[6], cid[7]]); + assert_eq!( + &data.unwrap()[..4], + &expected_seed.to_le_bytes(), + "seed={seed} block content mismatch after compaction" + ); + }); + }); + }); +} + +#[test] +fn sim_aggressive_faults_data_integrity() { + sim_seed_range().into_par_iter().for_each(|seed| { + let fault_config = FaultConfig::aggressive(); + + let sim = Arc::new(SimulatedIO::new(seed, fault_config)); + let data_dir = Path::new("/data"); + let Ok(()) = sim.mkdir(data_dir).and_then(|()| sim.sync_dir(data_dir)) else { + return; + }; + + let file_id = DataFileId::new(0); + let manager = + DataFileManager::with_default_max_size(Arc::clone(&sim), data_dir.to_path_buf()); + + let Ok(fd) = manager.open_for_append(file_id) else { + return; + }; + + let writer_result = DataFileWriter::new(&*sim, fd, file_id); + let Ok(writer) = writer_result else { return }; + let Ok(()) = writer.sync() else { return }; + let Ok(()) = sim.sync_dir(data_dir) else { + return; + }; + let start_pos = writer.position(); + let _ = sim.close(fd); + + let mut rng = Rng::new(seed); + let block_count = (rng.range_u32(15) + 5) as u16; + let sync_at = (rng.range_u32(block_count as u32) + 1) as u16; + + let _ = (|| -> Option<()> { + let path = data_dir.join(format!("{file_id}.tqb")); + let fd = sim.open(&path, OpenOptions::read_write()).ok()?; + let mut writer = DataFileWriter::resume(&*sim, fd, file_id, start_pos); + + let hint_path = hint_file_path(data_dir, file_id); + let hint_fd = sim.open(&hint_path, OpenOptions::read_write()).ok()?; + let hint_size = sim.file_size(hint_fd).ok()?; + let mut hint_writer = + HintFileWriter::resume(&*sim, hint_fd, HintOffset::new(hint_size)); + + (0..sync_at).try_for_each(|i| { + let cid = test_cid(i as u32); + let data = vec![i as u8; 64]; + let loc = writer.append_block(&cid, &data).ok()?; + hint_writer + .append_hint(&cid, loc.file_id, loc.offset, loc.length) + .ok()?; + Some(()) + })?; + + writer.sync().ok()?; + hint_writer.sync().ok()?; + sim.sync_dir(data_dir).ok()?; + + let _ = (sync_at..block_count).try_for_each(|i| { + let cid = test_cid(i as u32); + let data = vec![i as u8; 64]; + let _ = writer.append_block(&cid, &data).ok()?; + Some(()) + }); + + let _ = sim.close(hint_fd); + let _ = sim.close(fd); + Some(()) + })(); + + sim.crash(); + + let index_dir = tempfile::TempDir::new().unwrap(); + let Ok(rebuilt) = BlockIndex::open(index_dir.path()) else { + return; + }; + + let all_data_files = + tranquil_store::blockstore::list_files_by_extension(&*sim, data_dir, "tqb"); + if let Ok(files) = all_data_files { + files.iter().for_each(|&fid| { + let path = data_dir.join(format!("{fid}.tqb")); + let Ok(fd) = sim.open(&path, OpenOptions::read_only_existing()) else { + return; + }; + let file_size = sim.file_size(fd).unwrap_or(0); + let mut offset = + BlockOffset::new(tranquil_store::blockstore::BLOCK_HEADER_SIZE as u64); + let mut entries = Vec::new(); + while let Ok(Some(tranquil_store::blockstore::ReadBlockRecord::Valid { + offset: blk_off, + cid_bytes, + data, + })) = + tranquil_store::blockstore::decode_block_record(&*sim, fd, offset, file_size) + { + let length = tranquil_store::blockstore::BlockLength::new( + u32::try_from(data.len()).unwrap(), + ); + let loc = BlockLocation { + file_id: fid, + offset: blk_off, + length, + }; + entries.push((cid_bytes, loc)); + offset = blk_off.advance(BLOCK_RECORD_OVERHEAD as u64 + length.as_u64()); + } + if !entries.is_empty() { + let cursor = WriteCursor { + file_id: fid, + offset: entries + .last() + .map(|(_, loc)| { + loc.offset + .advance(BLOCK_RECORD_OVERHEAD as u64 + loc.length.as_u64()) + }) + .unwrap_or(offset), + }; + rebuilt + .batch_put( + &entries, + &[], + cursor, + CommitEpoch::zero(), + WallClockMs::new(0), + ) + .ok(); + } + let _ = sim.close(fd); + }); + } + + let idx = Arc::new(rebuilt); + let reader_manager = Arc::new(DataFileManager::with_default_max_size( + Arc::clone(&sim), + data_dir.to_path_buf(), + )); + let reader = BlockStoreReader::new(Arc::clone(&idx), reader_manager); + + (sync_at..block_count).for_each(|i| { + let cid = test_cid(i as u32); + if idx.get(&cid).is_some() + && let Ok(Some(data)) = reader.get(&cid) + { + assert_eq!( + data[0], i as u8, + "seed={seed} unsynced-but-recovered block {i} data mismatch" + ); + } + }); + }); +} + +#[test] +fn sim_multi_file_rotation_crash_recovery() { + with_runtime(|| { + sim_seed_range().into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let small_file_size = 512u64; + let config = BlockStoreConfig { + data_dir: dir.path().join("data"), + index_dir: dir.path().join("index"), + max_file_size: small_file_size, + group_commit: GroupCommitConfig::default(), + shard_count: 1, + }; + + let block_count = ((seed % 25) + 10) as u32; + let blocks: Vec<(CidBytes, Vec)> = (0..block_count) + .map(|i| (test_cid(i), block_data(i))) + .collect(); + let all_cids: Vec = blocks.iter().map(|(c, _)| *c).collect(); + + { + let store = TranquilBlockStore::open(config.clone()).unwrap(); + store.put_blocks_blocking(blocks).unwrap(); + + let files = store.list_data_files().unwrap(); + assert!( + files.len() > 1, + "seed={seed} expected multiple data files from rotation, got {}", + files.len() + ); + } + + let store = TranquilBlockStore::open(config).unwrap(); + + all_cids.iter().enumerate().for_each(|(idx, cid)| { + let data = store.get_block_sync(cid).unwrap(); + assert!( + data.is_some(), + "seed={seed} block {idx} must survive reopen across rotated files" + ); + let expected = block_data(idx as u32); + assert_eq!( + &data.unwrap()[..], + &expected[..], + "seed={seed} block {idx} content mismatch after rotation recovery" + ); + }); + }); + }); +} diff --git a/crates/tranquil-store/tests/sim_cross_store.rs b/crates/tranquil-store/tests/sim_cross_store.rs new file mode 100644 index 0000000..a9cb6c4 --- /dev/null +++ b/crates/tranquil-store/tests/sim_cross_store.rs @@ -0,0 +1,586 @@ +mod common; + +use std::ops::ControlFlow; +use std::sync::atomic::{AtomicBool, Ordering}; + +use rayon::prelude::*; +use tranquil_store::RealIO; +use tranquil_store::backup::{BackupCoordinator, restore_from_backup, verify_backup}; +use tranquil_store::blockstore::{ + BlockStoreConfig, CidBytes, DEFAULT_MAX_FILE_SIZE, GroupCommitConfig, TranquilBlockStore, +}; +use tranquil_store::eventlog::{EventLog, EventLogConfig, EventSequence}; +use tranquil_store::metastore::{Metastore, MetastoreConfig}; +use tranquil_store::{sim_seed_range, sim_single_seed}; + +use common::{ + TestStores, assert_store_consistent, block_data, open_test_stores, test_cid, test_cid_link, + test_did, test_handle, test_uuid, with_runtime, +}; +use tranquil_db_traits::{RepoEventType, SequenceNumber, SequencedEvent}; +use tranquil_types::Did; +use uuid::Uuid; + +const CACHE_SIZE: u64 = 16 * 1024 * 1024; + +fn open_stores(dir: &std::path::Path) -> TestStores { + open_test_stores(dir, DEFAULT_MAX_FILE_SIZE, CACHE_SIZE) +} + +fn seed_repo(stores: &TestStores, idx: u64) -> Uuid { + let uid = test_uuid(idx); + let did = test_did(idx); + let handle = test_handle(idx); + let cid = test_cid_link((idx & 0xFF) as u8); + + stores + .metastore + .repo_ops() + .create_repo( + stores.metastore.database(), + uid, + &did, + &handle, + &cid, + &format!("rev{idx}"), + ) + .unwrap(); + uid +} + +fn append_event(stores: &TestStores, idx: u64) { + let did = test_did(idx); + let event = SequencedEvent { + seq: SequenceNumber::from_raw(0), + did: did.clone(), + created_at: chrono::Utc::now(), + event_type: RepoEventType::Commit, + commit_cid: None, + prev_cid: None, + prev_data_cid: None, + ops: None, + blobs: None, + blocks: None, + handle: None, + active: None, + status: None, + rev: Some(format!("rev{idx}")), + }; + stores + .eventlog + .append_event(&did, RepoEventType::Commit, &event) + .unwrap(); +} + +#[derive(Debug)] +enum CrashPoint { + AfterBlockstorePut, + AfterMetastoreWrite, + AfterEventlogAppend, + AfterEventlogSync, + NoAbruptDrop, +} + +impl CrashPoint { + fn from_seed(seed: u64) -> Self { + match seed % 5 { + 0 => CrashPoint::AfterBlockstorePut, + 1 => CrashPoint::AfterMetastoreWrite, + 2 => CrashPoint::AfterEventlogAppend, + 3 => CrashPoint::AfterEventlogSync, + _ => CrashPoint::NoAbruptDrop, + } + } +} + +fn run_cross_store_crash_scenario(seed: u64) { + let dir = tempfile::TempDir::new().unwrap(); + let crash_point = CrashPoint::from_seed(seed); + let block_count = ((seed % 20) as u32) + 5; + let repo_count = (seed % 3) + 1; + + { + let stores = open_stores(dir.path()); + + let blocks: Vec<(CidBytes, Vec)> = (0..block_count) + .map(|i| (test_cid(i), block_data(i))) + .collect(); + stores.blockstore.put_blocks_blocking(blocks).unwrap(); + + if matches!(crash_point, CrashPoint::AfterBlockstorePut) { + return; + } + + (0..repo_count).for_each(|i| { + seed_repo(&stores, seed * 100 + i); + }); + stores.metastore.persist().unwrap(); + + if matches!(crash_point, CrashPoint::AfterMetastoreWrite) { + return; + } + + (0..repo_count).for_each(|i| { + append_event(&stores, seed * 100 + i); + }); + + if matches!(crash_point, CrashPoint::AfterEventlogAppend) { + return; + } + + stores.eventlog.sync().unwrap(); + + if matches!(crash_point, CrashPoint::AfterEventlogSync) { + return; + } + } + + let stores = open_stores(dir.path()); + + (0..block_count).for_each(|i| { + let cid = test_cid(i); + let data = stores.blockstore.get_block_sync(&cid).unwrap(); + assert!( + data.is_some(), + "seed={seed} block {i} must survive crash (blocks are durable after put_blocks_blocking)" + ); + assert_eq!( + &data.unwrap()[..4], + &i.to_le_bytes(), + "seed={seed} block {i} content mismatch" + ); + }); + + match crash_point { + CrashPoint::AfterBlockstorePut => { + assert_eq!( + stores.eventlog.max_seq(), + EventSequence::BEFORE_ALL, + "seed={seed} no events should exist after crash before metastore write" + ); + } + CrashPoint::AfterMetastoreWrite + | CrashPoint::AfterEventlogAppend + | CrashPoint::AfterEventlogSync + | CrashPoint::NoAbruptDrop => { + (0..repo_count).for_each(|i| { + let uid = test_uuid(seed * 100 + i); + let meta = stores.metastore.repo_ops().get_repo_meta(uid).unwrap(); + assert!( + meta.is_some(), + "seed={seed} repo {i} must survive (metastore persist was called)" + ); + }); + } + } + + match crash_point { + CrashPoint::AfterEventlogSync | CrashPoint::NoAbruptDrop => { + let max_seq = stores.eventlog.max_seq(); + assert!( + max_seq.raw() >= repo_count, + "seed={seed} eventlog should have at least {repo_count} events after sync, got {}", + max_seq.raw() + ); + } + CrashPoint::AfterEventlogAppend => { + let max_seq = stores.eventlog.max_seq(); + assert!( + max_seq.raw() <= repo_count, + "seed={seed} eventlog should have at most {repo_count} events without sync, got {}", + max_seq.raw() + ); + } + _ => {} + } + + assert_store_consistent(&stores, &format!("seed={seed} crash={crash_point:?}")); +} + +#[test] +fn sim_cross_store_crash_at_random_points() { + with_runtime(|| { + sim_seed_range().into_par_iter().for_each(|seed| { + run_cross_store_crash_scenario(seed); + }); + }); +} + +fn run_partial_commit_consistency(seed: u64) { + let dir = tempfile::TempDir::new().unwrap(); + let phase_count = ((seed % 4) as usize) + 2; + let blocks_per_phase = ((seed % 8) as u32) + 3; + + let mut committed_block_ranges: Vec> = Vec::new(); + let mut committed_repo_ids: Vec<(u64, Uuid)> = Vec::new(); + let mut total_blocks: u32 = 0; + + (0..phase_count).for_each(|phase| { + let crash_this_phase = phase == phase_count - 1 && !seed.is_multiple_of(3); + let block_start = total_blocks; + let block_end = block_start + blocks_per_phase; + + { + let stores = open_stores(dir.path()); + + committed_repo_ids.iter().for_each(|&(idx, uid)| { + let meta = stores.metastore.repo_ops().get_repo_meta(uid).unwrap(); + assert!( + meta.is_some(), + "seed={seed} phase={phase} previously committed repo idx={idx} missing" + ); + }); + + committed_block_ranges.iter().for_each(|range| { + range.clone().for_each(|i| { + let data = stores.blockstore.get_block_sync(&test_cid(i)).unwrap(); + assert!( + data.is_some(), + "seed={seed} phase={phase} previously committed block {i} missing" + ); + }); + }); + + let blocks: Vec<(CidBytes, Vec)> = (block_start..block_end) + .map(|i| (test_cid(i), block_data(i))) + .collect(); + stores.blockstore.put_blocks_blocking(blocks).unwrap(); + + if crash_this_phase { + return; + } + + let idx = seed * 1000 + phase as u64; + let uid = seed_repo(&stores, idx); + stores.metastore.persist().unwrap(); + + append_event(&stores, idx); + stores.eventlog.sync().unwrap(); + + committed_block_ranges.push(block_start..block_end); + committed_repo_ids.push((idx, uid)); + } + + total_blocks = block_end; + }); + + let stores = open_stores(dir.path()); + + committed_block_ranges.iter().for_each(|range| { + range.clone().for_each(|i| { + let data = stores.blockstore.get_block_sync(&test_cid(i)).unwrap(); + assert!( + data.is_some(), + "seed={seed} final verify: committed block {i} missing" + ); + }); + }); + + committed_repo_ids.iter().for_each(|&(idx, uid)| { + let meta = stores.metastore.repo_ops().get_repo_meta(uid).unwrap(); + assert!( + meta.is_some(), + "seed={seed} final verify: committed repo idx={idx} missing" + ); + }); + + let expected_event_count = committed_repo_ids.len() as u64; + let actual = stores.eventlog.max_seq(); + assert!( + actual.raw() >= expected_event_count, + "seed={seed} expected at least {expected_event_count} events, got {}", + actual.raw() + ); + + assert_store_consistent(&stores, &format!("seed={seed} partial_commit_final")); +} + +#[test] +fn sim_partial_commit_multi_phase_consistency() { + with_runtime(|| { + sim_seed_range().into_par_iter().for_each(|seed| { + run_partial_commit_consistency(seed); + }); + }); +} + +fn run_group_commit_crash(seed: u64) { + let dir = tempfile::TempDir::new().unwrap(); + let batch_sizes: Vec = (0..((seed % 5) + 2)) + .map(|i| ((seed.wrapping_mul(7).wrapping_add(i * 13)) % 30) as u32 + 1) + .collect(); + + let mut expected_blocks: Vec = Vec::new(); + let crash_batch = (seed % batch_sizes.len() as u64) as usize; + + { + let stores = open_stores(dir.path()); + let mut next_cid: u32 = 0; + + let _ = batch_sizes + .iter() + .enumerate() + .try_for_each(|(batch_idx, &size)| { + let start = next_cid; + next_cid = start + size; + let blocks: Vec<(CidBytes, Vec)> = (start..start + size) + .map(|i| (test_cid(i), block_data(i))) + .collect(); + + stores.blockstore.put_blocks_blocking(blocks).unwrap(); + + if batch_idx == crash_batch { + return ControlFlow::Break(()); + } + + expected_blocks.extend(start..start + size); + ControlFlow::Continue(()) + }); + } + + let stores = open_stores(dir.path()); + + expected_blocks.iter().for_each(|&i| { + let data = stores.blockstore.get_block_sync(&test_cid(i)).unwrap(); + assert!( + data.is_some(), + "seed={seed} block {i} (committed before crash batch) must survive" + ); + }); +} + +#[test] +fn sim_group_commit_crash_partial_batch() { + with_runtime(|| { + sim_seed_range().into_par_iter().for_each(|seed| { + run_group_commit_crash(seed); + }); + }); +} + +fn run_every_record_references_existing_block(seed: u64) { + let dir = tempfile::TempDir::new().unwrap(); + let block_count = ((seed % 50) as u32) + 10; + let repo_count = (seed % 5) + 1; + + { + let stores = open_stores(dir.path()); + + let blocks: Vec<(CidBytes, Vec)> = (0..block_count) + .map(|i| (test_cid(i), block_data(i))) + .collect(); + stores.blockstore.put_blocks_blocking(blocks).unwrap(); + + (0..repo_count).for_each(|i| { + seed_repo(&stores, seed * 100 + i); + }); + stores.metastore.persist().unwrap(); + + (0..repo_count).for_each(|i| { + append_event(&stores, seed * 100 + i); + }); + stores.eventlog.sync().unwrap(); + } + + let stores = open_stores(dir.path()); + + (0..repo_count).for_each(|i| { + let uid = test_uuid(seed * 100 + i); + let meta = stores.metastore.repo_ops().get_repo_meta(uid).unwrap(); + assert!( + meta.is_some(), + "seed={seed} repo {i} must exist after recovery" + ); + let (_, repo_meta) = meta.unwrap(); + + let root_cid_bytes: &[u8] = &repo_meta.repo_root_cid; + assert!( + root_cid_bytes.len() >= 4, + "seed={seed} repo {i} root CID must be valid" + ); + }); + + let max_seq = stores.eventlog.max_seq(); + assert!( + max_seq.raw() >= repo_count, + "seed={seed} eventlog must have at least {repo_count} events" + ); + + assert_store_consistent(&stores, &format!("seed={seed} every_record_refs_block")); +} + +#[test] +fn sim_every_record_references_existing_block() { + with_runtime(|| { + sim_seed_range().into_par_iter().for_each(|seed| { + run_every_record_references_existing_block(seed); + }); + }); +} + +#[test] +fn sim_backup_during_concurrent_block_and_event_writes() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = rt.enter(); + + let seed_range = match sim_single_seed() { + Some(s) => s..s + 1, + None => 0..50u64, + }; + + seed_range.into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let stores = open_stores(dir.path()); + let block_base = (seed * 200) as u32; + + let initial_blocks: Vec<(CidBytes, Vec)> = (block_base..block_base + 30) + .map(|i| (test_cid(i), block_data(i))) + .collect(); + stores + .blockstore + .put_blocks_blocking(initial_blocks) + .unwrap(); + + (0..3).for_each(|i| { + seed_repo(&stores, seed * 100 + i); + }); + stores.metastore.persist().unwrap(); + + (0..5).for_each(|i| { + append_event(&stores, seed * 100 + i); + }); + stores.eventlog.sync().unwrap(); + + let writer_flag = AtomicBool::new(true); + let write_base = block_base + 500; + + std::thread::scope(|s| { + let writer_handle = s.spawn(|| { + std::iter::from_fn(|| writer_flag.load(Ordering::Relaxed).then_some(())).fold( + write_base, + |idx, ()| { + let batch: Vec<(CidBytes, Vec)> = (idx..idx.saturating_add(3)) + .map(|i| (test_cid(i), block_data(i))) + .collect(); + let _ = stores.blockstore.put_blocks_blocking(batch); + std::thread::sleep(std::time::Duration::from_millis(1)); + idx.saturating_add(3) + }, + ); + }); + + let event_handle = s.spawn(|| { + let did = Did::from("did:plc:concurrent_writer".to_string()); + std::iter::from_fn(|| writer_flag.load(Ordering::Relaxed).then_some(())).fold( + 100u32, + |i, ()| { + let event = SequencedEvent { + seq: SequenceNumber::from_raw(0), + did: did.clone(), + created_at: chrono::Utc::now(), + event_type: RepoEventType::Commit, + commit_cid: None, + prev_cid: None, + prev_data_cid: None, + ops: None, + blobs: None, + blocks: None, + handle: None, + active: None, + status: None, + rev: Some(format!("concurrent-{i}")), + }; + let _ = stores + .eventlog + .append_event(&did, RepoEventType::Commit, &event); + let _ = stores.eventlog.sync(); + std::thread::sleep(std::time::Duration::from_millis(2)); + i.saturating_add(1) + }, + ); + }); + + std::thread::sleep(std::time::Duration::from_millis(30)); + + let backup_dir = tempfile::TempDir::new().unwrap(); + let coordinator = + BackupCoordinator::new(&stores.blockstore, &stores.eventlog, &stores.metastore); + let manifest = coordinator.create_backup(backup_dir.path()).unwrap(); + + writer_flag.store(false, Ordering::Relaxed); + writer_handle.join().unwrap(); + event_handle.join().unwrap(); + + let verify_result = verify_backup(backup_dir.path()).unwrap(); + assert!( + verify_result.is_healthy(), + "seed={seed} backup during concurrent writes must be healthy: \ + corrupted_blocks={}, corrupted_events={}, file_failures={}", + verify_result.corrupted_blocks, + verify_result.corrupted_events, + verify_result.file_failures.len() + ); + + let restore_dir = tempfile::TempDir::new().unwrap(); + let restore_result = + restore_from_backup(backup_dir.path(), restore_dir.path()).unwrap(); + assert!( + restore_result.blocks_files_restored > 0, + "seed={seed} restore must have block files" + ); + + let restored_bs = TranquilBlockStore::open(BlockStoreConfig { + data_dir: restore_dir.path().join("blocks"), + index_dir: restore_dir.path().join("block_index"), + max_file_size: DEFAULT_MAX_FILE_SIZE, + group_commit: GroupCommitConfig::default(), + shard_count: 1, + }) + .unwrap(); + + (block_base..block_base + 30).for_each(|i| { + let data = restored_bs.get_block_sync(&test_cid(i)).unwrap(); + assert!( + data.is_some(), + "seed={seed} pre-existing block {i} must exist in restored backup" + ); + assert_eq!( + &data.unwrap()[..4], + &i.to_le_bytes(), + "seed={seed} block {i} content mismatch in restored backup" + ); + }); + + let restored_el = EventLog::open( + EventLogConfig { + segments_dir: restore_dir.path().join("events"), + ..EventLogConfig::default() + }, + RealIO::new(), + ) + .unwrap(); + assert_eq!( + restored_el.max_seq().raw(), + manifest.eventlog.max_seq.raw(), + "seed={seed} restored eventlog max_seq must match manifest" + ); + let _ = restored_el.shutdown(); + + let restored_ms = Metastore::open( + &restore_dir.path().join("metastore"), + MetastoreConfig { + cache_size_bytes: CACHE_SIZE, + }, + ) + .unwrap(); + (0..3u64).for_each(|i| { + let uid = test_uuid(seed * 100 + i); + let meta = restored_ms.repo_ops().get_repo_meta(uid).unwrap(); + assert!( + meta.is_some(), + "seed={seed} repo {i} must exist in restored metastore" + ); + }); + }); + }); +} diff --git a/crates/tranquil-store/tests/sim_eventlog.rs b/crates/tranquil-store/tests/sim_eventlog.rs new file mode 100644 index 0000000..8a2f9da --- /dev/null +++ b/crates/tranquil-store/tests/sim_eventlog.rs @@ -0,0 +1,1014 @@ +mod common; + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use rayon::prelude::*; +use tranquil_store::eventlog::{ + DidHash, EVENT_RECORD_OVERHEAD, EventLogWriter, EventSequence, EventTypeTag, MAX_EVENT_PAYLOAD, + SEGMENT_HEADER_SIZE, SegmentId, SegmentManager, SegmentReader, ValidEvent, +}; +use tranquil_store::{FaultConfig, SimulatedIO, StorageIO, sim_seed_range}; + +use common::Rng; + +const SEGMENTS_DIR: &str = "/segments"; + +fn setup_manager(sim: SimulatedIO, max_segment_size: u64) -> Arc> { + Arc::new(SegmentManager::new(sim, PathBuf::from(SEGMENTS_DIR), max_segment_size).unwrap()) +} + +fn append_test_event( + writer: &mut EventLogWriter, + seq_hint: u64, + seed: u64, +) -> EventSequence { + writer + .append( + DidHash::from_did(&format!("did:plc:sim{seq_hint}")), + EventTypeTag::COMMIT, + format!("payload-{seq_hint}").into_bytes(), + ) + .unwrap_or_else(|e| panic!("seed {seed}: append event {seq_hint} failed: {e}")) +} + +fn read_all_events(mgr: &SegmentManager, seed: u64) -> Vec { + mgr.list_segments() + .unwrap_or_else(|e| panic!("seed {seed}: list_segments failed: {e}")) + .iter() + .flat_map(|&seg_id| { + let fd = mgr + .open_for_read(seg_id) + .unwrap_or_else(|e| panic!("seed {seed}: open_for_read({seg_id}) failed: {e}")); + SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| { + panic!( + "seed {seed}: SegmentReader::open({seg_id}, MAX_EVENT_PAYLOAD) failed: {e}" + ) + }) + .valid_prefix() + .unwrap_or_else(|e| panic!("seed {seed}: valid_prefix({seg_id}) failed: {e}")) + }) + .collect() +} + +fn small_segment_size(payload_size: usize, events_per_segment: usize) -> u64 { + let record_size = EVENT_RECORD_OVERHEAD + payload_size; + (SEGMENT_HEADER_SIZE + record_size * events_per_segment) as u64 +} + +#[test] +fn crash_during_segment_rotation_old_sealed_new_missing() { + sim_seed_range().into_par_iter().for_each(|seed| { + let payload_size = 50; + let events_per_seg = 3usize; + let max_seg = small_segment_size(payload_size, events_per_seg); + + let sim = SimulatedIO::pristine(seed); + let mgr = setup_manager(sim, max_seg); + + { + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| panic!("seed {seed}: open writer failed: {e}")); + (1..=events_per_seg as u64).for_each(|i| { + writer + .append( + DidHash::from_did(&format!("did:plc:rot{i}")), + EventTypeTag::COMMIT, + vec![i as u8; payload_size], + ) + .unwrap_or_else(|e| panic!("seed {seed}: append {i} failed: {e}")); + }); + writer + .sync() + .unwrap_or_else(|e| panic!("seed {seed}: sync failed: {e}")); + + let old_id = writer.active_segment_id(); + let old_index = writer.active_index_snapshot(); + mgr.seal_segment(old_id, &old_index) + .unwrap_or_else(|e| panic!("seed {seed}: seal_segment failed: {e}")); + } + + mgr.io() + .sync_dir(Path::new(SEGMENTS_DIR)) + .unwrap_or_else(|e| panic!("seed {seed}: sync_dir failed: {e}")); + mgr.shutdown(); + mgr.io().crash(); + + let writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| panic!("seed {seed}: recovery open failed: {e}")); + assert!( + writer.synced_seq().raw() >= events_per_seg as u64, + "seed {seed}: sealed events must survive, got seq {}", + writer.synced_seq(), + ); + }); +} + +#[test] +fn crash_during_segment_rotation_new_file_created_but_not_synced() { + sim_seed_range().into_par_iter().for_each(|seed| { + let payload_size = 50; + let events_per_seg = 3usize; + let max_seg = small_segment_size(payload_size, events_per_seg); + + let sim = SimulatedIO::pristine(seed); + let mgr = setup_manager(sim, max_seg); + + { + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| panic!("seed {seed}: open writer failed: {e}")); + (1..=events_per_seg as u64).for_each(|i| { + writer + .append( + DidHash::from_did(&format!("did:plc:rot2{i}")), + EventTypeTag::COMMIT, + vec![i as u8; payload_size], + ) + .unwrap_or_else(|e| panic!("seed {seed}: append {i} failed: {e}")); + }); + writer + .sync() + .unwrap_or_else(|e| panic!("seed {seed}: sync failed: {e}")); + mgr.io() + .sync_dir(Path::new(SEGMENTS_DIR)) + .unwrap_or_else(|e| panic!("seed {seed}: sync_dir failed: {e}")); + + writer + .rotate_if_needed() + .unwrap_or_else(|e| panic!("seed {seed}: rotate failed: {e}")); + } + + mgr.shutdown(); + mgr.io().crash(); + + let recovery = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + })); + + let Ok(Ok(mut writer)) = recovery else { + return; + }; + + assert!( + writer.synced_seq().raw() >= events_per_seg as u64, + "seed {seed}: sealed segment events must survive rotation crash, got {}", + writer.synced_seq(), + ); + + let post_seq = writer + .append( + DidHash::from_did("did:plc:post_rot"), + EventTypeTag::COMMIT, + vec![0xAA; payload_size], + ) + .unwrap_or_else(|e| panic!("seed {seed}: post-recovery append failed: {e}")); + assert!( + post_seq.raw() > events_per_seg as u64, + "seed {seed}: post-recovery seq must be monotonic" + ); + }); +} + +#[test] +fn crash_mid_rotation_with_faults() { + sim_seed_range().into_par_iter().for_each(|seed| { + let payload_size = 50; + let events_per_seg = 3usize; + let max_seg = small_segment_size(payload_size, events_per_seg); + + let sim = SimulatedIO::new(seed, FaultConfig::moderate()); + let mgr = setup_manager(sim, max_seg); + + let write_result = (|| -> std::io::Result { + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD)?; + (1..=events_per_seg as u64).try_for_each(|i| -> std::io::Result<()> { + writer.append( + DidHash::from_did(&format!("did:plc:frot{i}")), + EventTypeTag::COMMIT, + vec![i as u8; payload_size], + )?; + Ok(()) + })?; + writer.sync()?; + mgr.io().sync_dir(Path::new(SEGMENTS_DIR))?; + let _ = writer.rotate_if_needed(); + Ok(writer.synced_seq().raw()) + })(); + + mgr.shutdown(); + mgr.io().crash(); + + let recovery = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + })); + + if let Ok(Ok(writer)) = recovery + && let Ok(synced_before) = write_result + { + assert!( + writer.synced_seq().raw() <= synced_before, + "seed {seed}: recovered more events than were synced" + ); + } + }); +} + +#[test] +fn segment_deletion_does_not_corrupt_neighbors() { + sim_seed_range().into_par_iter().for_each(|seed| { + let payload_size = 50; + let events_per_seg = 3usize; + let max_seg = small_segment_size(payload_size, events_per_seg); + + let sim = SimulatedIO::pristine(seed); + let mgr = setup_manager(sim, max_seg); + + let total_events = (events_per_seg * 3) as u64; + { + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| panic!("seed {seed}: open writer failed: {e}")); + (1..=total_events).for_each(|i| { + writer + .append( + DidHash::from_did(&format!("did:plc:del{i}")), + EventTypeTag::COMMIT, + vec![i as u8; payload_size], + ) + .unwrap_or_else(|e| panic!("seed {seed}: append {i} failed: {e}")); + if (i as usize).is_multiple_of(events_per_seg) && (i as usize) < events_per_seg * 3 + { + writer + .sync() + .unwrap_or_else(|e| panic!("seed {seed}: sync failed: {e}")); + writer + .rotate_if_needed() + .unwrap_or_else(|e| panic!("seed {seed}: rotate failed: {e}")); + } + }); + writer + .sync() + .unwrap_or_else(|e| panic!("seed {seed}: final sync failed: {e}")); + } + + mgr.delete_segment(SegmentId::new(1)) + .unwrap_or_else(|e| panic!("seed {seed}: delete_segment(1) failed: {e}")); + + let seg2_fd = mgr + .open_for_read(SegmentId::new(2)) + .unwrap_or_else(|e| panic!("seed {seed}: open_for_read(2) failed: {e}")); + let seg2_events = SegmentReader::open(mgr.io(), seg2_fd, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| { + panic!("seed {seed}: SegmentReader::open(2, MAX_EVENT_PAYLOAD) failed: {e}") + }) + .valid_prefix() + .unwrap_or_else(|e| panic!("seed {seed}: valid_prefix(2) failed: {e}")); + assert_eq!( + seg2_events.len(), + events_per_seg, + "seed {seed}: segment 2 must remain readable after segment 1 deleted" + ); + + let seg3_fd = mgr + .open_for_read(SegmentId::new(3)) + .unwrap_or_else(|e| panic!("seed {seed}: open_for_read(3) failed: {e}")); + let seg3_events = SegmentReader::open(mgr.io(), seg3_fd, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| { + panic!("seed {seed}: SegmentReader::open(3, MAX_EVENT_PAYLOAD) failed: {e}") + }) + .valid_prefix() + .unwrap_or_else(|e| panic!("seed {seed}: valid_prefix(3) failed: {e}")); + assert_eq!( + seg3_events.len(), + events_per_seg, + "seed {seed}: segment 3 must remain readable after segment 1 deleted" + ); + }); +} + +#[test] +fn sequence_contiguity_across_segments_after_crash() { + sim_seed_range().into_par_iter().for_each(|seed| { + let payload_size = 50; + let events_per_seg = 3usize; + let max_seg = small_segment_size(payload_size, events_per_seg); + + let sim = SimulatedIO::pristine(seed); + let mgr = setup_manager(sim, max_seg); + + let sealed_events = (events_per_seg * 2) as u64; + let trailing = 2u64; + { + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| panic!("seed {seed}: open writer failed: {e}")); + (1..=sealed_events + trailing).for_each(|i| { + writer + .append( + DidHash::from_did(&format!("did:plc:sub{i}")), + EventTypeTag::COMMIT, + vec![i as u8; payload_size], + ) + .unwrap_or_else(|e| panic!("seed {seed}: append {i} failed: {e}")); + if (i as usize).is_multiple_of(events_per_seg) && i <= sealed_events { + writer + .sync() + .unwrap_or_else(|e| panic!("seed {seed}: sync failed: {e}")); + writer + .rotate_if_needed() + .unwrap_or_else(|e| panic!("seed {seed}: rotate failed: {e}")); + } + }); + mgr.io() + .sync_dir(Path::new(SEGMENTS_DIR)) + .unwrap_or_else(|e| panic!("seed {seed}: sync_dir failed: {e}")); + } + + mgr.shutdown(); + mgr.io().crash(); + + let writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| panic!("seed {seed}: recovery open failed: {e}")); + + assert!( + writer.synced_seq().raw() >= sealed_events, + "seed {seed}: sealed events must survive, got {}", + writer.synced_seq(), + ); + + let all_events = read_all_events(&mgr, seed); + + all_events.windows(2).for_each(|pair| { + assert_eq!( + pair[1].seq.raw(), + pair[0].seq.raw() + 1, + "seed {seed}: non-contiguous sequence {} -> {} across segments", + pair[0].seq, + pair[1].seq, + ); + }); + }); +} + +#[test] +fn fsync_ordering_unsynced_events_never_durable() { + sim_seed_range().into_par_iter().for_each(|seed| { + let sim = SimulatedIO::pristine(seed); + let mgr = setup_manager(sim, 64 * 1024); + + let synced_count = 5u64; + let unsynced_count = 5u64; + { + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| panic!("seed {seed}: open writer failed: {e}")); + (1..=synced_count).for_each(|i| { + append_test_event(&mut writer, i, seed); + }); + writer + .sync() + .unwrap_or_else(|e| panic!("seed {seed}: sync failed: {e}")); + mgr.io() + .sync_dir(Path::new(SEGMENTS_DIR)) + .unwrap_or_else(|e| panic!("seed {seed}: sync_dir failed: {e}")); + + (synced_count + 1..=synced_count + unsynced_count).for_each(|i| { + append_test_event(&mut writer, i, seed); + }); + } + + mgr.shutdown(); + mgr.io().crash(); + + let writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| panic!("seed {seed}: recovery open failed: {e}")); + assert_eq!( + writer.synced_seq().raw(), + synced_count, + "seed {seed}: exactly the synced events must survive, never unsynced" + ); + + let fd = mgr + .open_for_read(SegmentId::new(1)) + .unwrap_or_else(|e| panic!("seed {seed}: open_for_read(1) failed: {e}")); + let recovered = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| { + panic!("seed {seed}: SegmentReader::open(1, MAX_EVENT_PAYLOAD) failed: {e}") + }) + .valid_prefix() + .unwrap_or_else(|e| panic!("seed {seed}: valid_prefix(1) failed: {e}")); + assert_eq!( + recovered.len(), + synced_count as usize, + "seed {seed}: on-disk events must match synced count" + ); + + recovered.iter().enumerate().for_each(|(i, e)| { + let expected = format!("payload-{}", i + 1); + assert_eq!( + e.payload, + expected.as_bytes(), + "seed {seed}: event {} payload mismatch", + i + 1, + ); + }); + }); +} + +#[test] +fn fsync_ordering_proof_sync_before_blockstore_ack() { + sim_seed_range().into_par_iter().for_each(|seed| { + let sim = SimulatedIO::pristine(seed); + let mgr = setup_manager(sim, 64 * 1024); + + let event_count = 10u64; + + let mut synced_payloads: Vec> = Vec::new(); + { + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| panic!("seed {seed}: open writer failed: {e}")); + (1..=event_count).for_each(|i| { + append_test_event(&mut writer, i, seed); + if i % 3 == 0 { + writer + .sync() + .unwrap_or_else(|e| panic!("seed {seed}: sync failed: {e}")); + mgr.io() + .sync_dir(Path::new(SEGMENTS_DIR)) + .unwrap_or_else(|e| panic!("seed {seed}: sync_dir failed: {e}")); + + synced_payloads = (1..=i) + .map(|j| format!("payload-{j}").into_bytes()) + .collect(); + } + }); + } + + mgr.shutdown(); + mgr.io().crash(); + + let writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| panic!("seed {seed}: recovery open failed: {e}")); + let recovered_count = writer.synced_seq().raw() as usize; + + assert_eq!( + recovered_count, + synced_payloads.len(), + "seed {seed}: recovered {recovered_count} but expected exactly {} (synced+dir_synced)", + synced_payloads.len(), + ); + + let fd = mgr + .open_for_read(SegmentId::new(1)) + .unwrap_or_else(|e| panic!("seed {seed}: open_for_read(1) failed: {e}")); + let recovered = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| { + panic!("seed {seed}: SegmentReader::open(1, MAX_EVENT_PAYLOAD) failed: {e}") + }) + .valid_prefix() + .unwrap_or_else(|e| panic!("seed {seed}: valid_prefix(1) failed: {e}")); + + assert_eq!( + recovered.len(), + synced_payloads.len(), + "seed {seed}: on-disk event count must match synced count" + ); + + recovered + .iter() + .zip(synced_payloads.iter()) + .for_each(|(e, expected)| { + assert_eq!( + &e.payload, expected, + "seed {seed}: recovered event payload must match synced payload" + ); + }); + }); +} + +#[test] +fn group_sync_crash_after_append_before_sync() { + sim_seed_range().into_par_iter().for_each(|seed| { + let sim = SimulatedIO::pristine(seed); + let mgr = setup_manager(sim, 64 * 1024); + + let mut rng = Rng::new(seed); + let pre_sync_count = (rng.range_u32(8) as u64) + 2; + let post_append_count = (rng.range_u32(5) as u64) + 1; + + { + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| panic!("seed {seed}: open writer failed: {e}")); + (1..=pre_sync_count).for_each(|i| { + append_test_event(&mut writer, i, seed); + }); + writer + .sync() + .unwrap_or_else(|e| panic!("seed {seed}: sync failed: {e}")); + mgr.io() + .sync_dir(Path::new(SEGMENTS_DIR)) + .unwrap_or_else(|e| panic!("seed {seed}: sync_dir failed: {e}")); + + (pre_sync_count + 1..=pre_sync_count + post_append_count).for_each(|i| { + append_test_event(&mut writer, i, seed); + }); + } + + mgr.shutdown(); + mgr.io().crash(); + + let writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| panic!("seed {seed}: recovery open failed: {e}")); + assert_eq!( + writer.synced_seq().raw(), + pre_sync_count, + "seed {seed}: only pre-sync events survive when crash happens after append but before group sync" + ); + }); +} + +#[test] +fn group_sync_crash_mid_sync_partial_fsync() { + sim_seed_range().into_par_iter().for_each(|seed| { + let fault_config = FaultConfig { + sync_failure_probability: 0.3, + partial_write_probability: 0.1, + ..FaultConfig::none() + }; + let sim = SimulatedIO::new(seed, fault_config); + let mgr = setup_manager(sim, 64 * 1024); + + let event_count = 10u64; + let write_result = (|| -> std::io::Result { + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD)?; + (1..=event_count).try_for_each(|i| -> std::io::Result<()> { + writer.append( + DidHash::from_did(&format!("did:plc:gsync{i}")), + EventTypeTag::COMMIT, + format!("gsync-{i}").into_bytes(), + )?; + if i % 3 == 0 { + let _ = writer.sync(); + let _ = mgr.io().sync_dir(Path::new(SEGMENTS_DIR)); + } + Ok(()) + })?; + let _ = writer.sync(); + Ok(writer.synced_seq().raw()) + })(); + let _ = write_result; + + mgr.shutdown(); + mgr.io().crash(); + + let recovery = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + })); + + let Ok(Ok(writer)) = recovery else { + return; + }; + + let recovered = writer.synced_seq().raw(); + assert!( + recovered <= event_count, + "seed {seed}: recovered {recovered} exceeds written {event_count}" + ); + + if recovered == 0 { + return; + } + + let fd = mgr + .open_for_read(SegmentId::new(1)) + .unwrap_or_else(|e| panic!("seed {seed}: open_for_read(1) failed: {e}")); + let events = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| { + panic!("seed {seed}: SegmentReader::open(1, MAX_EVENT_PAYLOAD) failed: {e}") + }) + .valid_prefix() + .unwrap_or_else(|e| panic!("seed {seed}: valid_prefix(1) failed: {e}")); + + events.iter().enumerate().for_each(|(i, e)| { + assert_eq!( + e.seq, + EventSequence::new(i as u64 + 1), + "seed {seed}: recovered events must be contiguous starting from 1" + ); + }); + }); +} + +#[test] +fn group_sync_no_double_sync_no_skipped_events() { + sim_seed_range().into_par_iter().for_each(|seed| { + let sim = SimulatedIO::pristine(seed); + let mgr = setup_manager(sim, 64 * 1024); + + let batch_count = 4u64; + let events_per_batch = 3u64; + let total = batch_count * events_per_batch; + + { + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| panic!("seed {seed}: open writer failed: {e}")); + (0..batch_count).for_each(|batch| { + let start = batch * events_per_batch + 1; + let end = start + events_per_batch; + (start..end).for_each(|i| { + append_test_event(&mut writer, i, seed); + }); + + let result = writer + .sync() + .unwrap_or_else(|e| panic!("seed {seed} batch {batch}: sync failed: {e}")); + assert_eq!( + result.flushed_events.len(), + events_per_batch as usize, + "seed {seed} batch {batch}: sync must flush exactly one batch" + ); + result.flushed_events.iter().enumerate().for_each(|(j, e)| { + let expected_seq = start + j as u64; + assert_eq!( + e.seq, + EventSequence::new(expected_seq), + "seed {seed} batch {batch}: flushed event {j} has wrong seq" + ); + }); + }); + mgr.io() + .sync_dir(Path::new(SEGMENTS_DIR)) + .unwrap_or_else(|e| panic!("seed {seed}: sync_dir failed: {e}")); + } + + mgr.shutdown(); + mgr.io().crash(); + + let writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| panic!("seed {seed}: recovery open failed: {e}")); + assert_eq!( + writer.synced_seq().raw(), + total, + "seed {seed}: all batch-synced events must survive" + ); + + let fd = mgr + .open_for_read(SegmentId::new(1)) + .unwrap_or_else(|e| panic!("seed {seed}: open_for_read(1) failed: {e}")); + let events = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| { + panic!("seed {seed}: SegmentReader::open(1, MAX_EVENT_PAYLOAD) failed: {e}") + }) + .valid_prefix() + .unwrap_or_else(|e| panic!("seed {seed}: valid_prefix(1) failed: {e}")); + assert_eq!(events.len(), total as usize); + + events.iter().enumerate().for_each(|(i, e)| { + assert_eq!( + e.seq, + EventSequence::new(i as u64 + 1), + "seed {seed}: event at position {i} has wrong sequence" + ); + let expected_payload = format!("payload-{}", i + 1); + assert_eq!( + e.payload, + expected_payload.as_bytes(), + "seed {seed}: event {} payload mismatch (duplicate or skip)", + i + 1, + ); + }); + }); +} + +#[test] +fn group_sync_contention_under_faults() { + sim_seed_range().into_par_iter().for_each(|seed| { + let fault_config = FaultConfig { + partial_write_probability: 0.05, + sync_failure_probability: 0.10, + dir_sync_failure_probability: 0.05, + ..FaultConfig::none() + }; + let sim = SimulatedIO::new(seed, fault_config); + let mgr = setup_manager(sim, 64 * 1024); + + let mut rng = Rng::new(seed); + let event_count = (rng.range_u32(15) as u64) + 5; + let sync_interval = (rng.range_u32(4) as u64) + 1; + + let write_ok = (|| -> std::io::Result<()> { + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD)?; + (1..=event_count).try_for_each(|i| -> std::io::Result<()> { + writer.append( + DidHash::from_did(&format!("did:plc:cont{i}")), + EventTypeTag::COMMIT, + format!("contention-{i}").into_bytes(), + )?; + if i % sync_interval == 0 && writer.sync().is_ok() { + let _ = mgr.io().sync_dir(Path::new(SEGMENTS_DIR)); + } + Ok(()) + })?; + if writer.sync().is_ok() { + let _ = mgr.io().sync_dir(Path::new(SEGMENTS_DIR)); + } + Ok(()) + })(); + let _ = write_ok; + + mgr.shutdown(); + mgr.io().crash(); + + let recovery = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + })); + + let Ok(Ok(writer)) = recovery else { + return; + }; + + let recovered = writer.synced_seq().raw(); + assert!( + recovered <= event_count, + "seed {seed}: recovered {recovered} > written {event_count}" + ); + + if recovered == 0 { + return; + } + + let all_events = read_all_events(&mgr, seed); + + assert!( + !all_events.is_empty(), + "seed {seed}: recovered {recovered} but found no events on disk" + ); + + all_events.iter().enumerate().for_each(|(i, e)| { + assert_eq!( + e.seq, + EventSequence::new(i as u64 + 1), + "seed {seed}: event at position {i} has wrong seq {}, expected {}", + e.seq, + i + 1, + ); + }); + }); +} + +#[test] +fn multi_rotation_crash_at_each_phase() { + sim_seed_range().into_par_iter().for_each(|seed| { + let payload_size = 50; + let events_per_seg = 3usize; + let max_seg = small_segment_size(payload_size, events_per_seg); + + let crash_phase = seed % 4; + + let sim = SimulatedIO::pristine(seed); + let mgr = setup_manager(sim, max_seg); + + let total_sealed = (events_per_seg * 2) as u64; + let write_result = (|| -> std::io::Result<()> { + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD)?; + + (1..=total_sealed).for_each(|i| { + writer + .append( + DidHash::from_did(&format!("did:plc:mrot{i}")), + EventTypeTag::COMMIT, + vec![i as u8; payload_size], + ) + .unwrap_or_else(|e| panic!("seed {seed}: append sealed {i} failed: {e}")); + if (i as usize).is_multiple_of(events_per_seg) { + writer + .sync() + .unwrap_or_else(|e| panic!("seed {seed}: sync sealed failed: {e}")); + writer + .rotate_if_needed() + .unwrap_or_else(|e| panic!("seed {seed}: rotate sealed failed: {e}")); + } + }); + mgr.io().sync_dir(Path::new(SEGMENTS_DIR))?; + + match crash_phase { + 0 => { + writer + .append( + DidHash::from_did("did:plc:crash0"), + EventTypeTag::COMMIT, + vec![0xFF; payload_size], + ) + .unwrap_or_else(|e| panic!("seed {seed} phase 0: append failed: {e}")); + } + 1 => { + writer + .append( + DidHash::from_did("did:plc:crash1"), + EventTypeTag::COMMIT, + vec![0xFF; payload_size], + ) + .unwrap_or_else(|e| panic!("seed {seed} phase 1: append failed: {e}")); + writer.sync()?; + } + 2 => { + (1..=events_per_seg as u64).for_each(|i| { + writer + .append( + DidHash::from_did(&format!("did:plc:crash2_{i}")), + EventTypeTag::COMMIT, + vec![0xFF; payload_size], + ) + .unwrap_or_else(|e| { + panic!("seed {seed} phase 2: append {i} failed: {e}") + }); + }); + writer.sync()?; + let _ = writer.rotate_if_needed(); + } + _ => { + (1..=events_per_seg as u64).for_each(|i| { + writer + .append( + DidHash::from_did(&format!("did:plc:crash3_{i}")), + EventTypeTag::COMMIT, + vec![0xFF; payload_size], + ) + .unwrap_or_else(|e| { + panic!("seed {seed} phase 3: append {i} failed: {e}") + }); + }); + writer.sync()?; + writer.rotate_if_needed()?; + mgr.io().sync_dir(Path::new(SEGMENTS_DIR))?; + } + } + + Ok(()) + })(); + let _ = write_result; + + mgr.shutdown(); + mgr.io().crash(); + + let recovery = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) + })); + + let Ok(Ok(mut writer)) = recovery else { + return; + }; + + let min_expected = match crash_phase { + 0 => total_sealed, + 1 => total_sealed + 1, + _ => total_sealed + events_per_seg as u64, + }; + assert!( + writer.synced_seq().raw() >= min_expected, + "seed {seed} phase {crash_phase}: expected >= {min_expected} durable events, got {}", + writer.synced_seq(), + ); + + let post_seqs: Vec = (0..3) + .map(|i| { + writer + .append( + DidHash::from_did(&format!("did:plc:post{i}")), + EventTypeTag::COMMIT, + vec![0xBB; payload_size], + ) + .unwrap_or_else(|e| { + panic!( + "seed {seed} phase {crash_phase}: post-recovery append {i} failed: {e}" + ) + }) + }) + .collect(); + + post_seqs.windows(2).for_each(|pair| { + assert_eq!( + pair[1].raw(), + pair[0].raw() + 1, + "seed {seed} phase {crash_phase}: post-recovery seqs not contiguous" + ); + }); + }); +} + +#[test] +fn aggressive_faults_group_sync_recovery() { + let fault_config = FaultConfig { + partial_write_probability: 0.15, + sync_failure_probability: 0.10, + dir_sync_failure_probability: 0.05, + misdirected_write_probability: 0.05, + bit_flip_on_read_probability: 0.0, + io_error_probability: 0.0, + }; + + sim_seed_range().into_par_iter().for_each(|seed| { + let sim = SimulatedIO::new(seed, fault_config); + let mgr = setup_manager(sim, 64 * 1024); + + let event_count = 20u64; + let pristine_sim = SimulatedIO::pristine(seed); + let pristine_mgr = setup_manager(pristine_sim, 64 * 1024); + + { + let mut writer = + EventLogWriter::open(Arc::clone(&pristine_mgr), 256, MAX_EVENT_PAYLOAD) + .unwrap_or_else(|e| panic!("seed {seed}: open pristine writer failed: {e}")); + (1..=event_count).for_each(|i| { + writer + .append( + DidHash::from_did(&format!("did:plc:agg{i}")), + EventTypeTag::COMMIT, + format!("aggressive-{i}").into_bytes(), + ) + .unwrap_or_else(|e| panic!("seed {seed}: pristine append {i} failed: {e}")); + if i % 5 == 0 { + writer + .sync() + .unwrap_or_else(|e| panic!("seed {seed}: pristine sync failed: {e}")); + } + }); + writer + .sync() + .unwrap_or_else(|e| panic!("seed {seed}: pristine final sync failed: {e}")); + } + pristine_mgr.shutdown(); + + let pristine_fd = pristine_mgr + .open_for_read(SegmentId::new(1)) + .unwrap_or_else(|e| panic!("seed {seed}: pristine open_for_read(1) failed: {e}")); + let pristine_events = SegmentReader::open( + pristine_mgr.io(), + pristine_fd, + MAX_EVENT_PAYLOAD, + ) + .unwrap_or_else(|e| { + panic!("seed {seed}: pristine SegmentReader::open(1, MAX_EVENT_PAYLOAD) failed: {e}") + }) + .valid_prefix() + .unwrap_or_else(|e| panic!("seed {seed}: pristine valid_prefix(1) failed: {e}")); + + let _ = (|| -> std::io::Result<()> { + let mut writer = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD)?; + (1..=event_count).try_for_each(|i| -> std::io::Result<()> { + writer.append( + DidHash::from_did(&format!("did:plc:agg{i}")), + EventTypeTag::COMMIT, + format!("aggressive-{i}").into_bytes(), + )?; + if i % 5 == 0 { + let _ = writer.sync(); + let _ = mgr.io().sync_dir(Path::new(SEGMENTS_DIR)); + } + Ok(()) + })?; + let _ = writer.sync(); + Ok(()) + })(); + + mgr.shutdown(); + mgr.io().crash(); + + let Ok(writer) = EventLogWriter::open(Arc::clone(&mgr), 256, MAX_EVENT_PAYLOAD) else { + return; + }; + + let recovered_count = writer.synced_seq().raw(); + assert!( + recovered_count <= event_count, + "seed {seed}: recovered {recovered_count} > written {event_count}" + ); + + if recovered_count == 0 { + return; + } + + let Ok(fd) = mgr.open_for_read(SegmentId::new(1)) else { + return; + }; + let Ok(reader) = SegmentReader::open(mgr.io(), fd, MAX_EVENT_PAYLOAD) else { + return; + }; + let Ok(recovered_events) = reader.valid_prefix() else { + return; + }; + + let is_prefix = recovered_events + .iter() + .zip(pristine_events.iter()) + .all(|(r, p)| { + r.seq == p.seq + && r.did_hash == p.did_hash + && r.event_type == p.event_type + && r.payload == p.payload + }); + + assert!( + is_prefix, + "seed {seed}: recovered events must be a prefix of pristine" + ); + }); +} diff --git a/crates/tranquil-store/tests/sim_gc_concurrent.rs b/crates/tranquil-store/tests/sim_gc_concurrent.rs new file mode 100644 index 0000000..593a4a6 --- /dev/null +++ b/crates/tranquil-store/tests/sim_gc_concurrent.rs @@ -0,0 +1,431 @@ +mod common; + +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; + +use common::{ + Rng, advance_epoch, block_data, collect_all_dead, compact_all_sealed, small_blockstore_config, + test_cid, with_runtime, +}; +use tranquil_store::blockstore::{CidBytes, DataFileId, TranquilBlockStore}; +use tranquil_store::sim_single_seed; + +#[derive(Debug)] +struct ConcurrentGcOracle { + refcounts: parking_lot::Mutex>, +} + +impl ConcurrentGcOracle { + fn new() -> Self { + Self { + refcounts: parking_lot::Mutex::new(HashMap::new()), + } + } + + fn put(&self, seed: u32) { + *self.refcounts.lock().entry(seed).or_insert(0) += 1; + } + + fn delete(&self, seed: u32) -> bool { + let mut map = self.refcounts.lock(); + match map.get_mut(&seed) { + Some(rc) if *rc > 0 => { + *rc -= 1; + true + } + _ => false, + } + } + + fn snapshot_live(&self) -> HashSet { + self.refcounts + .lock() + .iter() + .filter(|&(_, rc)| *rc > 0) + .map(|(&seed, _)| seed) + .collect() + } + + fn refcount(&self, seed: u32) -> u32 { + self.refcounts.lock().get(&seed).copied().unwrap_or(0) + } +} + +#[test] +fn sim_gc_concurrent_writes_no_live_block_collected() { + with_runtime(|| { + let seed_range = match sim_single_seed() { + Some(s) => s..s + 1, + None => 0..100u64, + }; + + seed_range.into_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(small_blockstore_config(dir.path())).unwrap(); + let oracle = ConcurrentGcOracle::new(); + + let initial_count = ((seed % 50) as u32) + 50; + let initial_blocks: Vec<(CidBytes, Vec)> = (0..initial_count) + .map(|i| { + oracle.put(i); + (test_cid(i), block_data(i)) + }) + .collect(); + initial_blocks.chunks(20).for_each(|chunk| { + store.put_blocks_blocking(chunk.to_vec()).unwrap(); + }); + + let delete_start = initial_count / 4; + let delete_end = initial_count * 3 / 4; + let deletes: Vec = (delete_start..delete_end) + .filter(|&i| oracle.delete(i)).map(test_cid) + .collect(); + store.apply_commit_blocking(vec![], deletes).unwrap(); + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + let stop = AtomicBool::new(false); + let writer_counter = AtomicU32::new(initial_count); + + std::thread::scope(|s| { + let writer = s.spawn(|| { + std::iter::from_fn(|| (!stop.load(Ordering::Relaxed)).then_some(())) + .for_each(|()| { + let base = writer_counter.fetch_add(5, Ordering::Relaxed); + let batch: Vec<(CidBytes, Vec)> = (base..base + 5) + .map(|i| (test_cid(i), block_data(i))) + .collect(); + if store.put_blocks_blocking(batch).is_ok() { + (base..base + 5).for_each(|i| oracle.put(i)); + } + std::thread::sleep(std::time::Duration::from_millis(1)); + }); + }); + + let gc_thread = s.spawn(|| { + std::iter::from_fn(|| (!stop.load(Ordering::Relaxed)).then_some(())) + .fold(0u32, |compaction_rounds, ()| { + let _ = store.apply_commit_blocking(vec![], vec![]); + std::thread::sleep(std::time::Duration::from_millis(2)); + if let Ok(files) = store.list_data_files() { + files + .iter() + .copied() + .take(files.len().saturating_sub(1)) + .for_each(|fid| { + let _ = store.compact_file(fid, 0); + }); + } + std::thread::sleep(std::time::Duration::from_millis(3)); + compaction_rounds.saturating_add(1) + }) + }); + + std::thread::sleep(std::time::Duration::from_millis(200)); + stop.store(true, Ordering::Relaxed); + + writer.join().unwrap(); + let compaction_rounds = gc_thread.join().unwrap(); + + assert!( + compaction_rounds > 0, + "seed={seed} gc thread must have run at least one compaction round" + ); + + let live = oracle.snapshot_live(); + live.iter().for_each(|&s| { + let data = store.get_block_sync(&test_cid(s)).unwrap(); + assert!( + data.is_some(), + "seed={seed} live block {s} (refcount={}) must be readable after concurrent GC", + oracle.refcount(s) + ); + assert_eq!( + &data.unwrap()[..4], + &s.to_le_bytes(), + "seed={seed} live block {s} data mismatch" + ); + }); + + let dead = collect_all_dead(&store); + live.iter().for_each(|&s| { + assert!( + !dead.contains(&test_cid(s)), + "seed={seed} live block {s} (refcount={}) must not appear in dead candidates", + oracle.refcount(s) + ); + }); + }); + }); + }); +} + +#[test] +fn sim_gc_compaction_with_concurrent_deletes() { + with_runtime(|| { + let seed_range = match sim_single_seed() { + Some(s) => s..s + 1, + None => 0..100u64, + }; + + seed_range.into_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(small_blockstore_config(dir.path())).unwrap(); + let oracle = ConcurrentGcOracle::new(); + + let block_count = 200u32; + let blocks: Vec<(CidBytes, Vec)> = (0..block_count) + .map(|i| { + oracle.put(i); + (test_cid(i), block_data(i)) + }) + .collect(); + blocks.chunks(20).for_each(|chunk| { + store.put_blocks_blocking(chunk.to_vec()).unwrap(); + }); + + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + let stop = AtomicBool::new(false); + + std::thread::scope(|s| { + let deleter = s.spawn(|| { + let mut rng = Rng::new(seed); + std::iter::from_fn(|| (!stop.load(Ordering::Relaxed)).then_some(())) + .fold(0u32, |deleted_count, ()| { + let target = rng.range_u32(block_count); + let inc = if oracle.delete(target) { + let _ = + store.apply_commit_blocking(vec![], vec![test_cid(target)]); + 1 + } else { + 0 + }; + std::thread::sleep(std::time::Duration::from_millis(1)); + deleted_count + inc + }) + }); + + let gc_thread = s.spawn(|| { + std::iter::from_fn(|| (!stop.load(Ordering::Relaxed)).then_some(())) + .for_each(|()| { + let _ = store.apply_commit_blocking(vec![], vec![]); + std::thread::sleep(std::time::Duration::from_millis(3)); + if let Ok(files) = store.list_data_files() { + files + .iter() + .copied() + .take(files.len().saturating_sub(1)) + .for_each(|fid| { + let _ = store.compact_file(fid, 0); + }); + } + std::thread::sleep(std::time::Duration::from_millis(5)); + }); + }); + + std::thread::sleep(std::time::Duration::from_millis(300)); + stop.store(true, Ordering::Relaxed); + + let deleted_count = deleter.join().unwrap(); + gc_thread.join().unwrap(); + + assert!( + deleted_count > 0, + "seed={seed} deleter must have deleted at least one block" + ); + + let live = oracle.snapshot_live(); + live.iter().for_each(|&s| { + let data = store.get_block_sync(&test_cid(s)).unwrap(); + assert!( + data.is_some(), + "seed={seed} live block {s} (refcount={}) must be readable after concurrent delete+GC", + oracle.refcount(s) + ); + }); + }); + }); + }); +} + +#[test] +fn sim_gc_compaction_crash_at_each_substep() { + with_runtime(|| { + let seed_range = match sim_single_seed() { + Some(s) => s..s + 1, + None => 0..200u64, + }; + + seed_range.into_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let live_count = ((seed % 20) as u32) + 10; + let dead_count = ((seed % 15) as u32) + 5; + let live_seeds: Vec = (0..live_count).collect(); + let dead_seeds: Vec = (live_count..live_count + dead_count).collect(); + + { + let store = TranquilBlockStore::open(small_blockstore_config(dir.path())).unwrap(); + + let all_blocks: Vec<(CidBytes, Vec)> = live_seeds + .iter() + .chain(dead_seeds.iter()) + .map(|&s| (test_cid(s), block_data(s))) + .collect(); + store.put_blocks_blocking(all_blocks).unwrap(); + + let deletes: Vec = dead_seeds.iter().map(|&s| test_cid(s)).collect(); + store.apply_commit_blocking(vec![], deletes).unwrap(); + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + let files = store.list_data_files().unwrap(); + let sealed: Vec = files + .iter() + .copied() + .take(files.len().saturating_sub(1)) + .collect(); + + sealed.iter().for_each(|&fid| { + let _ = store.compact_file(fid, 0); + }); + } + + let store = TranquilBlockStore::open(small_blockstore_config(dir.path())).unwrap(); + + live_seeds.iter().for_each(|&s| { + let data = store.get_block_sync(&test_cid(s)).unwrap(); + assert!( + data.is_some(), + "seed={seed} live block {s} must survive compaction+reopen" + ); + assert_eq!( + &data.unwrap()[..4], + &s.to_le_bytes(), + "seed={seed} live block {s} data mismatch after compaction+reopen" + ); + }); + }); + }); +} + +#[test] +fn sim_gc_compacted_files_contain_all_live_blocks() { + with_runtime(|| { + let seed_range = match sim_single_seed() { + Some(s) => s..s + 1, + None => 0..500u64, + }; + + seed_range.into_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(small_blockstore_config(dir.path())).unwrap(); + + let total = ((seed % 100) as u32) + 50; + let blocks: Vec<(CidBytes, Vec)> = + (0..total).map(|i| (test_cid(i), block_data(i))).collect(); + blocks.chunks(10).for_each(|chunk| { + store.put_blocks_blocking(chunk.to_vec()).unwrap(); + }); + + let kill_set: HashSet = (0..total) + .filter(|i| { + let hash = i.wrapping_mul(2654435761).wrapping_add(seed as u32); + hash % 3 == 0 + }) + .collect(); + let kill_cids: Vec = kill_set.iter().map(|&i| test_cid(i)).collect(); + store.apply_commit_blocking(vec![], kill_cids).unwrap(); + + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + compact_all_sealed(&store); + + let live_set: HashSet = (0..total).filter(|i| !kill_set.contains(i)).collect(); + + live_set.iter().for_each(|&s| { + let data = store.get_block_sync(&test_cid(s)).unwrap(); + assert!( + data.is_some(), + "seed={seed} live block {s} must be in compacted files" + ); + assert_eq!( + &data.unwrap()[..4], + &s.to_le_bytes(), + "seed={seed} live block {s} data mismatch" + ); + }); + + let dead = collect_all_dead(&store); + live_set.iter().for_each(|&s| { + assert!( + !dead.contains(&test_cid(s)), + "seed={seed} live block {s} must not be dead after compaction" + ); + }); + }); + }); +} + +#[test] +fn sim_gc_orphan_detection_after_crash_between_compact_and_delete() { + with_runtime(|| { + let seed_range = match sim_single_seed() { + Some(s) => s..s + 1, + None => 0..200u64, + }; + + seed_range.into_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let live_count = ((seed % 20) as u32) + 5; + let dead_count = ((seed % 10) as u32) + 3; + let padding_count = ((seed % 40) as u32) + 10; + let live_seeds: Vec = (0..live_count).collect(); + let dead_seeds: Vec = (live_count..live_count + dead_count).collect(); + let padding_base = live_count + dead_count + 5000; + + let store = TranquilBlockStore::open(small_blockstore_config(dir.path())).unwrap(); + + let all_blocks: Vec<(CidBytes, Vec)> = live_seeds + .iter() + .chain(dead_seeds.iter()) + .map(|&s| (test_cid(s), block_data(s))) + .collect(); + store.put_blocks_blocking(all_blocks).unwrap(); + + let padding: Vec<(CidBytes, Vec)> = (padding_base..padding_base + padding_count) + .map(|s| (test_cid(s), vec![0xAAu8; 256])) + .collect(); + store.put_blocks_blocking(padding).unwrap(); + + let deletes: Vec = dead_seeds.iter().map(|&s| test_cid(s)).collect(); + store.apply_commit_blocking(vec![], deletes).unwrap(); + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + let files = store.list_data_files().unwrap(); + let sealed: Vec = files + .iter() + .copied() + .take(files.len().saturating_sub(1)) + .collect(); + + sealed.iter().for_each(|&fid| { + let info = store.liveness_info(fid).unwrap(); + if info.ratio() < 1.0 && info.total_blocks > 0 { + let _ = store.compact_file(fid, 0); + } + }); + + live_seeds.iter().for_each(|&s| { + let data = store.get_block_sync(&test_cid(s)).unwrap(); + assert!( + data.is_some(), + "seed={seed} live block {s} must survive even with potential orphan data files" + ); + }); + }); + }); +} diff --git a/crates/tranquil-store/tests/sim_metastore.rs b/crates/tranquil-store/tests/sim_metastore.rs new file mode 100644 index 0000000..bfe4086 --- /dev/null +++ b/crates/tranquil-store/tests/sim_metastore.rs @@ -0,0 +1,1048 @@ +mod common; + +use std::sync::Arc; + +use rayon::prelude::*; +use tokio::sync::oneshot; +use tranquil_db_traits::{ + ApplyCommitInput, Backlink, BacklinkPath, CommitEventData, RecordDelete, RecordUpsert, + RepoEventType, +}; +use tranquil_store::eventlog::{EventLogBridge, EventSequence}; +use tranquil_store::metastore::handler::{CommitRequest, HandlerPool, MetastoreRequest}; +use tranquil_store::metastore::partitions::Partition; +use tranquil_store::{sim_seed_range, sim_single_seed}; +use tranquil_types::{AtUri, CidLink, Nsid, Rkey}; + +use common::{ + NAMES, open_test_stores, test_cid_link, test_did, test_handle, test_uuid, with_runtime, +}; + +const CACHE_SIZE: u64 = 16 * 1024 * 1024; +const MAX_FILE_SIZE: u64 = tranquil_store::blockstore::DEFAULT_MAX_FILE_SIZE; + +fn collection_nsid(idx: u64) -> Nsid { + let collections = [ + "app.bsky.feed.post", + "app.bsky.feed.like", + "app.bsky.graph.follow", + "app.bsky.feed.repost", + ]; + Nsid::new(collections[(idx as usize) % collections.len()]).unwrap() +} + +fn test_rkey(idx: u64) -> Rkey { + Rkey::new(format!("3k{idx:06x}")).unwrap() +} + +fn test_at_uri(did: &tranquil_types::Did, collection: &Nsid, rkey: &Rkey) -> AtUri { + AtUri::new(format!( + "at://{}/{}/{}", + did.as_str(), + collection.as_str(), + rkey.as_str() + )) + .unwrap() +} + +fn block_cid_bytes(seed: u64) -> Vec { + let digest: [u8; 32] = std::array::from_fn(|i| ((seed + i as u64) & 0xFF) as u8); + let mh = multihash::Multihash::<64>::wrap(0x12, &digest).unwrap(); + cid::Cid::new_v1(0x71, mh).to_bytes() +} + +fn build_commit_event( + did: &tranquil_types::Did, + prev_cid: &CidLink, + new_cid: &CidLink, + rev: &str, +) -> CommitEventData { + CommitEventData { + did: did.clone(), + event_type: RepoEventType::Commit, + commit_cid: Some(new_cid.clone()), + prev_cid: Some(prev_cid.clone()), + ops: None, + blobs: None, + blocks: None, + prev_data_cid: None, + rev: Some(rev.to_owned()), + } +} + +struct MetastoreTestHarness { + metastore: tranquil_store::metastore::Metastore, + eventlog: Arc>, +} + +impl MetastoreTestHarness { + fn open(base: &std::path::Path) -> Self { + let stores = open_test_stores(base, MAX_FILE_SIZE, CACHE_SIZE); + Self { + metastore: stores.metastore, + eventlog: stores.eventlog, + } + } + + fn bridge(&self) -> Arc> { + Arc::new(EventLogBridge::new(Arc::clone(&self.eventlog))) + } + + fn apply_commit( + &self, + input: ApplyCommitInput, + ) -> Result { + let bridge = self.bridge(); + let commit_ops = self.metastore.commit_ops(bridge); + commit_ops.apply_commit(input) + } + + fn create_repo(&self, idx: u64) -> (uuid::Uuid, tranquil_types::Did, CidLink) { + let uid = test_uuid(idx); + let did = test_did(idx); + let handle = test_handle(idx); + let cid = test_cid_link((idx & 0xFF) as u8); + self.metastore + .repo_ops() + .create_repo( + self.metastore.database(), + uid, + &did, + &handle, + &cid, + &format!("rev0_{idx}"), + ) + .unwrap_or_else(|e| panic!("create_repo idx={idx}: {e:?}")); + (uid, did, cid) + } + + fn recover_mutations(&self) { + let bridge = self.bridge(); + let event_ops = self.metastore.event_ops(bridge); + let indexes = self.metastore.partition(Partition::Indexes).clone(); + let _ = event_ops.recover_metastore_mutations(&indexes).unwrap(); + } + + fn read_cursor(&self) -> Option { + let bridge = self.bridge(); + let event_ops = self.metastore.event_ops(bridge); + event_ops.read_last_applied_cursor().unwrap() + } +} + +#[test] +fn sim_apply_commit_crash_before_batch_commit_is_invisible() { + with_runtime(|| { + sim_seed_range().into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let name = NAMES[(seed as usize) % NAMES.len()]; + let record_count = (seed % 5) + 1; + + { + let h = MetastoreTestHarness::open(dir.path()); + let (uid, did, root_cid) = h.create_repo(seed); + h.metastore + .persist() + .unwrap_or_else(|e| panic!("seed={seed} persist after create: {e:?}")); + + let new_cid = test_cid_link(((seed + 50) & 0xFF) as u8); + let rev = format!("rev1_{name}{seed}"); + + let upserts: Vec = (0..record_count) + .map(|i| { + let idx = seed * 100 + i; + RecordUpsert { + collection: collection_nsid(idx), + rkey: test_rkey(idx), + cid: test_cid_link(((idx + 10) & 0xFF) as u8), + } + }) + .collect(); + + let input = ApplyCommitInput { + user_id: uid, + did: did.clone(), + expected_root_cid: Some(root_cid.clone()), + new_root_cid: new_cid.clone(), + new_rev: rev.to_owned(), + record_upserts: upserts, + record_deletes: vec![], + backlinks_to_add: vec![], + backlinks_to_remove: vec![], + new_block_cids: vec![], + obsolete_block_cids: vec![], + commit_event: build_commit_event(&did, &root_cid, &new_cid, &rev), + }; + + let _ = h.apply_commit(input); + } + + { + let h = MetastoreTestHarness::open(dir.path()); + h.recover_mutations(); + + let uid = test_uuid(seed); + let meta = h + .metastore + .repo_ops() + .get_repo_meta(uid) + .unwrap_or_else(|e| panic!("seed={seed} get_repo_meta: {e:?}")); + assert!( + meta.is_some(), + "seed={seed} repo must survive crash (was persisted before commit)" + ); + + let cursor = h.read_cursor(); + let eventlog_max = h.eventlog.max_seq(); + + let expected_record_count = i64::try_from(record_count).unwrap(); + + match eventlog_max == EventSequence::BEFORE_ALL { + true => { + let (_, repo_meta) = meta.unwrap(); + assert_eq!( + repo_meta.repo_rev, + format!("rev0_{seed}"), + "seed={seed} repo rev must be initial if eventlog empty" + ); + } + false => { + assert!( + cursor.is_some(), + "seed={seed} cursor must exist if events were recovered" + ); + } + } + + let record_ops = h.metastore.record_ops(); + let count = record_ops + .count_records(uid) + .unwrap_or_else(|e| panic!("seed={seed} count_records: {e:?}")); + + match cursor { + Some(c) if c > 0 => { + assert_eq!( + count, expected_record_count, + "seed={seed} after recovery, records must match committed count" + ); + } + _ => { + assert!( + count == 0 || count == expected_record_count, + "seed={seed} records must be 0 (not committed) or full (recovered): got {count}" + ); + } + } + } + }); + }); +} + +#[test] +fn sim_apply_commit_atomicity_all_or_nothing() { + with_runtime(|| { + sim_seed_range().into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let record_count = (seed % 8) + 2; + let name = NAMES[(seed as usize) % NAMES.len()]; + + { + let h = MetastoreTestHarness::open(dir.path()); + let (uid, did, root_cid) = h.create_repo(seed); + h.metastore + .persist() + .unwrap_or_else(|e| panic!("seed={seed} persist after create: {e:?}")); + + let new_cid = test_cid_link(((seed + 77) & 0xFF) as u8); + let rev = format!("rev1_{name}{seed}"); + let collection = collection_nsid(seed); + + let upserts: Vec = (0..record_count) + .map(|i| RecordUpsert { + collection: collection.clone(), + rkey: test_rkey(seed * 100 + i), + cid: test_cid_link(((seed * 100 + i + 10) & 0xFF) as u8), + }) + .collect(); + + let backlink_count = std::cmp::min(record_count, 3); + let backlinks: Vec = (0..backlink_count) + .map(|i| { + let idx = seed * 100 + i; + let src_uri = test_at_uri(&did, &collection, &test_rkey(idx)); + let target_did = test_did(seed + 999); + let target_uri = test_at_uri( + &target_did, + &Nsid::new("app.bsky.feed.post").unwrap(), + &test_rkey(idx + 5000), + ); + Backlink { + uri: src_uri, + path: BacklinkPath::Subject, + link_to: target_uri.as_str().to_owned(), + } + }) + .collect(); + + let block_count = std::cmp::min(record_count, 4); + let new_block_cids: Vec> = (0..block_count) + .map(|i| block_cid_bytes(seed * 100 + i + 30)) + .collect(); + + let input = ApplyCommitInput { + user_id: uid, + did: did.clone(), + expected_root_cid: Some(root_cid.clone()), + new_root_cid: new_cid.clone(), + new_rev: rev.to_owned(), + record_upserts: upserts, + record_deletes: vec![], + backlinks_to_add: backlinks, + backlinks_to_remove: vec![], + new_block_cids, + obsolete_block_cids: vec![], + commit_event: build_commit_event(&did, &root_cid, &new_cid, &rev), + }; + + let result = h.apply_commit(input); + assert!( + result.is_ok(), + "seed={seed} apply_commit must succeed: {:?}", + result.err() + ); + h.metastore + .persist() + .unwrap_or_else(|e| panic!("seed={seed} persist: {e:?}")); + } + + { + let h = MetastoreTestHarness::open(dir.path()); + h.recover_mutations(); + let uid = test_uuid(seed); + + let (_, repo_meta) = h + .metastore + .repo_ops() + .get_repo_meta(uid) + .unwrap_or_else(|e| panic!("seed={seed} get_repo_meta: {e:?}")) + .unwrap_or_else(|| panic!("seed={seed} repo meta missing")); + let rev = format!("rev1_{name}{seed}"); + assert_eq!( + repo_meta.repo_rev, rev, + "seed={seed} repo rev must match committed value" + ); + + let count = h + .metastore + .record_ops() + .count_records(uid) + .unwrap_or_else(|e| panic!("seed={seed} count_records: {e:?}")); + let expected = i64::try_from(record_count).unwrap(); + assert_eq!( + count, expected, + "seed={seed} all records must be visible after recovery" + ); + + let cursor = h.read_cursor(); + assert!( + cursor.is_some() && cursor.unwrap() > 0, + "seed={seed} cursor must advance after successful commit" + ); + + let eventlog_max = h.eventlog.max_seq(); + assert!( + eventlog_max.raw() > 0, + "seed={seed} eventlog must have at least one event" + ); + } + }); + }); +} + +#[test] +fn sim_crash_recovery_cursor_tracks_last_durable_commit() { + with_runtime(|| { + sim_seed_range().into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let commit_count = (seed % 5) + 2; + let crash_after = seed % commit_count; + + let persisted_record_count = { + let h = MetastoreTestHarness::open(dir.path()); + let (uid, did, root_cid) = h.create_repo(seed); + h.metastore + .persist() + .unwrap_or_else(|e| panic!("seed={seed} persist after create: {e:?}")); + + (0..commit_count) + .fold((root_cid, 0i64), |(prev_cid, persisted), commit_idx| { + let new_cid = + test_cid_link(((seed + commit_idx + 50) & 0xFF) as u8); + let rev = format!("rev{commit_idx}_{seed}"); + let collection = collection_nsid(seed + commit_idx); + let rkey = test_rkey(seed * 1000 + commit_idx); + + let input = ApplyCommitInput { + user_id: uid, + did: did.clone(), + expected_root_cid: Some(prev_cid.clone()), + new_root_cid: new_cid.clone(), + new_rev: rev.to_owned(), + record_upserts: vec![RecordUpsert { + collection, + rkey, + cid: test_cid_link( + ((seed + commit_idx + 80) & 0xFF) as u8, + ), + }], + record_deletes: vec![], + backlinks_to_add: vec![], + backlinks_to_remove: vec![], + new_block_cids: vec![], + obsolete_block_cids: vec![], + commit_event: build_commit_event( + &did, &prev_cid, &new_cid, &rev, + ), + }; + + let result = h.apply_commit(input).unwrap_or_else(|e| { + panic!("seed={seed} commit {commit_idx}: {e:?}") + }); + assert!( + result.seq > 0, + "seed={seed} commit {commit_idx} seq must be positive" + ); + + let new_persisted = match commit_idx <= crash_after { + true => { + h.metastore.persist().unwrap_or_else(|e| { + panic!("seed={seed} persist at {commit_idx}: {e:?}") + }); + h.eventlog.sync().unwrap_or_else(|e| { + panic!("seed={seed} sync at {commit_idx}: {e:?}") + }); + i64::try_from(commit_idx + 1).unwrap() + } + false => persisted, + }; + (new_cid, new_persisted) + }) + .1 + }; + + { + let h = MetastoreTestHarness::open(dir.path()); + h.recover_mutations(); + let uid = test_uuid(seed); + + let _ = h + .metastore + .repo_ops() + .get_repo_meta(uid) + .unwrap_or_else(|e| panic!("seed={seed} get_repo_meta: {e:?}")) + .unwrap_or_else(|| panic!("seed={seed} repo meta missing")); + + let cursor = h.read_cursor(); + assert!( + cursor.is_some(), + "seed={seed} cursor must exist after commits + recovery" + ); + + let record_count = h + .metastore + .record_ops() + .count_records(uid) + .unwrap_or_else(|e| panic!("seed={seed} count_records: {e:?}")); + let max_expected = i64::try_from(commit_count).unwrap(); + assert!( + record_count >= persisted_record_count, + "seed={seed} recovered records ({record_count}) must be >= persisted count ({persisted_record_count})" + ); + assert!( + record_count <= max_expected, + "seed={seed} records ({record_count}) must not exceed total commits ({commit_count})" + ); + } + }); + }); +} + +#[test] +fn sim_multi_commit_crash_cycle_consistency() { + with_runtime(|| { + sim_seed_range().into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let cycles = ((seed % 4) as usize) + 2; + + let (uid, did) = { + let h = MetastoreTestHarness::open(dir.path()); + let (uid, did, _) = h.create_repo(seed); + h.metastore + .persist() + .unwrap_or_else(|e| panic!("seed={seed} initial persist: {e:?}")); + (uid, did) + }; + + let (total_records, last_rev) = (0..cycles).fold( + (0i64, String::new()), + |(prev_total, _), cycle| { + let records_this_cycle = (seed.wrapping_add(cycle as u64) % 4) + 1; + + let h = MetastoreTestHarness::open(dir.path()); + h.recover_mutations(); + + let repo_info = h + .metastore + .repo_ops() + .get_repo(uid) + .unwrap_or_else(|e| { + panic!("seed={seed} cycle={cycle} get_repo: {e:?}") + }); + assert!( + repo_info.is_some(), + "seed={seed} cycle={cycle} repo must exist" + ); + let current_cid = repo_info.unwrap().repo_root_cid; + + let actual_records = h + .metastore + .record_ops() + .count_records(uid) + .unwrap_or_else(|e| { + panic!("seed={seed} cycle={cycle} count_records: {e:?}") + }); + assert!( + actual_records >= prev_total, + "seed={seed} cycle={cycle} records ({actual_records}) must be >= previous total ({prev_total})" + ); + + let collection = collection_nsid(seed + cycle as u64); + let upserts: Vec = (0..records_this_cycle) + .map(|i| { + let idx = seed * 10000 + (cycle as u64) * 100 + i; + RecordUpsert { + collection: collection.clone(), + rkey: test_rkey(idx), + cid: test_cid_link(((idx + 20) & 0xFF) as u8), + } + }) + .collect(); + + let new_cid = + test_cid_link(((seed + cycle as u64 + 100) & 0xFF) as u8); + let rev = format!("rev{cycle}_{seed}"); + + let input = ApplyCommitInput { + user_id: uid, + did: did.clone(), + expected_root_cid: Some(current_cid.clone()), + new_root_cid: new_cid.clone(), + new_rev: rev.to_owned(), + record_upserts: upserts, + record_deletes: vec![], + backlinks_to_add: vec![], + backlinks_to_remove: vec![], + new_block_cids: vec![], + obsolete_block_cids: vec![], + commit_event: build_commit_event( + &did, ¤t_cid, &new_cid, &rev, + ), + }; + + h.apply_commit(input).unwrap_or_else(|e| { + panic!("seed={seed} cycle={cycle} apply_commit: {e:?}") + }); + h.metastore.persist().unwrap_or_else(|e| { + panic!("seed={seed} cycle={cycle} persist: {e:?}") + }); + h.eventlog.sync().unwrap_or_else(|e| { + panic!("seed={seed} cycle={cycle} sync: {e:?}") + }); + + let new_total = + actual_records + i64::try_from(records_this_cycle).unwrap(); + (new_total, rev) + }, + ); + + { + let h = MetastoreTestHarness::open(dir.path()); + h.recover_mutations(); + let (_, repo_meta) = h + .metastore + .repo_ops() + .get_repo_meta(uid) + .unwrap_or_else(|e| panic!("seed={seed} final get_repo_meta: {e:?}")) + .unwrap_or_else(|| panic!("seed={seed} final repo meta missing")); + assert_eq!( + repo_meta.repo_rev, last_rev, + "seed={seed} final rev must match last committed" + ); + + let final_count = h + .metastore + .record_ops() + .count_records(uid) + .unwrap_or_else(|e| panic!("seed={seed} final count_records: {e:?}")); + assert_eq!( + final_count, total_records, + "seed={seed} final record count must match oracle" + ); + + let cursor = h + .read_cursor() + .unwrap_or_else(|| panic!("seed={seed} cursor missing after full recovery")); + let eventlog_max = h.eventlog.max_seq(); + assert_eq!( + cursor, + eventlog_max.raw(), + "seed={seed} cursor must equal eventlog max after full recovery" + ); + } + }); + }); +} + +#[test] +fn sim_handler_pool_shutdown_with_inflight_commits() { + let rt = tokio::runtime::Runtime::new().unwrap(); + + let seed_range = match sim_single_seed() { + Some(s) => s..s + 1, + None => 0..std::cmp::min(sim_seed_range().end, 100), + }; + + seed_range.into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let stores = open_test_stores(dir.path(), MAX_FILE_SIZE, CACHE_SIZE); + let bridge = Arc::new(EventLogBridge::new(Arc::clone(&stores.eventlog))); + let name = NAMES[(seed as usize) % NAMES.len()]; + + let repo_count = (seed % 5) + 2; + let repos: Vec<(uuid::Uuid, tranquil_types::Did, CidLink)> = (0..repo_count) + .map(|i| { + let idx = seed * 100 + i; + let uid = test_uuid(idx); + let did = test_did(idx); + let handle = test_handle(idx); + let cid = test_cid_link((idx & 0xFF) as u8); + stores + .metastore + .repo_ops() + .create_repo( + stores.metastore.database(), + uid, + &did, + &handle, + &cid, + &format!("rev0_{name}{idx}"), + ) + .unwrap_or_else(|e| panic!("seed={seed} create_repo idx={idx}: {e:?}")); + (uid, did, cid) + }) + .collect(); + + stores + .metastore + .persist() + .unwrap_or_else(|e| panic!("seed={seed} persist: {e:?}")); + + let pool = HandlerPool::spawn(stores.metastore.clone(), Arc::clone(&bridge), None, Some(2)); + + let receivers: Vec<(u64, oneshot::Receiver<_>)> = repos + .iter() + .enumerate() + .map(|(i, (uid, did, root_cid))| { + let idx = seed * 100 + i as u64; + let new_cid = test_cid_link(((seed + i as u64 + 50) & 0xFF) as u8); + let rev = format!("rev1_{name}{idx}"); + let input = ApplyCommitInput { + user_id: *uid, + did: did.clone(), + expected_root_cid: Some(root_cid.clone()), + new_root_cid: new_cid.clone(), + new_rev: rev.to_owned(), + record_upserts: vec![RecordUpsert { + collection: collection_nsid(seed + i as u64), + rkey: test_rkey(idx), + cid: test_cid_link(((seed + i as u64 + 30) & 0xFF) as u8), + }], + record_deletes: vec![], + backlinks_to_add: vec![], + backlinks_to_remove: vec![], + new_block_cids: vec![], + obsolete_block_cids: vec![], + commit_event: build_commit_event(did, root_cid, &new_cid, &rev), + }; + let (tx, rx) = oneshot::channel(); + pool.send(MetastoreRequest::Commit(Box::new( + CommitRequest::ApplyCommit { + input: Box::new(input), + tx, + }, + ))) + .unwrap_or_else(|e| panic!("seed={seed} idx={idx} send: {e:?}")); + (idx, rx) + }) + .collect(); + + receivers.into_iter().for_each(|(idx, rx)| { + let result = rt.block_on(rx); + match result { + Ok(Ok(commit_result)) => { + assert!( + commit_result.seq > 0, + "seed={seed} idx={idx} commit seq must be positive" + ); + } + Ok(Err(e)) => { + panic!("seed={seed} idx={idx} commit failed: {e:?}"); + } + Err(_) => { + panic!("seed={seed} idx={idx} channel dropped before response"); + } + } + }); + + rt.block_on(pool.close()); + + stores + .metastore + .persist() + .unwrap_or_else(|e| panic!("seed={seed} final persist: {e:?}")); + stores + .eventlog + .sync() + .unwrap_or_else(|e| panic!("seed={seed} final sync: {e:?}")); + + drop(pool); + drop(bridge); + drop(stores); + + let h = MetastoreTestHarness::open(dir.path()); + h.recover_mutations(); + + (0..repo_count).for_each(|i| { + let idx = seed * 100 + i; + let uid = test_uuid(idx); + let expected_rev = format!("rev1_{name}{idx}"); + + let meta = h + .metastore + .repo_ops() + .get_repo_meta(uid) + .unwrap_or_else(|e| panic!("seed={seed} idx={idx} get_repo_meta: {e:?}")); + assert!( + meta.is_some(), + "seed={seed} repo idx={idx} must exist after pool shutdown" + ); + let (_, rm) = meta.unwrap(); + assert_eq!( + rm.repo_rev, expected_rev, + "seed={seed} repo idx={idx} rev must match committed value" + ); + + let count = h + .metastore + .record_ops() + .count_records(uid) + .unwrap_or_else(|e| panic!("seed={seed} idx={idx} count_records: {e:?}")); + assert_eq!( + count, 1, + "seed={seed} repo idx={idx} must have 1 record after commit" + ); + }); + }); +} + +#[test] +fn sim_record_deletes_through_crash_recovery() { + with_runtime(|| { + sim_seed_range().into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let insert_count = (seed % 6) + 2; + let delete_count = (seed % insert_count) + 1; + + { + let h = MetastoreTestHarness::open(dir.path()); + let (uid, did, root_cid) = h.create_repo(seed); + h.metastore + .persist() + .unwrap_or_else(|e| panic!("seed={seed} persist after create: {e:?}")); + + let collection = collection_nsid(seed); + + let upserts: Vec = (0..insert_count) + .map(|i| { + let idx = seed * 100 + i; + RecordUpsert { + collection: collection.clone(), + rkey: test_rkey(idx), + cid: test_cid_link(((idx + 10) & 0xFF) as u8), + } + }) + .collect(); + + let mid_cid = test_cid_link(((seed + 60) & 0xFF) as u8); + let rev1 = format!("rev1_{seed}"); + let insert_input = ApplyCommitInput { + user_id: uid, + did: did.clone(), + expected_root_cid: Some(root_cid.clone()), + new_root_cid: mid_cid.clone(), + new_rev: rev1.clone(), + record_upserts: upserts, + record_deletes: vec![], + backlinks_to_add: vec![], + backlinks_to_remove: vec![], + new_block_cids: vec![], + obsolete_block_cids: vec![], + commit_event: build_commit_event(&did, &root_cid, &mid_cid, &rev1), + }; + h.apply_commit(insert_input) + .unwrap_or_else(|e| panic!("seed={seed} insert commit: {e:?}")); + h.metastore + .persist() + .unwrap_or_else(|e| panic!("seed={seed} persist after insert: {e:?}")); + h.eventlog + .sync() + .unwrap_or_else(|e| panic!("seed={seed} sync after insert: {e:?}")); + + let deletes: Vec = (0..delete_count) + .map(|i| { + let idx = seed * 100 + i; + RecordDelete { + collection: collection.clone(), + rkey: test_rkey(idx), + } + }) + .collect(); + + let final_cid = test_cid_link(((seed + 70) & 0xFF) as u8); + let rev2 = format!("rev2_{seed}"); + let delete_input = ApplyCommitInput { + user_id: uid, + did: did.clone(), + expected_root_cid: Some(mid_cid.clone()), + new_root_cid: final_cid.clone(), + new_rev: rev2.clone(), + record_upserts: vec![], + record_deletes: deletes, + backlinks_to_add: vec![], + backlinks_to_remove: vec![], + new_block_cids: vec![], + obsolete_block_cids: vec![], + commit_event: build_commit_event(&did, &mid_cid, &final_cid, &rev2), + }; + h.apply_commit(delete_input) + .unwrap_or_else(|e| panic!("seed={seed} delete commit: {e:?}")); + } + + { + let h = MetastoreTestHarness::open(dir.path()); + h.recover_mutations(); + let uid = test_uuid(seed); + let collection = collection_nsid(seed); + + let (_, repo_meta) = h + .metastore + .repo_ops() + .get_repo_meta(uid) + .unwrap_or_else(|e| panic!("seed={seed} get_repo_meta: {e:?}")) + .unwrap_or_else(|| panic!("seed={seed} repo meta missing")); + + let cursor = h.read_cursor(); + let eventlog_max = h.eventlog.max_seq(); + + match eventlog_max.raw() > 0 && cursor.is_some() { + true => { + assert_eq!( + repo_meta.repo_rev, + format!("rev2_{seed}"), + "seed={seed} rev must reflect delete commit after recovery" + ); + + let surviving = i64::try_from(insert_count - delete_count).unwrap(); + let count = h + .metastore + .record_ops() + .count_records(uid) + .unwrap_or_else(|e| panic!("seed={seed} count_records: {e:?}")); + assert_eq!( + count, surviving, + "seed={seed} record count must reflect deletes after recovery" + ); + + (0..delete_count).for_each(|i| { + let idx = seed * 100 + i; + let rkey = test_rkey(idx); + let cid = h + .metastore + .record_ops() + .get_record_cid(uid, &collection, &rkey) + .unwrap_or_else(|e| { + panic!("seed={seed} get_record_cid idx={idx}: {e:?}") + }); + assert!( + cid.is_none(), + "seed={seed} record idx={idx} must be deleted after recovery" + ); + }); + + (delete_count..insert_count).for_each(|i| { + let idx = seed * 100 + i; + let rkey = test_rkey(idx); + let cid = h + .metastore + .record_ops() + .get_record_cid(uid, &collection, &rkey) + .unwrap_or_else(|e| { + panic!("seed={seed} get_record_cid idx={idx}: {e:?}") + }); + assert!( + cid.is_some(), + "seed={seed} record idx={idx} must survive (not deleted)" + ); + }); + } + false => { + let count = h + .metastore + .record_ops() + .count_records(uid) + .unwrap_or_else(|e| panic!("seed={seed} count_records: {e:?}")); + let inserted = i64::try_from(insert_count).unwrap(); + let surviving = i64::try_from(insert_count - delete_count).unwrap(); + assert!( + count == inserted || count == surviving, + "seed={seed} records must be {inserted} (insert only) or {surviving} (deletes applied): got {count}" + ); + } + } + } + }); + }); +} + +#[test] +fn sim_obsolete_block_cids_through_crash_recovery() { + with_runtime(|| { + sim_seed_range().into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let block_count = (seed % 6) + 2; + let obsolete_count = (seed % block_count) + 1; + + { + let h = MetastoreTestHarness::open(dir.path()); + let (uid, did, root_cid) = h.create_repo(seed); + h.metastore + .persist() + .unwrap_or_else(|e| panic!("seed={seed} persist after create: {e:?}")); + + let new_blocks: Vec> = (0..block_count) + .map(|i| block_cid_bytes(seed * 100 + i)) + .collect(); + + let mid_cid = test_cid_link(((seed + 60) & 0xFF) as u8); + let rev1 = format!("rev1_{seed}"); + let insert_input = ApplyCommitInput { + user_id: uid, + did: did.clone(), + expected_root_cid: Some(root_cid.clone()), + new_root_cid: mid_cid.clone(), + new_rev: rev1.clone(), + record_upserts: vec![RecordUpsert { + collection: collection_nsid(seed), + rkey: test_rkey(seed), + cid: test_cid_link(((seed + 10) & 0xFF) as u8), + }], + record_deletes: vec![], + backlinks_to_add: vec![], + backlinks_to_remove: vec![], + new_block_cids: new_blocks, + obsolete_block_cids: vec![], + commit_event: build_commit_event(&did, &root_cid, &mid_cid, &rev1), + }; + h.apply_commit(insert_input) + .unwrap_or_else(|e| panic!("seed={seed} insert commit: {e:?}")); + h.metastore + .persist() + .unwrap_or_else(|e| panic!("seed={seed} persist after insert: {e:?}")); + h.eventlog + .sync() + .unwrap_or_else(|e| panic!("seed={seed} sync after insert: {e:?}")); + + let obsolete: Vec> = (0..obsolete_count) + .map(|i| block_cid_bytes(seed * 100 + i)) + .collect(); + + let final_cid = test_cid_link(((seed + 70) & 0xFF) as u8); + let rev2 = format!("rev2_{seed}"); + let obsolete_input = ApplyCommitInput { + user_id: uid, + did: did.clone(), + expected_root_cid: Some(mid_cid.clone()), + new_root_cid: final_cid.clone(), + new_rev: rev2.clone(), + record_upserts: vec![], + record_deletes: vec![], + backlinks_to_add: vec![], + backlinks_to_remove: vec![], + new_block_cids: vec![], + obsolete_block_cids: obsolete, + commit_event: build_commit_event(&did, &mid_cid, &final_cid, &rev2), + }; + h.apply_commit(obsolete_input) + .unwrap_or_else(|e| panic!("seed={seed} obsolete commit: {e:?}")); + } + + { + let h = MetastoreTestHarness::open(dir.path()); + h.recover_mutations(); + let uid = test_uuid(seed); + + let (_, repo_meta) = h + .metastore + .repo_ops() + .get_repo_meta(uid) + .unwrap_or_else(|e| panic!("seed={seed} get_repo_meta: {e:?}")) + .unwrap_or_else(|| panic!("seed={seed} repo meta missing")); + + let cursor = h.read_cursor(); + let eventlog_max = h.eventlog.max_seq(); + + let total_blocks = h + .metastore + .user_block_ops() + .count_user_blocks(uid) + .unwrap_or_else(|e| panic!("seed={seed} count_user_blocks: {e:?}")); + + match eventlog_max.raw() > 0 && cursor.is_some() { + true => { + assert_eq!( + repo_meta.repo_rev, + format!("rev2_{seed}"), + "seed={seed} rev must reflect obsolete commit after recovery" + ); + + let expected_remaining = + i64::try_from(block_count - obsolete_count).unwrap(); + assert_eq!( + total_blocks, expected_remaining, + "seed={seed} block count must reflect obsolete removals after recovery" + ); + } + false => { + let all_blocks = i64::try_from(block_count).unwrap(); + let after_obsolete = + i64::try_from(block_count - obsolete_count).unwrap(); + assert!( + total_blocks == all_blocks || total_blocks == after_obsolete, + "seed={seed} blocks must be {all_blocks} (insert only) or {after_obsolete} (obsolete applied): got {total_blocks}" + ); + } + } + } + }); + }); +} diff --git a/crates/tranquil-store/tests/sim_reachability.rs b/crates/tranquil-store/tests/sim_reachability.rs new file mode 100644 index 0000000..43d99e4 --- /dev/null +++ b/crates/tranquil-store/tests/sim_reachability.rs @@ -0,0 +1,337 @@ +mod common; + +use std::collections::HashSet; + +use rayon::prelude::*; +use tranquil_store::blockstore::{CidBytes, TranquilBlockStore}; +use tranquil_store::{sim_seed_range, sim_single_seed}; + +use common::{ + advance_epoch, block_data, collect_all_dead, compact_all_sealed, default_blockstore_config, + small_blockstore_config, test_cid, with_runtime, +}; + +#[test] +fn sim_reachability_detects_known_leaks() { + with_runtime(|| { + let seed_range = match sim_single_seed() { + Some(s) => s..s + 1, + None => 0..sim_seed_range().end.min(1000), + }; + + seed_range.into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(default_blockstore_config(dir.path())).unwrap(); + + let reachable_count = ((seed % 30) as u32) + 10; + let leaked_count = ((seed % 10) as u32) + 3; + + let reachable_cids: Vec = (0..reachable_count).map(test_cid).collect(); + let leaked_cids: Vec = (reachable_count..reachable_count + leaked_count) + .map(test_cid) + .collect(); + + let all_blocks: Vec<(CidBytes, Vec)> = reachable_cids + .iter() + .chain(leaked_cids.iter()) + .map(|&cid| { + ( + cid, + block_data(u32::from_le_bytes(cid[4..8].try_into().unwrap())), + ) + }) + .collect(); + store.put_blocks_blocking(all_blocks).unwrap(); + + let reachable_set: HashSet = reachable_cids.iter().copied().collect(); + let (leaked, live_scanned) = store + .find_leaked_refcounts(|cid| reachable_set.contains(cid)) + .unwrap(); + + assert_eq!( + live_scanned, + (reachable_count + leaked_count) as u64, + "seed={seed} must scan all live blocks" + ); + + let leaked_found: HashSet = leaked.iter().map(|(cid, _)| *cid).collect(); + leaked_cids.iter().for_each(|cid| { + assert!( + leaked_found.contains(cid), + "seed={seed} leaked block must be detected by reachability walk" + ); + }); + reachable_cids.iter().for_each(|cid| { + assert!( + !leaked_found.contains(cid), + "seed={seed} reachable block must NOT be flagged as leaked" + ); + }); + }); + }); +} + +#[test] +fn sim_reachability_repair_makes_leaked_blocks_gc_eligible() { + with_runtime(|| { + let seed_range = match sim_single_seed() { + Some(s) => s..s + 1, + None => 0..sim_seed_range().end.min(500), + }; + + seed_range.into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(default_blockstore_config(dir.path())).unwrap(); + + let reachable_count = ((seed % 20) as u32) + 5; + let leaked_count = ((seed % 8) as u32) + 2; + + let reachable_cids: Vec = (0..reachable_count).map(test_cid).collect(); + let leaked_cids: Vec = (reachable_count..reachable_count + leaked_count) + .map(test_cid) + .collect(); + + let all_blocks: Vec<(CidBytes, Vec)> = reachable_cids + .iter() + .chain(leaked_cids.iter()) + .map(|&cid| { + ( + cid, + block_data(u32::from_le_bytes(cid[4..8].try_into().unwrap())), + ) + }) + .collect(); + store.put_blocks_blocking(all_blocks).unwrap(); + + let reachable_set: HashSet = reachable_cids.iter().copied().collect(); + let (leaked, _) = store + .find_leaked_refcounts(|cid| reachable_set.contains(cid)) + .unwrap(); + + assert_eq!( + leaked.len(), + leaked_count as usize, + "seed={seed} must detect all leaked blocks" + ); + + let repaired = store.repair_leaked_refcounts(&leaked).unwrap(); + assert_eq!( + repaired, leaked_count as u64, + "seed={seed} must repair all leaked blocks" + ); + + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + let dead = collect_all_dead(&store); + leaked_cids.iter().for_each(|cid| { + assert!( + dead.contains(cid), + "seed={seed} repaired leaked block must now be GC-eligible" + ); + }); + reachable_cids.iter().for_each(|cid| { + assert!( + !dead.contains(cid), + "seed={seed} reachable block must NOT be GC-eligible" + ); + }); + }); + }); +} + +#[test] +fn sim_crash_retry_scenario_produces_leaked_refcounts() { + with_runtime(|| { + let seed_range = match sim_single_seed() { + Some(s) => s..s + 1, + None => 0..sim_seed_range().end.min(500), + }; + + seed_range.into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(default_blockstore_config(dir.path())).unwrap(); + + let shared_count = ((seed % 15) as u32) + 5; + let shared_cids: Vec = (0..shared_count).map(test_cid).collect(); + + let shared_blocks: Vec<(CidBytes, Vec)> = shared_cids + .iter() + .map(|&cid| { + ( + cid, + block_data(u32::from_le_bytes(cid[4..8].try_into().unwrap())), + ) + }) + .collect(); + store.put_blocks_blocking(shared_blocks.clone()).unwrap(); + + let retry_count = ((seed % 3) as usize) + 1; + (0..retry_count).for_each(|_| { + store.put_blocks_blocking(shared_blocks.clone()).unwrap(); + }); + + let reachable_set: HashSet = shared_cids.iter().copied().collect(); + let (leaked, live_scanned) = store + .find_leaked_refcounts(|cid| reachable_set.contains(cid)) + .unwrap(); + + assert_eq!( + live_scanned, shared_count as u64, + "seed={seed} all blocks are reachable, so live_scanned should equal total" + ); + assert!( + leaked.is_empty(), + "seed={seed} all blocks are reachable, none should be leaked" + ); + + let extra_count = ((seed % 5) as u32) + 2; + let extra_cids: Vec = (shared_count..shared_count + extra_count) + .map(test_cid) + .collect(); + let extra_blocks: Vec<(CidBytes, Vec)> = extra_cids + .iter() + .map(|&cid| { + ( + cid, + block_data(u32::from_le_bytes(cid[4..8].try_into().unwrap())), + ) + }) + .collect(); + store.put_blocks_blocking(extra_blocks).unwrap(); + + let (leaked_after, _) = store + .find_leaked_refcounts(|cid| reachable_set.contains(cid)) + .unwrap(); + + let leaked_cid_set: HashSet = + leaked_after.iter().map(|(cid, _)| *cid).collect(); + extra_cids.iter().for_each(|cid| { + assert!( + leaked_cid_set.contains(cid), + "seed={seed} extra block not in reachable set must be detected as leaked" + ); + }); + + let repaired = store.repair_leaked_refcounts(&leaked_after).unwrap(); + assert_eq!( + repaired, extra_count as u64, + "seed={seed} must repair exactly the extra blocks" + ); + }); + }); +} + +#[test] +fn sim_reachability_after_compaction() { + with_runtime(|| { + let seed_range = match sim_single_seed() { + Some(s) => s..s + 1, + None => 0..sim_seed_range().end.min(200), + }; + + seed_range.into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(small_blockstore_config(dir.path())).unwrap(); + + let total = ((seed % 40) as u32) + 20; + let blocks: Vec<(CidBytes, Vec)> = + (0..total).map(|i| (test_cid(i), block_data(i))).collect(); + blocks.chunks(10).for_each(|chunk| { + store.put_blocks_blocking(chunk.to_vec()).unwrap(); + }); + + let kill_mask: HashSet = (0..total) + .filter(|i| i.wrapping_mul(2654435761).wrapping_add(seed as u32) % 3 == 0) + .collect(); + let kill_cids: Vec = kill_mask.iter().map(|&i| test_cid(i)).collect(); + store.apply_commit_blocking(vec![], kill_cids).unwrap(); + advance_epoch(&store); + std::thread::sleep(std::time::Duration::from_millis(5)); + + compact_all_sealed(&store); + + let reachable: HashSet = (0..total) + .filter(|i| !kill_mask.contains(i)) + .map(test_cid) + .collect(); + + let (leaked, _) = store + .find_leaked_refcounts(|cid| reachable.contains(cid)) + .unwrap(); + + assert!( + leaked.is_empty(), + "seed={seed} no leaked refcounts expected after proper delete+compact cycle, found {}", + leaked.len() + ); + + reachable.iter().for_each(|cid| { + let data = store.get_block_sync(cid).unwrap(); + assert!( + data.is_some(), + "seed={seed} reachable block must be readable after compaction" + ); + }); + }); + }); +} + +#[test] +fn sim_reachability_with_dedup_refcounts() { + with_runtime(|| { + let seed_range = match sim_single_seed() { + Some(s) => s..s + 1, + None => 0..sim_seed_range().end.min(200), + }; + + seed_range.into_par_iter().for_each(|seed| { + let dir = tempfile::TempDir::new().unwrap(); + let store = TranquilBlockStore::open(default_blockstore_config(dir.path())).unwrap(); + + let block_count = ((seed % 20) as u32) + 5; + let cids: Vec = (0..block_count).map(test_cid).collect(); + + let dup_count = ((seed % 4) as usize) + 2; + (0..dup_count).for_each(|_| { + let blocks: Vec<(CidBytes, Vec)> = cids + .iter() + .map(|&cid| { + ( + cid, + block_data(u32::from_le_bytes(cid[4..8].try_into().unwrap())), + ) + }) + .collect(); + store.put_blocks_blocking(blocks).unwrap(); + }); + + let partially_deleted: Vec = (0..block_count).filter(|i| i % 2 == 0).collect(); + partially_deleted.iter().for_each(|&i| { + (0..dup_count - 1).for_each(|_| { + store + .apply_commit_blocking(vec![], vec![test_cid(i)]) + .unwrap(); + }); + }); + + let reachable: HashSet = cids.iter().copied().collect(); + let (leaked, _) = store + .find_leaked_refcounts(|cid| reachable.contains(cid)) + .unwrap(); + + assert!( + leaked.is_empty(), + "seed={seed} all blocks are still reachable (refcount>0), none should be leaked" + ); + + cids.iter().for_each(|cid| { + let data = store.get_block_sync(cid).unwrap(); + assert!( + data.is_some(), + "seed={seed} block with remaining refcount must be readable" + ); + }); + }); + }); +} diff --git a/crates/tranquil-store/tests/sim_soak.rs b/crates/tranquil-store/tests/sim_soak.rs new file mode 100644 index 0000000..a81ec63 --- /dev/null +++ b/crates/tranquil-store/tests/sim_soak.rs @@ -0,0 +1,464 @@ +mod common; + +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +use tranquil_store::backup::{BackupCoordinator, restore_from_backup, verify_backup}; +use tranquil_store::blockstore::CidBytes; +use tranquil_store::sim_single_seed; + +use common::{ + Rng, TestStores, block_data, compact_all_sealed, open_test_stores, test_cid, test_cid_link, + test_did, test_handle, test_uuid, +}; +use tranquil_db_traits::{RepoEventType, SequenceNumber, SequencedEvent}; + +const CACHE_SIZE: u64 = 32 * 1024 * 1024; + +fn open_soak_stores(base: &std::path::Path) -> TestStores { + open_test_stores(base, 4096, CACHE_SIZE) +} + +#[derive(Debug)] +struct SoakOracle { + live_blocks: HashMap, + repos: HashSet, + event_count: u64, +} + +impl SoakOracle { + fn new() -> Self { + Self { + live_blocks: HashMap::new(), + repos: HashSet::new(), + event_count: 0, + } + } + + fn put_block(&mut self, seed: u32) { + *self.live_blocks.entry(seed).or_insert(0) += 1; + } + + fn delete_block(&mut self, seed: u32) -> bool { + match self.live_blocks.get_mut(&seed) { + Some(rc) if *rc > 0 => { + *rc -= 1; + true + } + _ => false, + } + } + + fn add_repo(&mut self, idx: u64) { + self.repos.insert(idx); + } + + fn add_event(&mut self) { + self.event_count += 1; + } + + fn live_block_seeds(&self) -> Vec { + self.live_blocks + .iter() + .filter(|&(_, rc)| *rc > 0) + .map(|(&s, _)| s) + .collect() + } +} + +#[derive(Debug)] +enum SoakOp { + PutBlocks { count: u32 }, + DeleteBlocks { count: u32 }, + ReadBlocks { count: u32 }, + CreateRepo, + AppendEvent, + CompactGc, + Backup, + CrashRecover, +} + +fn generate_ops(rng: &mut Rng, total: usize) -> Vec { + (0..total) + .map(|_| { + let roll = rng.range_u32(100); + match roll { + 0..30 => SoakOp::PutBlocks { + count: rng.range_u32(10) + 1, + }, + 30..45 => SoakOp::DeleteBlocks { + count: rng.range_u32(5) + 1, + }, + 45..65 => SoakOp::ReadBlocks { + count: rng.range_u32(10) + 1, + }, + 65..75 => SoakOp::CreateRepo, + 75..85 => SoakOp::AppendEvent, + 85..92 => SoakOp::CompactGc, + 92..97 => SoakOp::Backup, + _ => SoakOp::CrashRecover, + } + }) + .collect() +} + +fn verify_integrity(stores: &TestStores, oracle: &SoakOracle) { + oracle.live_block_seeds().iter().for_each(|&seed| { + let data = stores.blockstore.get_block_sync(&test_cid(seed)).unwrap(); + assert!( + data.is_some(), + "soak: live block seed={seed} must be readable" + ); + assert_eq!( + &data.unwrap()[..4], + &seed.to_le_bytes(), + "soak: live block seed={seed} data mismatch" + ); + }); + + oracle.repos.iter().for_each(|&idx| { + let uid = test_uuid(idx); + let meta = stores.metastore.repo_ops().get_repo_meta(uid).unwrap(); + assert!(meta.is_some(), "soak: persisted repo idx={idx} must exist"); + }); +} + +#[test] +fn sim_soak_continuous_operations_with_crash_recovery() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = rt.enter(); + + let seed = sim_single_seed().unwrap_or(42); + let mut rng = Rng::new(seed); + + let op_count = match std::env::var("TRANQUIL_SOAK_OPS") { + Ok(v) => v.parse::().unwrap_or(10_000), + Err(_) => 10_000, + }; + + let ops = generate_ops(&mut rng, op_count); + + let dir = tempfile::TempDir::new().unwrap(); + let mut stores = Some(open_soak_stores(dir.path())); + let mut oracle = SoakOracle::new(); + let mut block_counter: u32 = 0; + let mut repo_counter: u64 = 0; + let mut crash_count: u32 = 0; + let mut backup_count: u32 = 0; + let mut last_persist_repos: HashSet = HashSet::new(); + + ops.iter().enumerate().for_each(|(op_idx, op)| { + if matches!(op, SoakOp::CrashRecover) { + { + let s = stores.as_ref().unwrap(); + s.metastore.persist().unwrap(); + s.eventlog.sync().unwrap(); + } + last_persist_repos = oracle.repos.clone(); + + stores.take(); + stores = Some(open_soak_stores(dir.path())); + crash_count += 1; + + let fresh = stores.as_ref().unwrap(); + oracle.live_block_seeds().iter().for_each(|&bseed| { + let data = fresh.blockstore.get_block_sync(&test_cid(bseed)).unwrap(); + assert!( + data.is_some(), + "soak: after crash #{crash_count} (op={op_idx}), live block seed={bseed} must survive" + ); + }); + + last_persist_repos.iter().for_each(|&idx| { + let uid = test_uuid(idx); + let meta = fresh.metastore.repo_ops().get_repo_meta(uid).unwrap(); + assert!( + meta.is_some(), + "soak: after crash #{crash_count} (op={op_idx}), persisted repo idx={idx} must survive" + ); + }); + + return; + } + + let s = stores.as_ref().unwrap(); + match op { + SoakOp::PutBlocks { count } => { + let base = block_counter; + let blocks: Vec<(CidBytes, Vec)> = (0..*count) + .map(|j| { + let bseed = base + j; + (test_cid(bseed), block_data(bseed)) + }) + .collect(); + if s.blockstore.put_blocks_blocking(blocks).is_ok() { + (base..base + count).for_each(|bseed| oracle.put_block(bseed)); + } + block_counter = base + count; + } + SoakOp::DeleteBlocks { count } => { + let candidates: Vec = oracle.live_block_seeds(); + let to_delete: Vec = + candidates.iter().take(*count as usize).copied().collect(); + let cids: Vec = to_delete.iter().map(|&bseed| test_cid(bseed)).collect(); + if !cids.is_empty() + && s.blockstore.apply_commit_blocking(vec![], cids).is_ok() + { + to_delete.iter().for_each(|&bseed| { + let _ = oracle.delete_block(bseed); + }); + } + } + SoakOp::ReadBlocks { count } => { + let live = oracle.live_block_seeds(); + live.iter().take(*count as usize).for_each(|&bseed| { + let data = s.blockstore.get_block_sync(&test_cid(bseed)).unwrap(); + assert!( + data.is_some(), + "soak op={op_idx}: live block seed={bseed} must be readable" + ); + }); + } + SoakOp::CreateRepo => { + let idx = repo_counter; + repo_counter += 1; + let uid = test_uuid(idx); + let did = test_did(idx); + let handle = test_handle(idx); + let cid_link = test_cid_link((idx & 0xFF) as u8); + s.metastore + .repo_ops() + .create_repo( + s.metastore.database(), + uid, + &did, + &handle, + &cid_link, + &format!("rev{idx}"), + ) + .unwrap(); + oracle.add_repo(idx); + } + SoakOp::AppendEvent => { + let event_idx = oracle.event_count; + let did = test_did(event_idx); + let event = SequencedEvent { + seq: SequenceNumber::from_raw(0), + did: did.clone(), + created_at: chrono::Utc::now(), + event_type: RepoEventType::Commit, + commit_cid: None, + prev_cid: None, + prev_data_cid: None, + ops: None, + blobs: None, + blocks: None, + handle: None, + active: None, + status: None, + rev: Some(format!("soak-rev-{event_idx}")), + }; + s.eventlog + .append_event(&did, RepoEventType::Commit, &event) + .unwrap(); + oracle.add_event(); + } + SoakOp::CompactGc => { + let _ = s.blockstore.apply_commit_blocking(vec![], vec![]); + std::thread::sleep(std::time::Duration::from_millis(2)); + compact_all_sealed(&s.blockstore); + } + SoakOp::Backup => { + s.metastore.persist().unwrap(); + s.eventlog.sync().unwrap(); + + let backup_path = dir.path().join("_backup"); + let restore_path = dir.path().join("_restore"); + let _ = std::fs::remove_dir_all(&backup_path); + let _ = std::fs::remove_dir_all(&restore_path); + std::fs::create_dir_all(&backup_path).unwrap(); + + let coordinator = + BackupCoordinator::new(&s.blockstore, &s.eventlog, &s.metastore); + match coordinator.create_backup(&backup_path) { + Ok(manifest) => { + let verify_result = verify_backup(&backup_path).unwrap(); + assert!( + verify_result.is_healthy(), + "soak op={op_idx}: backup must be healthy after {backup_count} backups" + ); + + let _ = restore_from_backup(&backup_path, &restore_path); + + let _ = std::fs::remove_dir_all(&backup_path); + let _ = std::fs::remove_dir_all(&restore_path); + + backup_count += 1; + let _ = manifest; + } + Err(_) => { + let _ = std::fs::remove_dir_all(&backup_path); + } + } + } + SoakOp::CrashRecover => unreachable!(), + } + }); + + let s = stores.as_ref().unwrap(); + s.metastore.persist().unwrap(); + s.eventlog.sync().unwrap(); + + verify_integrity(s, &oracle); + + let live = oracle.live_block_seeds(); + let total_blocks = block_counter; + let total_repos = repo_counter; + let total_events = oracle.event_count; + + assert!(total_blocks > 0, "soak test must have written blocks"); + assert!( + !live.is_empty(), + "soak test must have live blocks at the end" + ); + + eprintln!( + "soak test complete: seed={seed} ops={op_count} blocks_written={total_blocks} \ + live_blocks={} repos={total_repos} events={total_events} crashes={crash_count} \ + backups={backup_count}", + live.len() + ); +} + +#[test] +fn sim_soak_concurrent_writers_with_gc_and_reads() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let _guard = rt.enter(); + + let seed = sim_single_seed().unwrap_or(99); + let duration_ms: u64 = match std::env::var("TRANQUIL_SOAK_DURATION_MS") { + Ok(v) => v.parse().unwrap_or(5_000), + Err(_) => 5_000, + }; + + let dir = tempfile::TempDir::new().unwrap(); + let stores = open_soak_stores(dir.path()); + let stop = AtomicBool::new(false); + let block_counter = AtomicU64::new(0); + let ops_counter = AtomicU64::new(0); + + let initial_blocks: Vec<(CidBytes, Vec)> = + (0u32..100).map(|i| (test_cid(i), block_data(i))).collect(); + stores + .blockstore + .put_blocks_blocking(initial_blocks) + .unwrap(); + block_counter.store(100, Ordering::SeqCst); + + std::thread::scope(|s| { + let writer = s.spawn(|| { + std::iter::from_fn(|| (!stop.load(Ordering::Relaxed)).then_some(())).for_each(|()| { + let base = block_counter.fetch_add(5, Ordering::SeqCst) as u32; + let batch: Vec<(CidBytes, Vec)> = (base..base + 5) + .map(|i| (test_cid(i), block_data(i))) + .collect(); + let _ = stores.blockstore.put_blocks_blocking(batch); + ops_counter.fetch_add(5, Ordering::Relaxed); + std::thread::sleep(std::time::Duration::from_micros(100)); + }); + }); + + let deleter = s.spawn(|| { + let mut rng_d = Rng::new(seed + 1); + std::iter::from_fn(|| (!stop.load(Ordering::Relaxed)).then_some(())).for_each(|()| { + let target = rng_d.range_u32(50); + let _ = stores + .blockstore + .apply_commit_blocking(vec![], vec![test_cid(target)]); + ops_counter.fetch_add(1, Ordering::Relaxed); + std::thread::sleep(std::time::Duration::from_millis(2)); + }); + }); + + let reader = s.spawn(|| { + let mut rng_r = Rng::new(seed + 2); + std::iter::from_fn(|| (!stop.load(Ordering::Relaxed)).then_some(())).fold( + 0u64, + |read_failures, ()| { + let target = rng_r.range_u32(80); + let inc = match stores.blockstore.get_block_sync(&test_cid(target)) { + Ok(Some(data)) => { + assert_eq!( + &data[..4], + &target.to_le_bytes(), + "reader: block {target} data mismatch during concurrent ops" + ); + 0 + } + Ok(None) | Err(_) => 1, + }; + ops_counter.fetch_add(1, Ordering::Relaxed); + std::thread::sleep(std::time::Duration::from_micros(50)); + read_failures + inc + }, + ) + }); + + let gc_thread = s.spawn(|| { + std::iter::from_fn(|| (!stop.load(Ordering::Relaxed)).then_some(())).fold( + 0u32, + |gc_rounds, ()| { + let _ = stores.blockstore.apply_commit_blocking(vec![], vec![]); + std::thread::sleep(std::time::Duration::from_millis(10)); + if let Ok(files) = stores.blockstore.list_data_files() { + files + .iter() + .copied() + .take(files.len().saturating_sub(1)) + .for_each(|fid| { + let _ = stores.blockstore.compact_file(fid, 0); + }); + } + ops_counter.fetch_add(1, Ordering::Relaxed); + std::thread::sleep(std::time::Duration::from_millis(20)); + gc_rounds.saturating_add(1) + }, + ) + }); + + std::thread::sleep(std::time::Duration::from_millis(duration_ms)); + stop.store(true, Ordering::Relaxed); + + writer.join().unwrap(); + deleter.join().unwrap(); + let _read_failures = reader.join().unwrap(); + let gc_rounds = gc_thread.join().unwrap(); + + let total_ops = ops_counter.load(Ordering::SeqCst); + let final_block_count = block_counter.load(Ordering::SeqCst); + + assert!(gc_rounds > 0, "soak: gc must have run at least once"); + assert!( + total_ops > 100, + "soak: must have executed significant operations" + ); + + (50u32..80).for_each(|i| { + let data = stores.blockstore.get_block_sync(&test_cid(i)).unwrap(); + assert!( + data.is_some(), + "soak: block {i} (never deleted) must be present after concurrent ops" + ); + assert_eq!( + &data.unwrap()[..4], + &i.to_le_bytes(), + "soak: block {i} content integrity check" + ); + }); + + eprintln!( + "concurrent soak complete: seed={seed} duration={duration_ms}ms total_ops={total_ops} \ + blocks_allocated={final_block_count} gc_rounds={gc_rounds}" + ); + }); +} diff --git a/crates/tranquil-sync/src/listener.rs b/crates/tranquil-sync/src/listener.rs index 52fcc66..0362266 100644 --- a/crates/tranquil-sync/src/listener.rs +++ b/crates/tranquil-sync/src/listener.rs @@ -134,7 +134,7 @@ fn to_firehose_event(event: tranquil_db_traits::SequencedEvent) -> SequencedEven prev_data_cid: event.prev_data_cid, ops: event.ops, blobs: event.blobs, - blocks_cids: event.blocks_cids, + blocks: event.blocks, handle: event.handle, active: event.active, status: event.status, diff --git a/crates/tranquil-sync/src/subscribe_repos.rs b/crates/tranquil-sync/src/subscribe_repos.rs index 32c3632..2fb4b8e 100644 --- a/crates/tranquil-sync/src/subscribe_repos.rs +++ b/crates/tranquil-sync/src/subscribe_repos.rs @@ -150,7 +150,9 @@ async fn handle_socket_inner( current_cursor = event.seq; last_seen = event.seq; let bytes = - match format_event_with_prefetched_blocks(event, &prefetched).await { + match format_event_with_prefetched_blocks(state, event, &prefetched) + .await + { Ok(b) => b, Err(e) => { warn!("Failed to format backfill event: {}", e); @@ -190,13 +192,14 @@ async fn handle_socket_inner( }; for event in events { last_seen = event.seq; - let bytes = match format_event_with_prefetched_blocks(event, &prefetched).await { - Ok(b) => b, - Err(e) => { - warn!("Failed to format cutover event: {}", e); - return Err(()); - } - }; + let bytes = + match format_event_with_prefetched_blocks(state, event, &prefetched).await { + Ok(b) => b, + Err(e) => { + warn!("Failed to format cutover event: {}", e); + return Err(()); + } + }; if let Err(e) = socket.send(Message::Binary(bytes.into())).await { warn!("Failed to send cutover event: {}", e); return Err(()); diff --git a/example.toml b/example.toml index a7422e0..831003b 100644 --- a/example.toml +++ b/example.toml @@ -175,7 +175,7 @@ # Can also be specified via environment variable `S3_BUCKET`. #s3_bucket = -# Custom S3 endpoint URL (for MinIO, R2, etc.). +# Custom S3 endpoint URL. # # Can also be specified via environment variable `S3_ENDPOINT`. #s3_endpoint = @@ -206,8 +206,27 @@ # Can also be specified via environment variable `TRANQUIL_STORE_HANDLER_THREADS`. #handler_threads = +# Maximum total bytes of pending (unsynced) eventlog payloads. Appenders block +# once this budget is exhausted until in-flight events drain via fsync. Set to +# 0 to disable backpressure. Default: 1 GiB. +# +# Can also be specified via environment variable +# `TRANQUIL_STORE_EVENTLOG_PENDING_BYTES_BUDGET`. +# +# Default value: 1073741824 +#eventlog_pending_bytes_budget = 1073741824 + +# Maximum size of an individual eventlog payload in bytes. Single events +# larger than this are rejected at append time. Default: 256 MiB. +# +# Can also be specified via environment variable +# `TRANQUIL_STORE_EVENTLOG_MAX_EVENT_PAYLOAD`. +# +# Default value: 268435456 +#eventlog_max_event_payload = 268435456 + [cache] -# Cache backend: `ripple` (default, built-in gossip) or `valkey`. +# Cache backend: `ripple` by default, or `valkey`. # # Can also be specified via environment variable `CACHE_BACKEND`. # @@ -509,10 +528,19 @@ # Default value: 3600 #delete_check_interval_secs = 3600 -# Interval in seconds between block garbage collection cycles. -# Reclaims orphaned ipld blocks that were stored but never committed. +# Maximum age of events retained in the eventlog before pruning. +# Per the atproto firehose spec, the relay backfill window only needs +# to cover "hours or days". # -# Can also be specified via environment variable `BLOCK_GC_INTERVAL_SECS`. +# Can also be specified via environment variable `EVENT_RETENTION_MAX_AGE_SECS`. # -# Default value: 21600 -#block_gc_interval_secs = 21600 +# Default value: 604800 (7 days) +#event_retention_max_age_secs = 604800 + +# Interval in seconds between event retention prune passes. +# Set to 0 to disable. +# +# Can also be specified via environment variable `EVENT_RETENTION_INTERVAL_SECS`. +# +# Default value: 3600 +#event_retention_interval_secs = 3600 diff --git a/justfile b/justfile index 220d6b3..437ddd0 100644 --- a/justfile +++ b/justfile @@ -22,6 +22,9 @@ lint: fmt-check clippy test-store: SQLX_OFFLINE=true cargo nextest run -p tranquil-store --features tranquil-store/test-harness +test-store-sim-nightly: + SQLX_OFFLINE=true TRANQUIL_SIM_SEEDS=10000 cargo nextest run -p tranquil-store --features tranquil-store/test-harness --profile sim-nightly + test-unit: SQLX_OFFLINE=true cargo test --test dpop_unit --test validation_edge_cases --test scope_edge_cases @@ -57,6 +60,10 @@ test *args: @just test-unit ./scripts/run-tests.sh {{args}} +test-embedded *args: + @just test-unit + SQLX_OFFLINE=true TRANQUIL_TEST_BACKEND=store TRANQUIL_PDS_ALLOW_INSECURE_SECRETS=1 DISABLE_RATE_LIMITING=1 TRANQUIL_LEXICON_OFFLINE=1 SKIP_IMPORT_VERIFICATION=true cargo nextest run -E 'not binary(store_parity)' {{args}} + test-one name: ./scripts/run-tests.sh --test {{name}} diff --git a/migrations/20260407_inline_event_blocks.sql b/migrations/20260407_inline_event_blocks.sql new file mode 100644 index 0000000..73460b3 --- /dev/null +++ b/migrations/20260407_inline_event_blocks.sql @@ -0,0 +1,3 @@ +ALTER TABLE repo_seq + ADD COLUMN block_cids BYTEA[], + ADD COLUMN block_data BYTEA[]; diff --git a/migrations/20260408_repo_seq_created_at_index.sql b/migrations/20260408_repo_seq_created_at_index.sql new file mode 100644 index 0000000..a4f80fc --- /dev/null +++ b/migrations/20260408_repo_seq_created_at_index.sql @@ -0,0 +1 @@ +CREATE INDEX IF NOT EXISTS idx_repo_seq_created_at ON repo_seq(created_at);