mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-16 23:34:14 +00:00
feat(tranquil-store): gc and hardening
Lewis: May this revision serve well! <lu5a@proton.me>
This commit is contained in:
@@ -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"
|
||||
|
||||
-32
@@ -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"
|
||||
}
|
||||
+18
-6
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
-15
@@ -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"
|
||||
}
|
||||
+18
-7
@@ -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"
|
||||
}
|
||||
+5
-3
@@ -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"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM repo_seq WHERE created_at < $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "746ca87e98f6eaa9c060770c0d1bca6f0d4da60df2dc2db088e57bdc914cf233"
|
||||
}
|
||||
+19
-6
@@ -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"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM blocks WHERE cid = ANY($1)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8eecf8fef308716be88815eb59bb67ec7c534b3c821d55481b110e3e462ee366"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
+18
-6
@@ -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"
|
||||
}
|
||||
-29
@@ -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"
|
||||
}
|
||||
+18
-6
@@ -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"
|
||||
}
|
||||
Generated
+27
-22
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<AppState>,
|
||||
auth: Auth<Admin>,
|
||||
Json(input): Json<SetAdminStatusInput>,
|
||||
) -> Result<Json<EmptyResponse>, 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 {}))
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -330,6 +330,7 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Cid> = 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<Cid> = tracking_store
|
||||
.get_all_relevant_cids()
|
||||
.into_iter()
|
||||
.chain(relevant_blocks.keys().copied())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let written_cids_str: Vec<String> = 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(())
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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<String> = 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// 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<usize>,
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<CidLink>,
|
||||
}
|
||||
|
||||
#[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<u8>,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum EventBlocks {
|
||||
Inline(Vec<EventBlockInline>),
|
||||
LegacyCids(Vec<String>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SequencedEvent {
|
||||
pub seq: SequenceNumber,
|
||||
@@ -201,7 +239,7 @@ pub struct SequencedEvent {
|
||||
pub prev_data_cid: Option<CidLink>,
|
||||
pub ops: Option<serde_json::Value>,
|
||||
pub blobs: Option<Vec<String>>,
|
||||
pub blocks_cids: Option<Vec<String>>,
|
||||
pub blocks: Option<EventBlocks>,
|
||||
pub handle: Option<Handle>,
|
||||
pub active: Option<bool>,
|
||||
pub status: Option<AccountStatus>,
|
||||
@@ -216,17 +254,11 @@ pub struct CommitEventData {
|
||||
pub prev_cid: Option<CidLink>,
|
||||
pub ops: Option<serde_json::Value>,
|
||||
pub blobs: Option<Vec<String>>,
|
||||
pub blocks_cids: Option<Vec<String>>,
|
||||
pub blocks: Option<Vec<EventBlockInline>>,
|
||||
pub prev_data_cid: Option<CidLink>,
|
||||
pub rev: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EventBlocksCids {
|
||||
pub blocks_cids: Option<Vec<String>>,
|
||||
pub commit_cid: Option<CidLink>,
|
||||
}
|
||||
|
||||
#[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<i64, DbError>;
|
||||
|
||||
async fn find_unreferenced_blocks(
|
||||
&self,
|
||||
candidate_cids: &[Vec<u8>],
|
||||
) -> Result<Vec<Vec<u8>>, DbError>;
|
||||
|
||||
async fn insert_commit_event(&self, data: &CommitEventData) -> Result<SequenceNumber, DbError>;
|
||||
|
||||
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<SequenceNumber, DbError>;
|
||||
|
||||
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<SequenceNumber, DbError>;
|
||||
|
||||
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<Utc>) -> Result<PruneCount, DbError>;
|
||||
|
||||
async fn get_max_seq(&self) -> Result<SequenceNumber, DbError>;
|
||||
|
||||
async fn get_min_seq_since(
|
||||
@@ -491,12 +517,6 @@ pub trait RepoRepository: Send + Sync {
|
||||
limit: i64,
|
||||
) -> Result<Vec<SequencedEvent>, DbError>;
|
||||
|
||||
async fn get_events_since_rev(
|
||||
&self,
|
||||
did: &Did,
|
||||
since_rev: &str,
|
||||
) -> Result<Vec<EventBlocksCids>, DbError>;
|
||||
|
||||
async fn list_repos_paginated(
|
||||
&self,
|
||||
cursor_did: Option<&Did>,
|
||||
@@ -521,8 +541,6 @@ pub trait RepoRepository: Send + Sync {
|
||||
input: ApplyCommitInput,
|
||||
) -> Result<ApplyCommitResult, ApplyCommitError>;
|
||||
|
||||
async fn get_broken_genesis_commits(&self) -> Result<Vec<BrokenGenesisCommit>, DbError>;
|
||||
|
||||
async fn get_users_without_blocks(&self) -> Result<Vec<UserWithoutBlocks>, DbError>;
|
||||
|
||||
async fn get_users_needing_record_blobs_backfill(
|
||||
|
||||
@@ -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<String>,
|
||||
ops: Option<serde_json::Value>,
|
||||
blobs: Option<Vec<String>>,
|
||||
block_cids: Option<Vec<Vec<u8>>>,
|
||||
block_data: Option<Vec<Vec<u8>>>,
|
||||
blocks_cids: Option<Vec<String>>,
|
||||
handle: Option<String>,
|
||||
active: Option<bool>,
|
||||
@@ -34,6 +36,81 @@ struct SequencedEventRow {
|
||||
rev: Option<String>,
|
||||
}
|
||||
|
||||
fn row_to_event_blocks(
|
||||
block_cids: Option<Vec<Vec<u8>>>,
|
||||
block_data: Option<Vec<Vec<u8>>>,
|
||||
legacy_blocks_cids: Option<Vec<String>>,
|
||||
) -> Result<Option<EventBlocks>, 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<Vec<String>>) -> Option<EventBlocks> {
|
||||
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<u8>>, Vec<Vec<u8>>) {
|
||||
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<EventBlockInline>>,
|
||||
) -> (Vec<Vec<u8>>, Vec<Vec<u8>>) {
|
||||
blocks
|
||||
.map(|bs| bs.into_iter().map(|b| (b.cid_bytes, b.data)).unzip())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn map_sequenced_row(r: SequencedEventRow) -> Result<SequencedEvent, DbError> {
|
||||
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<u8>],
|
||||
) -> Result<Vec<Vec<u8>>, 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<SequenceNumber, DbError> {
|
||||
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<u8>],
|
||||
&block_data as &[Vec<u8>],
|
||||
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<SequenceNumber, DbError> {
|
||||
let cid_bytes = commit_cid
|
||||
.to_cid()
|
||||
.map(|c| c.to_bytes())
|
||||
.unwrap_or_default();
|
||||
let block_cids: Vec<Vec<u8>> = vec![cid_bytes];
|
||||
let block_data: Vec<Vec<u8>> = 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<u8>],
|
||||
&block_data as &[Vec<u8>]
|
||||
)
|
||||
.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<SequenceNumber, DbError> {
|
||||
let ops = serde_json::json!([]);
|
||||
let blobs: Vec<String> = vec![];
|
||||
let blocks_cids: Vec<String> = 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<u8>> = vec![commit_cid_bytes, mst_cid_bytes];
|
||||
let block_data: Vec<Vec<u8>> = 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<u8>],
|
||||
&block_data as &[Vec<u8>],
|
||||
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<Utc>) -> Result<PruneCount, DbError> {
|
||||
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<SequenceNumber, DbError> {
|
||||
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<i64>,
|
||||
) -> Result<Vec<SequencedEvent>, 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<Vec<SequencedEvent>, 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<Option<SequencedEvent>, 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<Vec<SequencedEvent>, 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<Vec<EventBlocksCids>, 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<Vec<tranquil_db_traits::BrokenGenesisCommit>, 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<Vec<UserWithoutBlocks>, DbError> {
|
||||
let rows: Vec<(Uuid, String, Option<String>)> = sqlx::query_as(
|
||||
r#"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+217
-125
@@ -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<Backlink> {
|
||||
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<TrackingBlockStore>,
|
||||
new_mst: &Mst<TrackingBlockStore>,
|
||||
original_root_cid: CommitCid,
|
||||
) -> Result<Vec<Cid>, 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<Cid> = old_nodes.into_iter().collect();
|
||||
let new_nodes_set: BTreeSet<Cid> = new_nodes.into_iter().collect();
|
||||
let old_leaf_set: BTreeSet<Cid> = old_leaves.iter().map(|(_, cid)| *cid).collect();
|
||||
let new_leaf_set: BTreeSet<Cid> = 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<Cid> = 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<CommitResult, ApiError> {
|
||||
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<Cid> = ctx
|
||||
.tracking_store
|
||||
.get_all_relevant_cids()
|
||||
.into_iter()
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let written_cids_str: Vec<String> = 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::<Vec<Cid>, 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>,
|
||||
cid: RecordCid,
|
||||
prev: RecordCid,
|
||||
},
|
||||
Delete {
|
||||
collection: Nsid,
|
||||
rkey: Rkey,
|
||||
prev: Option<Cid>,
|
||||
prev: RecordCid,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -329,10 +388,12 @@ pub struct CommitParams<'a> {
|
||||
pub did: &'a Did,
|
||||
pub user_id: Uuid,
|
||||
pub current_root_cid: Option<Cid>,
|
||||
pub prev_commit_bytes: Option<Bytes>,
|
||||
pub prev_data_cid: Option<Cid>,
|
||||
pub new_mst_root: Cid,
|
||||
pub ops: Vec<RecordOp>,
|
||||
pub blocks_cids: &'a [String],
|
||||
pub block_bytes: std::collections::HashMap<Cid, Bytes>,
|
||||
pub new_tree_cids: Vec<Cid>,
|
||||
pub blobs: &'a [String],
|
||||
pub obsolete_cids: Vec<Cid>,
|
||||
pub backlinks_to_add: Vec<Backlink>,
|
||||
@@ -344,8 +405,8 @@ pub async fn commit_and_log(
|
||||
params: CommitParams<'_>,
|
||||
) -> Result<CommitResult, CommitError> {
|
||||
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<Vec<u8>> = 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<Vec<u8>> = 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<Vec<u8>> = 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<EventBlockInline> = 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<String> = 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<Cid> = 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<Cid> = tracking_store
|
||||
.get_all_relevant_cids()
|
||||
.into_iter()
|
||||
.chain(relevant_blocks.keys().copied())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let written_cids_str: Vec<String> = 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<SequenceNumber, CommitError> {
|
||||
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)))
|
||||
}
|
||||
|
||||
@@ -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<dyn RepoRepository>,
|
||||
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<dyn RepoRepository>, block_sto
|
||||
info!(success, failed, "Completed record_blobs backfill");
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn start_scheduled_tasks(
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
blob_repo: Arc<dyn BlobRepository>,
|
||||
@@ -429,23 +328,91 @@ pub async fn start_scheduled_tasks(
|
||||
sso_repo: Arc<dyn SsoRepository>,
|
||||
repo_repo: Arc<dyn RepoRepository>,
|
||||
block_store: AnyBlockStore,
|
||||
eventlog_segments_dir: Option<std::path::PathBuf>,
|
||||
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<Arc<tranquil_store::archival::ContinuousArchiver>> =
|
||||
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<CidBytes> {
|
||||
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<CidBytes>,
|
||||
) -> 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::<Ipld>(&block) {
|
||||
if let Some(Ipld::Link(left_cid)) = obj.get("l")
|
||||
&& let Ok(bytes) = <CidBytes>::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) => {
|
||||
<CidBytes>::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<Did> = 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<ReachabilityResult> {
|
||||
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<Did, CidLink> = 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ pub struct AppState {
|
||||
pub bootstrap_invite_code: Option<String>,
|
||||
pub signal_sender: Option<Arc<tranquil_signal::SignalSlot>>,
|
||||
pub signal_store_provider: Option<Arc<dyn tranquil_signal::SignalStoreProvider>>,
|
||||
pub eventlog_segments_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[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<Arc<dyn tranquil_signal::SignalStoreProvider>>,
|
||||
Option<PathBuf>,
|
||||
) = 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<Arc<dyn tranquil_signal::SignalStoreProvider>>,
|
||||
eventlog_segments_dir: Option<PathBuf>,
|
||||
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<dyn tranquil_signal::SignalStoreProvider>,
|
||||
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::<RealIO>::new(pool);
|
||||
let client = MetastoreClient::<RealIO>::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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,6 @@ pub struct CommitFrameBuilder {
|
||||
seq: i64,
|
||||
did: Did,
|
||||
commit_cid: Cid,
|
||||
prev_cid: Option<Cid>,
|
||||
ops_json: serde_json::Value,
|
||||
blob_cids: Vec<Cid>,
|
||||
time: chrono::DateTime<chrono::Utc>,
|
||||
@@ -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<String>,
|
||||
time: chrono::DateTime<chrono::Utc>,
|
||||
@@ -163,9 +160,6 @@ impl CommitFrameBuilder {
|
||||
) -> Result<Self, CommitFrameError> {
|
||||
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<Cid> = 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<SequencedEvent> 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<Cid>),
|
||||
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<String> {
|
||||
|
||||
async fn write_car_blocks(
|
||||
commit_cid: Cid,
|
||||
commit_bytes: Option<Bytes>,
|
||||
commit_bytes: Bytes,
|
||||
other_blocks: BTreeMap<Cid, Bytes>,
|
||||
) -> Result<Vec<u8>, 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<Vec<u8>, SyncFrameErro
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
async fn format_sync_event(
|
||||
async fn event_blocks_to_map(
|
||||
blocks: Option<&EventBlocks>,
|
||||
prefetched: &HashMap<Cid, Bytes>,
|
||||
state: &AppState,
|
||||
) -> Result<HashMap<Cid, Bytes>, 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> = cid_strs
|
||||
.iter()
|
||||
.map(|s| Cid::from_str(s).map_err(SyncFrameError::CidParse))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let mut map: HashMap<Cid, Bytes> = HashMap::with_capacity(cids.len());
|
||||
let to_fetch: Vec<Cid> = 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<Cid> = missing.into_iter().map(|(cid, _)| cid).collect();
|
||||
return Err(SyncFrameError::MissingLegacyBlocks(missing_cids));
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn format_sync_event(
|
||||
event: &SequencedEvent,
|
||||
prefetched: &HashMap<Cid, Bytes>,
|
||||
state: &AppState,
|
||||
) -> Result<Vec<u8>, 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<Cid>,
|
||||
block_cids: Vec<Cid>,
|
||||
inline_blocks: HashMap<Cid, Bytes>,
|
||||
}
|
||||
|
||||
fn prepare_commit_event(event: SequencedEvent) -> Result<CommitEventContext, SyncFrameError> {
|
||||
let block_cids_str = event.blocks_cids.clone().unwrap_or_default();
|
||||
async fn prepare_commit_event(
|
||||
event: SequencedEvent,
|
||||
prefetched: &HashMap<Cid, Bytes>,
|
||||
state: &AppState,
|
||||
) -> Result<CommitEventContext, SyncFrameError> {
|
||||
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<CommitEventContext, Syn
|
||||
frame.prev_data = Some(cid);
|
||||
}
|
||||
let commit_cid = frame.commit;
|
||||
if !inline_blocks.contains_key(&commit_cid) {
|
||||
return Err(SyncFrameError::MissingInlineCommitBlock);
|
||||
}
|
||||
let prev_cid = prev_cid_link
|
||||
.as_ref()
|
||||
.and_then(|c| Cid::from_str(c.as_str()).ok());
|
||||
let mut block_cids: Vec<Cid> = 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<Item = (Cid, Bytes)>,
|
||||
commit_cid: Cid,
|
||||
) -> (Option<Bytes>, BTreeMap<Cid, Bytes>) {
|
||||
) -> Result<(Bytes, BTreeMap<Cid, Bytes>), 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<Bytes>,
|
||||
commit_bytes: Bytes,
|
||||
other_blocks: BTreeMap<Cid, Bytes>,
|
||||
) -> Result<Vec<u8>, 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<Vec<u8>, SyncFrameError> {
|
||||
format_event_inner(event, &HashMap::new(), state).await
|
||||
}
|
||||
|
||||
async fn format_event_inner(
|
||||
event: SequencedEvent,
|
||||
prefetched: &HashMap<Cid, Bytes>,
|
||||
state: &AppState,
|
||||
) -> Result<Vec<u8>, 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<Cid, Bytes>,
|
||||
) -> Result<Vec<u8>, SyncFrameError> {
|
||||
format_event_inner(event, prefetched, state).await
|
||||
}
|
||||
|
||||
pub async fn prefetch_blocks_for_events(
|
||||
state: &AppState,
|
||||
events: &[SequencedEvent],
|
||||
) -> Result<HashMap<Cid, Bytes>, SyncFrameError> {
|
||||
let mut all_cids: Vec<Cid> = events
|
||||
let legacy_cids: Vec<Cid> = 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::<Result<_, _>>()?;
|
||||
if legacy_cids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let fetched = state.block_store.get_many(&all_cids).await?;
|
||||
let blocks_map: HashMap<Cid, Bytes> = 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<Cid, Bytes>,
|
||||
) -> Result<Vec<u8>, 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<Cid, Bytes>,
|
||||
) -> Result<Vec<u8>, 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(
|
||||
|
||||
@@ -29,6 +29,7 @@ static TEST_DB_POOL: OnceLock<sqlx::PgPool> = OnceLock::new();
|
||||
static TEST_TEMP_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
static CLUSTER: OnceLock<Vec<ServerInstance>> = OnceLock::new();
|
||||
static TEST_REPOS: OnceLock<Arc<tranquil_db::PostgresRepositories>> = OnceLock::new();
|
||||
static TEST_BLOCK_STORE: OnceLock<tranquil_pds::repo::AnyBlockStore> = 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<sqlx::PgPool>,
|
||||
pub cache: Option<(Arc<dyn Cache>, Arc<dyn DistributedRateLimiter>)>,
|
||||
pub store_path: Option<PathBuf>,
|
||||
pub shared_state: Option<AppState>,
|
||||
}
|
||||
|
||||
#[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<ServerInstance> {
|
||||
pub async fn spawn_cluster(pool: Option<sqlx::PgPool>, node_count: usize) -> Vec<ServerInstance> {
|
||||
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<dyn Cache>, Arc<dyn DistributedRateLimiter>)> =
|
||||
@@ -713,11 +707,28 @@ pub async fn spawn_cluster(database_url: String, node_count: usize) -> Vec<Serve
|
||||
ripple_nodes.push((cache, rate_limiter));
|
||||
}
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("PDS_HOSTNAME", "pds.test");
|
||||
}
|
||||
tranquil_config::ensure_test_defaults();
|
||||
let base_state = match is_store_backend() {
|
||||
true => {
|
||||
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<ServerInstance> = 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<sqlx::PgPool> {
|
||||
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<sqlx::PgPool> {
|
||||
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<sqlx::PgPool> {
|
||||
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<sqlx::PgPool> {
|
||||
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<tranquil_db::PostgresRepositories>
|
||||
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
|
||||
|
||||
@@ -32,6 +32,7 @@ pub struct ParsedCommitFrame {
|
||||
pub prev_data: Option<Cid>,
|
||||
}
|
||||
|
||||
#[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<ParsedCommitFrame> {
|
||||
self.frames
|
||||
.lock()
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<String> = (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
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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<Vec<Vec<u8>>, 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<u8>]) -> Result<u64, RepoError> {
|
||||
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<Option<Bytes>, RepoError> {
|
||||
let cid_bytes = cid.to_bytes();
|
||||
@@ -152,7 +126,7 @@ impl BlockStore for PostgresBlockStore {
|
||||
#[derive(Clone)]
|
||||
pub struct TrackingBlockStore<S: BlockStore> {
|
||||
inner: S,
|
||||
written_cids: Arc<Mutex<Vec<Cid>>>,
|
||||
written_blocks: Arc<Mutex<HashMap<Cid, Bytes>>>,
|
||||
read_cids: Arc<Mutex<HashSet<Cid>>>,
|
||||
}
|
||||
|
||||
@@ -160,18 +134,26 @@ impl<S: BlockStore + Sync> TrackingBlockStore<S> {
|
||||
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<Cid> {
|
||||
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<Cid, Bytes> {
|
||||
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<Cid> {
|
||||
match self.read_cids.lock() {
|
||||
Ok(guard) => guard.iter().cloned().collect(),
|
||||
@@ -206,9 +188,14 @@ impl<S: BlockStore + Sync> BlockStore for TrackingBlockStore<S> {
|
||||
|
||||
async fn put(&self, data: &[u8]) -> Result<Cid, RepoError> {
|
||||
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<S: BlockStore + Sync> BlockStore for TrackingBlockStore<S> {
|
||||
blocks: impl IntoIterator<Item = (Cid, Bytes)> + Send,
|
||||
) -> Result<(), RepoError> {
|
||||
let blocks: Vec<_> = blocks.into_iter().collect();
|
||||
let cids: Vec<Cid> = 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(())
|
||||
}
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
state.repos.sso.clone(),
|
||||
state.repos.repo.clone(),
|
||||
state.block_store.clone(),
|
||||
state.eventlog_segments_dir.clone(),
|
||||
shutdown.clone(),
|
||||
));
|
||||
|
||||
|
||||
@@ -24,4 +24,6 @@ uuid = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tranquil-signal = { path = ".", features = ["fjall-store"] }
|
||||
rand = "0.9"
|
||||
tempfile = "3"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,15 +14,16 @@ use uuid::Uuid;
|
||||
|
||||
use crate::store::{IdentityType, PgProtocolStore, PgSignalStore};
|
||||
|
||||
async fn test_store() -> PgSignalStore {
|
||||
async fn test_store() -> Option<PgSignalStore> {
|
||||
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()))
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -61,11 +61,16 @@ fn compute_stats(durations: &mut [Duration]) -> Option<LatencyStats> {
|
||||
}
|
||||
|
||||
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<Vec<u8>> = (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<Vec<u8>> =
|
||||
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::<Vec<Duration>>()
|
||||
.await
|
||||
.collect::<Vec<()>>()
|
||||
.await;
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut all_latencies: Vec<Duration> = 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::<usize>().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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Vec<Duration>>()
|
||||
.await
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut all_latencies: Vec<Duration> = 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::<Vec<()>>()
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
|
||||
@@ -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<HandlerPool>, 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)),
|
||||
},
|
||||
|
||||
@@ -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<RealIO> {
|
||||
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<u64>,
|
||||
decode_payload_ns: Vec<u64>,
|
||||
ops_json_ns: Vec<u64>,
|
||||
did_parse_ns: Vec<u64>,
|
||||
full_conversion_ns: Vec<u64>,
|
||||
total_get_events_ns: Vec<u64>,
|
||||
}
|
||||
|
||||
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<SequencedEvent> = (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<serde_json::Value> = 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<SequencedEvent> = (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::<serde_json::Value>(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);
|
||||
}
|
||||
@@ -128,7 +128,7 @@ async fn seed_records(pool: &Arc<HandlerPool>, 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()),
|
||||
},
|
||||
|
||||
@@ -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<u8> {
|
||||
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::<f64>().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<u32> {
|
||||
input
|
||||
.split(';')
|
||||
.map(|s| s.trim().replace('_', "").parse::<u32>().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);
|
||||
});
|
||||
}
|
||||
@@ -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<SegmentId>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<Vec<SegmentId>> {
|
||||
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<SegmentId> = 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<ArchivalState> {
|
||||
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<dyn ArchivalDestination>,
|
||||
}
|
||||
|
||||
impl ContinuousArchiver {
|
||||
pub fn new(
|
||||
segments_dir: PathBuf,
|
||||
sidecar_path: PathBuf,
|
||||
destination: Box<dyn ArchivalDestination>,
|
||||
) -> Self {
|
||||
Self {
|
||||
segments_dir,
|
||||
sidecar: ArchivalSidecar::new(sidecar_path),
|
||||
destination,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_pass(&self) -> io::Result<ArchivalPassResult> {
|
||||
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<SegmentId> = 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<Mutex<Vec<(SegmentId, Vec<u8>)>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CollectingDestination {
|
||||
stored: ArchivedSegments,
|
||||
}
|
||||
|
||||
impl CollectingDestination {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
stored: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn stored_ids(&self) -> Vec<SegmentId> {
|
||||
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<u32>,
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<io::Error> for CompactionError {
|
||||
fn from(e: io::Error) -> Self {
|
||||
Self::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BlockIndexError> for CompactionError {
|
||||
fn from(e: BlockIndexError) -> Self {
|
||||
Self::Index(e)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn compact_on_writer_thread<S: StorageIO>(
|
||||
manager: &DataFileManager<S>,
|
||||
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<CompactionResult, CompactionError> {
|
||||
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<S: StorageIO>(
|
||||
manager: &DataFileManager<S>,
|
||||
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<CidBytes> = 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))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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<u8> {
|
||||
postcard::to_allocvec(entry)
|
||||
.expect("IndexEntry serialization is infallible for fixed-layout types")
|
||||
}
|
||||
|
||||
fn deserialize_entry(bytes: &[u8]) -> Result<IndexEntry, KeyIndexError> {
|
||||
postcard::from_bytes(bytes).map_err(KeyIndexError::Deserialize)
|
||||
}
|
||||
|
||||
fn serialize_cursor(cursor: &WriteCursor) -> Vec<u8> {
|
||||
postcard::to_allocvec(cursor)
|
||||
.expect("WriteCursor serialization is infallible for fixed-layout types")
|
||||
}
|
||||
|
||||
fn deserialize_cursor(bytes: &[u8]) -> Result<WriteCursor, KeyIndexError> {
|
||||
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<fjall::Error> 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<KeyIndexOpenOutcome, KeyIndexError> {
|
||||
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<Self, KeyIndexError> {
|
||||
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<Option<IndexEntry>, KeyIndexError> {
|
||||
self.blocks
|
||||
.get(cid_bytes)?
|
||||
.map(|v| deserialize_entry(&v))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub fn has(&self, cid_bytes: &[u8; CID_SIZE]) -> Result<bool, KeyIndexError> {
|
||||
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<RefCount, KeyIndexError> {
|
||||
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<Option<WriteCursor>, 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());
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,27 @@ impl<S: StorageIO> DataFileManager<S> {
|
||||
pub fn list_files(&self) -> io::Result<Vec<DataFileId>> {
|
||||
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<S: StorageIO> Drop for DataFileManager<S> {
|
||||
fn drop(&mut self) {
|
||||
self.handles.write().drain().for_each(|(_, entry)| {
|
||||
let _ = self.io.close(entry.fd);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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<S: StorageIO>(
|
||||
pub fn list_files_by_extension<S: StorageIO>(
|
||||
io: &S,
|
||||
dir: &Path,
|
||||
extension: &str,
|
||||
|
||||
@@ -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<io::Error>),
|
||||
Index(Arc<KeyIndexError>),
|
||||
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<io::Error> for ReadError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<KeyIndexError> for ReadError {
|
||||
fn from(e: KeyIndexError) -> Self {
|
||||
Self::Index(Arc::new(e))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BlockStoreReader<S: StorageIO> {
|
||||
index: Arc<KeyIndex>,
|
||||
index: Arc<BlockIndex>,
|
||||
manager: Arc<DataFileManager<S>>,
|
||||
}
|
||||
|
||||
@@ -70,54 +61,58 @@ impl<S: StorageIO> Clone for BlockStoreReader<S> {
|
||||
}
|
||||
|
||||
impl<S: StorageIO> BlockStoreReader<S> {
|
||||
pub fn new(index: Arc<KeyIndex>, manager: Arc<DataFileManager<S>>) -> Self {
|
||||
pub fn new(index: Arc<BlockIndex>, manager: Arc<DataFileManager<S>>) -> Self {
|
||||
Self { index, manager }
|
||||
}
|
||||
|
||||
pub fn manager(&self) -> &DataFileManager<S> {
|
||||
&self.manager
|
||||
}
|
||||
|
||||
pub fn get(&self, cid: &[u8; CID_SIZE]) -> Result<Option<Bytes>, 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<bool, ReadError> {
|
||||
self.index.has(cid).map_err(ReadError::from)
|
||||
Ok(self.index.has(cid))
|
||||
}
|
||||
|
||||
pub fn get_many(&self, cids: &[[u8; CID_SIZE]]) -> Result<Vec<Option<Bytes>>, ReadError> {
|
||||
let mut results: Vec<Option<Bytes>> = 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::<Result<Vec<_>, _>>()?;
|
||||
.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<Bytes>],
|
||||
) -> Result<(), ReadError> {
|
||||
let mut by_file: HashMap<DataFileId, Vec<(usize, BlockLocation)>> = 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<Bytes, ReadError> {
|
||||
@@ -155,457 +150,3 @@ impl<S: StorageIO> BlockStoreReader<S> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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<KeyIndex>,
|
||||
manager: Arc<DataFileManager<RealIO>>,
|
||||
writer: Option<GroupCommitWriter>,
|
||||
sender: flume::Sender<CommitRequest>,
|
||||
}
|
||||
|
||||
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<RealIO> {
|
||||
BlockStoreReader::new(Arc::clone(&self.index), Arc::clone(&self.manager))
|
||||
}
|
||||
|
||||
async fn put_blocks(
|
||||
&self,
|
||||
blocks: Vec<([u8; CID_SIZE], Vec<u8>)>,
|
||||
) -> Result<Vec<super::super::types::BlockLocation>, 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<u8>)>::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<u64> = 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<u8>)>::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<u64> = 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();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<AtomicU64>);
|
||||
|
||||
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<DataFileId, Vec<CidBytes>>,
|
||||
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<WriteCursor>,
|
||||
pub epoch: CommitEpoch,
|
||||
pub data_files: Vec<DataFileId>,
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
use xxhash_rust::xxh3::xxh3_64_with_seed;
|
||||
|
||||
pub struct BloomFilter {
|
||||
bits: Vec<u64>,
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<DanglingCid>,
|
||||
pub dangling_root_cids: Vec<DanglingRootCid>,
|
||||
pub orphaned_user_repos: Vec<OrphanedUserRepo>,
|
||||
pub inconsistent_handles: Vec<InconsistentHandle>,
|
||||
pub orphan_data_files: Vec<DataFileId>,
|
||||
pub deserialization_failures: u64,
|
||||
pub eventlog_contiguity: Option<SequenceContiguityResult>,
|
||||
pub cursor_ahead_of_eventlog: bool,
|
||||
pub metastore_cursor: Option<EventSequence>,
|
||||
pub eventlog_max_seq: Option<EventSequence>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DanglingCid {
|
||||
pub user_hash: UserHash,
|
||||
pub collection: String,
|
||||
pub rkey: String,
|
||||
pub cid_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DanglingRootCid {
|
||||
pub user_hash: UserHash,
|
||||
pub root_cid_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
#[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<S: StorageIO + 'static>(
|
||||
blockstore: &TranquilBlockStore,
|
||||
metastore: &Metastore,
|
||||
eventlog: &EventLog<S>,
|
||||
) -> ConsistencyReport {
|
||||
verify_store_consistency_with_options(
|
||||
blockstore,
|
||||
metastore,
|
||||
eventlog,
|
||||
ConsistencyCheckOptions::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn verify_store_consistency_with_options<S: StorageIO + 'static>(
|
||||
blockstore: &TranquilBlockStore,
|
||||
metastore: &Metastore,
|
||||
eventlog: &EventLog<S>,
|
||||
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<UserHash> {
|
||||
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<UserHash> {
|
||||
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<UserHash>,
|
||||
report: &mut ConsistencyReport,
|
||||
) {
|
||||
let prefix = KeyBuilder::new().tag(KeyTag::USER_BLOCKS).build();
|
||||
let mut seen_orphan_hashes: HashSet<UserHash> = 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<S: StorageIO + 'static>(
|
||||
eventlog: &EventLog<S>,
|
||||
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<S: StorageIO + 'static>(
|
||||
repo_data: &fjall::Keyspace,
|
||||
eventlog: &EventLog<S>,
|
||||
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<DataFileId> = 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<UserHash> {
|
||||
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<UserHash>, 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<UserHash> {
|
||||
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()
|
||||
}
|
||||
@@ -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<S: StorageIO> {
|
||||
log: Arc<EventLog<S>>,
|
||||
}
|
||||
|
||||
impl<S: StorageIO> EventLogBridge<S> {
|
||||
impl<S: StorageIO + 'static> EventLogBridge<S> {
|
||||
pub fn new(log: Arc<EventLog<S>>) -> Self {
|
||||
Self { log }
|
||||
}
|
||||
@@ -156,21 +155,22 @@ impl<S: StorageIO> EventLogBridge<S> {
|
||||
fn scan_for_timestamp(
|
||||
&self,
|
||||
reader: &super::EventLogReader<S>,
|
||||
cursor: EventSequence,
|
||||
mut cursor: EventSequence,
|
||||
target_ts: TimestampMicros,
|
||||
batch_size: usize,
|
||||
) -> Result<Option<SequenceNumber>, 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<S: StorageIO> EventLogBridge<S> {
|
||||
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<u8>,
|
||||
) -> 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) {}
|
||||
}
|
||||
|
||||
@@ -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<io::Result<SyncResult>>,
|
||||
},
|
||||
Freeze {
|
||||
response: flume::Sender<io::Result<FreezeResponse>>,
|
||||
resume: flume::Receiver<()>,
|
||||
},
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
pub struct WriterNotify {
|
||||
synced_seq: AtomicU64,
|
||||
poisoned: AtomicBool,
|
||||
mutex: Mutex<()>,
|
||||
cond: Condvar,
|
||||
}
|
||||
|
||||
pub struct PendingBytesBudget {
|
||||
budget: u64,
|
||||
state: Mutex<BudgetState>,
|
||||
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<u64, ValidEvent>,
|
||||
next_write_seq: u64,
|
||||
gap_since: Option<Instant>,
|
||||
}
|
||||
|
||||
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<ValidEvent> {
|
||||
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<ValidEvent> {
|
||||
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<S>,
|
||||
broadcast_tx: &'a broadcast::Sender<RawEvent>,
|
||||
notify: &'a WriterNotify,
|
||||
synced_seq: &'a AtomicU64,
|
||||
pending_bytes: &'a PendingBytesBudget,
|
||||
}
|
||||
|
||||
fn post_sync<S: StorageIO>(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<S: StorageIO>(writer: &mut EventLogWriter<S>, 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<S: StorageIO>(
|
||||
writer: &mut EventLogWriter<S>,
|
||||
events: Vec<ValidEvent>,
|
||||
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<S: StorageIO>(
|
||||
writer: &mut EventLogWriter<S>,
|
||||
response: flume::Sender<io::Result<SyncResult>>,
|
||||
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<S: StorageIO>(
|
||||
writer: &mut EventLogWriter<S>,
|
||||
response: flume::Sender<io::Result<FreezeResponse>>,
|
||||
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<S: StorageIO>(
|
||||
receiver: &flume::Receiver<WriterRequest>,
|
||||
writer: &mut EventLogWriter<S>,
|
||||
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<dyn std::any::Any + Send>) {
|
||||
let msg = payload
|
||||
.downcast_ref::<&str>()
|
||||
.copied()
|
||||
.or_else(|| payload.downcast_ref::<String>().map(|s| s.as_str()))
|
||||
.unwrap_or("unknown panic");
|
||||
tracing::error!(panic = msg, "eventlog commit thread panicked");
|
||||
}
|
||||
|
||||
pub struct CommitThread {
|
||||
sender: flume::Sender<WriterRequest>,
|
||||
handle: Mutex<Option<std::thread::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl CommitThread {
|
||||
pub fn spawn<S: StorageIO + 'static>(
|
||||
mut writer: EventLogWriter<S>,
|
||||
reader: Arc<EventLogReader<S>>,
|
||||
broadcast_tx: broadcast::Sender<RawEvent>,
|
||||
notify: Arc<WriterNotify>,
|
||||
synced_seq: Arc<AtomicU64>,
|
||||
pending_bytes: Arc<PendingBytesBudget>,
|
||||
) -> io::Result<Self> {
|
||||
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<WriterRequest> {
|
||||
&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);
|
||||
}
|
||||
}
|
||||
@@ -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<SegmentId> {
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
let ext = path.extension()?.to_str()?;
|
||||
(ext == SEGMENT_FILE_EXTENSION).then(|| stem.parse::<u32>().ok().map(SegmentId::new))?
|
||||
}
|
||||
|
||||
struct CachedSegmentHandle {
|
||||
fd: FileId,
|
||||
@@ -34,6 +45,10 @@ impl<S: StorageIO> SegmentManager<S> {
|
||||
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<S: StorageIO> SegmentManager<S> {
|
||||
}
|
||||
|
||||
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<S: StorageIO> SegmentManager<S> {
|
||||
.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<Vec<SegmentId>> {
|
||||
let entries = self.io.list_dir(&self.segments_dir)?;
|
||||
let mut ids: Vec<SegmentId> = 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::<u32>().ok().map(SegmentId::new))?
|
||||
})
|
||||
.collect();
|
||||
let mut ids: Vec<SegmentId> = entries.iter().filter_map(|p| parse_segment_id(p)).collect();
|
||||
ids.sort();
|
||||
Ok(ids)
|
||||
}
|
||||
@@ -222,11 +233,13 @@ impl<S: StorageIO> SegmentManager<S> {
|
||||
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));
|
||||
|
||||
@@ -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<SegmentId>,
|
||||
}
|
||||
|
||||
pub struct EventLogFreezeGuard {
|
||||
_resume: Option<flume::Sender<()>>,
|
||||
}
|
||||
|
||||
impl Drop for EventLogFreezeGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(resume) = self._resume.take() {
|
||||
let _ = resume.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EventLog<S: StorageIO> {
|
||||
writer: Mutex<EventLogWriter<S>>,
|
||||
commit_thread: CommitThread,
|
||||
reader: Arc<EventLogReader<S>>,
|
||||
manager: Arc<SegmentManager<S>>,
|
||||
broadcast_tx: broadcast::Sender<RawEvent>,
|
||||
synced_seq: AtomicU64,
|
||||
consecutive_sync_failures: AtomicU32,
|
||||
synced_seq: Arc<AtomicU64>,
|
||||
notify: Arc<WriterNotify>,
|
||||
pending_bytes: Arc<PendingBytesBudget>,
|
||||
next_seq: AtomicU64,
|
||||
max_payload: u32,
|
||||
}
|
||||
|
||||
impl<S: StorageIO> EventLog<S> {
|
||||
impl<S: StorageIO + 'static> EventLog<S> {
|
||||
pub fn open(config: EventLogConfig, io: S) -> io::Result<Self> {
|
||||
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<S: StorageIO> EventLog<S> {
|
||||
) -> io::Result<EventSequence> {
|
||||
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<SyncResult> {
|
||||
self.sync_and_broadcast()
|
||||
self.sync_data()
|
||||
}
|
||||
|
||||
pub fn append_and_sync(
|
||||
@@ -143,7 +242,7 @@ impl<S: StorageIO> EventLog<S> {
|
||||
event: &SequencedEvent,
|
||||
) -> io::Result<EventSequence> {
|
||||
let seq = self.append_event(did, event_type, event)?;
|
||||
self.sync_and_broadcast()?;
|
||||
self.group_sync(seq)?;
|
||||
Ok(seq)
|
||||
}
|
||||
|
||||
@@ -151,44 +250,21 @@ impl<S: StorageIO> EventLog<S> {
|
||||
&self,
|
||||
events: Vec<(&Did, RepoEventType, &SequencedEvent)>,
|
||||
) -> io::Result<Vec<EventSequence>> {
|
||||
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<SyncResult> {
|
||||
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<SyncResult> {
|
||||
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<S: StorageIO> EventLog<S> {
|
||||
}
|
||||
|
||||
pub fn maybe_rotate(&self) -> io::Result<bool> {
|
||||
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<usize> {
|
||||
self.run_retention_at(TimestampMicros::now(), max_age)
|
||||
}
|
||||
|
||||
pub fn run_retention_at(&self, now: TimestampMicros, max_age: Duration) -> io::Result<usize> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
@@ -294,6 +355,7 @@ impl<S: StorageIO> EventLog<S> {
|
||||
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<S: StorageIO> EventLog<S> {
|
||||
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<S: StorageIO> EventLog<S> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn snapshot_state(&self) -> io::Result<EventLogSnapshotState> {
|
||||
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<SegmentId> = 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<S> {
|
||||
@@ -337,34 +484,22 @@ impl<S: StorageIO> EventLog<S> {
|
||||
&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<S: StorageIO + Send + Sync> PostBlockstoreHook for EventLog<S> {
|
||||
impl<S: StorageIO + Send + Sync + 'static> PostBlockstoreHook for EventLog<S> {
|
||||
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<S: StorageIO> EventLogSubscriber<S> {
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Vec<u8>>,
|
||||
pub ops: Option<Vec<u8>>,
|
||||
pub blobs: Option<Vec<String>>,
|
||||
pub blocks_cids: Option<Vec<String>>,
|
||||
pub blocks: Option<Vec<EventBlockInline>>,
|
||||
pub handle: Option<String>,
|
||||
pub active: Option<bool>,
|
||||
pub status: Option<u8>,
|
||||
@@ -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<EventPayload, PayloadError> {
|
||||
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,
|
||||
|
||||
@@ -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<EventSequence>,
|
||||
pub max_seq: Option<EventSequence>,
|
||||
pub gaps: Vec<SequenceGap>,
|
||||
}
|
||||
|
||||
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<S: StorageIO> {
|
||||
manager: Arc<SegmentManager<S>>,
|
||||
indexes: RwLock<HashMap<SegmentId, Arc<SegmentIndex>>>,
|
||||
sidecars: RwLock<HashMap<SegmentId, Arc<SidecarIndex>>>,
|
||||
ranges: RwLock<Vec<SegmentRange>>,
|
||||
mmaps: RwLock<HashMap<SegmentId, Arc<MappedFile>>>,
|
||||
active_segment: RwLock<Option<SegmentId>>,
|
||||
use_mmap: bool,
|
||||
skip_sealed_checksum: bool,
|
||||
max_payload: u32,
|
||||
}
|
||||
|
||||
impl<S: StorageIO> EventLogReader<S> {
|
||||
pub fn new(manager: Arc<SegmentManager<S>>, use_mmap: bool) -> Self {
|
||||
pub fn new(
|
||||
manager: Arc<SegmentManager<S>>,
|
||||
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<S: StorageIO> EventLogReader<S> {
|
||||
|
||||
fn rebuild_index(&self, segment_id: SegmentId) -> io::Result<SegmentIndex> {
|
||||
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<S: StorageIO> EventLogReader<S> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_sequence_contiguity(&self) -> SequenceContiguityResult {
|
||||
let ranges = self.ranges.read();
|
||||
let mut gaps: Vec<SequenceGap> = 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<S: StorageIO> EventLogReader<S> {
|
||||
mut predicate: impl FnMut(&EventSequence) -> bool,
|
||||
) -> io::Result<bool> {
|
||||
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<S: StorageIO> EventLogReader<S> {
|
||||
|
||||
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<S: StorageIO> EventLogReader<S> {
|
||||
};
|
||||
|
||||
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<bool> {
|
||||
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<S: StorageIO> EventLogReader<S> {
|
||||
}
|
||||
};
|
||||
|
||||
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<S: StorageIO> EventLogReader<S> {
|
||||
) -> 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<S: StorageIO> EventLogReader<S> {
|
||||
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<Option<Arc<SidecarIndex>>> {
|
||||
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<SidecarIndex> {
|
||||
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<MappedFile>);
|
||||
|
||||
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<MmapDecodeResult> {
|
||||
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<SimulatedIO> =
|
||||
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!(
|
||||
|
||||
@@ -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<S: StorageIO>(
|
||||
fd: FileId,
|
||||
offset: SegmentOffset,
|
||||
event: &ValidEvent,
|
||||
max_payload: u32,
|
||||
) -> io::Result<u64> {
|
||||
let payload_len = u32::try_from(event.payload.len()).map_err(|_| {
|
||||
io::Error::new(
|
||||
@@ -53,10 +53,10 @@ pub fn encode_event_record<S: StorageIO>(
|
||||
"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<S: StorageIO>(
|
||||
fd: FileId,
|
||||
offset: SegmentOffset,
|
||||
file_size: u64,
|
||||
max_payload: u32,
|
||||
) -> io::Result<Option<ReadEventRecord>> {
|
||||
let raw = offset.raw();
|
||||
if raw > file_size {
|
||||
@@ -128,7 +129,7 @@ pub fn decode_event_record<S: StorageIO>(
|
||||
};
|
||||
|
||||
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<S: StorageIO>(
|
||||
fd: FileId,
|
||||
offset: SegmentOffset,
|
||||
file_size: u64,
|
||||
max_payload: u32,
|
||||
) -> io::Result<Option<ValidateEventRecord>> {
|
||||
let raw = offset.raw();
|
||||
assert!(
|
||||
@@ -216,7 +218,7 @@ pub fn validate_event_record<S: StorageIO>(
|
||||
}
|
||||
|
||||
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<EventSequence>,
|
||||
max_payload: u32,
|
||||
}
|
||||
|
||||
impl SegmentWriter {
|
||||
@@ -274,6 +277,7 @@ impl SegmentWriter {
|
||||
fd: FileId,
|
||||
segment_id: SegmentId,
|
||||
base_seq: EventSequence,
|
||||
max_payload: u32,
|
||||
) -> io::Result<Self> {
|
||||
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<EventSequence>,
|
||||
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<S: StorageIO>(
|
||||
&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<Self> {
|
||||
pub fn open(io: &'a S, fd: FileId, max_payload: u32) -> io::Result<Self> {
|
||||
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<Vec<ValidEvent>> {
|
||||
self.map(|result| {
|
||||
result.map(|record| match record {
|
||||
@@ -430,7 +448,13 @@ impl<S: StorageIO> Iterator for SegmentReader<'_, S> {
|
||||
type Item = io::Result<ReadEventRecord>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
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<S: StorageIO> 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<ValidEvent> = (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 { .. }),
|
||||
|
||||
@@ -159,6 +159,7 @@ pub fn rebuild_from_segment<S: StorageIO>(
|
||||
io: &S,
|
||||
segment_fd: FileId,
|
||||
index_interval: usize,
|
||||
max_payload: u32,
|
||||
) -> io::Result<(SegmentIndex, Option<EventSequence>)> {
|
||||
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<S: StorageIO>(
|
||||
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);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
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::<EventTypeTag>(&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::<EventLength>(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_sequence_to_sequence_number() {
|
||||
let es = EventSequence::new(42);
|
||||
|
||||
@@ -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<S: StorageIO> {
|
||||
next_seq: EventSequence,
|
||||
synced_seq: EventSequence,
|
||||
index_interval: usize,
|
||||
max_payload: u32,
|
||||
event_count_in_segment: usize,
|
||||
last_event_offset: Option<SegmentOffset>,
|
||||
pending_events: Vec<ValidEvent>,
|
||||
}
|
||||
|
||||
impl<S: StorageIO> EventLogWriter<S> {
|
||||
pub fn open(manager: Arc<SegmentManager<S>>, index_interval: usize) -> io::Result<Self> {
|
||||
pub fn open(
|
||||
manager: Arc<SegmentManager<S>>,
|
||||
index_interval: usize,
|
||||
max_payload: u32,
|
||||
) -> io::Result<Self> {
|
||||
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<S: StorageIO> EventLogWriter<S> {
|
||||
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<S: StorageIO> EventLogWriter<S> {
|
||||
segment_id: SegmentId,
|
||||
next_seq: EventSequence,
|
||||
index_interval: usize,
|
||||
max_payload: u32,
|
||||
) -> io::Result<Self> {
|
||||
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<S: StorageIO> EventLogWriter<S> {
|
||||
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<S: StorageIO> EventLogWriter<S> {
|
||||
segments: &[SegmentId],
|
||||
active_id: SegmentId,
|
||||
index_interval: usize,
|
||||
max_payload: u32,
|
||||
) -> io::Result<Self> {
|
||||
let fd = manager.open_for_append(active_id)?;
|
||||
|
||||
@@ -87,6 +99,7 @@ impl<S: StorageIO> EventLogWriter<S> {
|
||||
manager.io(),
|
||||
fd,
|
||||
index_interval,
|
||||
max_payload,
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(rebuild_err) => {
|
||||
@@ -94,13 +107,15 @@ impl<S: StorageIO> EventLogWriter<S> {
|
||||
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<S: StorageIO> EventLogWriter<S> {
|
||||
|
||||
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<S: StorageIO> EventLogWriter<S> {
|
||||
}
|
||||
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<S: StorageIO> EventLogWriter<S> {
|
||||
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<S: StorageIO> EventLogWriter<S> {
|
||||
next_seq,
|
||||
synced_seq,
|
||||
index_interval,
|
||||
max_payload,
|
||||
event_count_in_segment,
|
||||
last_event_offset,
|
||||
pending_events: Vec::new(),
|
||||
@@ -188,12 +206,11 @@ impl<S: StorageIO> EventLogWriter<S> {
|
||||
) -> io::Result<EventSequence> {
|
||||
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<S: StorageIO> EventLogWriter<S> {
|
||||
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<SyncResult> {
|
||||
if !self.pending_events.is_empty() {
|
||||
self.active_writer.sync(self.manager.io())?;
|
||||
@@ -260,9 +303,20 @@ impl<S: StorageIO> EventLogWriter<S> {
|
||||
|
||||
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::<S>(self.manager.io(), new_fd, new_id, self.next_seq) {
|
||||
match SegmentWriter::new::<S>(
|
||||
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<S: StorageIO> EventLogWriter<S> {
|
||||
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<S: StorageIO> EventLogWriter<S> {
|
||||
fn find_last_seq_from_segments<S: StorageIO>(
|
||||
manager: &SegmentManager<S>,
|
||||
segments: &[SegmentId],
|
||||
max_payload: u32,
|
||||
) -> io::Result<Option<EventSequence>> {
|
||||
segments.iter().rev().try_fold(None, |acc, &seg_id| {
|
||||
if acc.is_some() {
|
||||
@@ -336,7 +398,8 @@ fn find_last_seq_from_segments<S: StorageIO>(
|
||||
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<EventSequence> = (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();
|
||||
|
||||
@@ -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<Vec<u8>> = (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<Vec<u8>> = (0..8).map(|i| format!("item-{i}").into_bytes()).collect();
|
||||
let Ok(result) = run_pristine_comparison(seed, FaultConfig::moderate(), &payloads, 4)
|
||||
else {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<Backlink> = (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(),
|
||||
};
|
||||
|
||||
@@ -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<T>(rx: oneshot::Receiver<Result<T, DbError>>) -> Result<T, DbError> {
|
||||
@@ -88,6 +88,7 @@ async fn recv_migration_reactivation(
|
||||
|
||||
pub struct MetastoreClient<S: StorageIO> {
|
||||
pool: Arc<HandlerPool>,
|
||||
event_log: Arc<EventLog<S>>,
|
||||
_phantom: PhantomData<S>,
|
||||
}
|
||||
|
||||
@@ -95,15 +96,17 @@ impl<S: StorageIO> Clone for MetastoreClient<S> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
pool: Arc::clone(&self.pool),
|
||||
event_log: Arc::clone(&self.event_log),
|
||||
_phantom: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: StorageIO> MetastoreClient<S> {
|
||||
pub fn new(pool: Arc<HandlerPool>) -> Self {
|
||||
pub fn new(pool: Arc<HandlerPool>, event_log: Arc<EventLog<S>>) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
event_log,
|
||||
_phantom: PhantomData,
|
||||
}
|
||||
}
|
||||
@@ -112,6 +115,10 @@ impl<S: StorageIO> MetastoreClient<S> {
|
||||
&self.pool
|
||||
}
|
||||
|
||||
pub fn event_log(&self) -> &Arc<EventLog<S>> {
|
||||
&self.event_log
|
||||
}
|
||||
|
||||
pub async fn create_repo_full(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
@@ -467,20 +474,6 @@ impl<S: StorageIO + 'static> tranquil_db_traits::RepoRepository for MetastoreCli
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn find_unreferenced_blocks(
|
||||
&self,
|
||||
candidate_cids: &[Vec<u8>],
|
||||
) -> Result<Vec<Vec<u8>>, 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<SequenceNumber, DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool
|
||||
@@ -526,6 +519,7 @@ impl<S: StorageIO + 'static> tranquil_db_traits::RepoRepository for MetastoreCli
|
||||
did: &Did,
|
||||
commit_cid: &CidLink,
|
||||
rev: Option<&str>,
|
||||
commit_bytes: &[u8],
|
||||
) -> Result<SequenceNumber, DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool
|
||||
@@ -533,6 +527,7 @@ impl<S: StorageIO + 'static> 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<S: StorageIO + 'static> tranquil_db_traits::RepoRepository for MetastoreCli
|
||||
commit_cid: &CidLink,
|
||||
mst_root_cid: &CidLink,
|
||||
rev: &str,
|
||||
commit_bytes: &[u8],
|
||||
mst_root_bytes: &[u8],
|
||||
) -> Result<SequenceNumber, DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool.send(MetastoreRequest::Event(
|
||||
@@ -552,27 +549,14 @@ impl<S: StorageIO + 'static> 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<S: StorageIO + 'static> tranquil_db_traits::RepoRepository for MetastoreCli
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn prune_events_older_than(&self, cutoff: DateTime<Utc>) -> Result<PruneCount, DbError> {
|
||||
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<SequenceNumber, DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool
|
||||
@@ -674,21 +686,6 @@ impl<S: StorageIO + 'static> tranquil_db_traits::RepoRepository for MetastoreCli
|
||||
recv(rx).await
|
||||
}
|
||||
|
||||
async fn get_events_since_rev(
|
||||
&self,
|
||||
did: &Did,
|
||||
since_rev: &str,
|
||||
) -> Result<Vec<EventBlocksCids>, 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<S: StorageIO + 'static> tranquil_db_traits::RepoRepository for MetastoreCli
|
||||
recv_commit(rx).await
|
||||
}
|
||||
|
||||
async fn get_broken_genesis_commits(&self) -> Result<Vec<BrokenGenesisCommit>, 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<Vec<UserWithoutBlocks>, DbError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pool.send(MetastoreRequest::Commit(Box::new(
|
||||
|
||||
@@ -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<S: StorageIO> {
|
||||
blockstore: Option<TranquilBlockStore>,
|
||||
}
|
||||
|
||||
impl<S: StorageIO> CommitOps<S> {
|
||||
impl<S: StorageIO + 'static> CommitOps<S> {
|
||||
pub fn new(
|
||||
db: Database,
|
||||
repo_data: Keyspace,
|
||||
@@ -382,46 +381,6 @@ impl<S: StorageIO> CommitOps<S> {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_broken_genesis_commits(&self) -> Result<Vec<BrokenGenesisCommit>, 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<BrokenGenesisCommit>,
|
||||
page_size: usize,
|
||||
) -> Result<Vec<BrokenGenesisCommit>, 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<Vec<UserWithoutBlocks>, 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()),
|
||||
},
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl SeqMetaValue {
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
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<Self> {
|
||||
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);
|
||||
|
||||
@@ -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<S: StorageIO> {
|
||||
bridge: Arc<EventLogBridge<S>>,
|
||||
}
|
||||
|
||||
impl<S: StorageIO> EventOps<S> {
|
||||
impl<S: StorageIO + 'static> EventOps<S> {
|
||||
pub fn new(db: Database, repo_data: Keyspace, bridge: Arc<EventLogBridge<S>>) -> Self {
|
||||
Self {
|
||||
db,
|
||||
@@ -61,7 +60,7 @@ impl<S: StorageIO> EventOps<S> {
|
||||
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<S: StorageIO> EventOps<S> {
|
||||
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<S: StorageIO> EventOps<S> {
|
||||
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<S: StorageIO> EventOps<S> {
|
||||
prev_data_cid: None,
|
||||
ops: None,
|
||||
blobs: None,
|
||||
blocks_cids: None,
|
||||
blocks: None,
|
||||
handle: None,
|
||||
active,
|
||||
status: Some(status),
|
||||
@@ -154,7 +156,15 @@ impl<S: StorageIO> EventOps<S> {
|
||||
did: &Did,
|
||||
commit_cid: &CidLink,
|
||||
rev: Option<&str>,
|
||||
commit_bytes: &[u8],
|
||||
) -> Result<SequenceNumber, DbError> {
|
||||
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<S: StorageIO> EventOps<S> {
|
||||
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<S: StorageIO> EventOps<S> {
|
||||
commit_cid: &CidLink,
|
||||
mst_root_cid: &CidLink,
|
||||
rev: &str,
|
||||
commit_bytes: &[u8],
|
||||
mst_root_bytes: &[u8],
|
||||
) -> Result<SequenceNumber, DbError> {
|
||||
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<S: StorageIO> EventOps<S> {
|
||||
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<S: StorageIO> EventOps<S> {
|
||||
limit: Option<i64>,
|
||||
) -> Result<Vec<SequencedEvent>, 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<S: StorageIO> EventOps<S> {
|
||||
end: SequenceNumber,
|
||||
) -> Result<Vec<SequencedEvent>, 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<Option<SequencedEvent>, DbError> {
|
||||
@@ -230,9 +259,7 @@ impl<S: StorageIO> EventOps<S> {
|
||||
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<S: StorageIO> EventOps<S> {
|
||||
limit: i64,
|
||||
) -> Result<Vec<SequencedEvent>, 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<S: StorageIO> EventOps<S> {
|
||||
self.bridge.get_min_seq_since(since)
|
||||
}
|
||||
|
||||
pub fn get_events_since_rev(
|
||||
&self,
|
||||
did: &Did,
|
||||
since_rev: &str,
|
||||
) -> Result<Vec<EventBlocksCids>, 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<S: StorageIO> EventOps<S> {
|
||||
})
|
||||
}
|
||||
|
||||
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<S: StorageIO> EventOps<S> {
|
||||
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<S: StorageIO> EventOps<S> {
|
||||
}
|
||||
}
|
||||
|
||||
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<SequencedEvent>,
|
||||
) -> Result<Vec<SequencedEvent>, DbError> {
|
||||
@@ -739,7 +676,7 @@ impl<S: StorageIO> EventOps<S> {
|
||||
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());
|
||||
|
||||
@@ -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<i64>,
|
||||
},
|
||||
FindUnreferencedBlocks {
|
||||
candidate_cids: Vec<Vec<u8>>,
|
||||
tx: Tx<Vec<Vec<u8>>>,
|
||||
},
|
||||
}
|
||||
|
||||
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<String>,
|
||||
commit_bytes: Vec<u8>,
|
||||
tx: Tx<SequenceNumber>,
|
||||
},
|
||||
InsertGenesisCommitEvent {
|
||||
@@ -359,13 +358,10 @@ pub enum EventRequest {
|
||||
commit_cid: CidLink,
|
||||
mst_root_cid: CidLink,
|
||||
rev: String,
|
||||
commit_bytes: Vec<u8>,
|
||||
mst_root_bytes: Vec<u8>,
|
||||
tx: Tx<SequenceNumber>,
|
||||
},
|
||||
UpdateSeqBlocksCids {
|
||||
seq: SequenceNumber,
|
||||
blocks_cids: Vec<String>,
|
||||
tx: Tx<()>,
|
||||
},
|
||||
DeleteSequencesExcept {
|
||||
did: Did,
|
||||
keep_seq: SequenceNumber,
|
||||
@@ -397,11 +393,6 @@ pub enum EventRequest {
|
||||
limit: i64,
|
||||
tx: Tx<Vec<SequencedEvent>>,
|
||||
},
|
||||
GetEventsSinceRev {
|
||||
did: Did,
|
||||
since_rev: String,
|
||||
tx: Tx<Vec<EventBlocksCids>>,
|
||||
},
|
||||
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<CidLink>,
|
||||
tx: oneshot::Sender<Result<(), ImportRepoError>>,
|
||||
},
|
||||
GetBrokenGenesisCommits {
|
||||
tx: Tx<Vec<BrokenGenesisCommit>>,
|
||||
},
|
||||
GetUsersWithoutBlocks {
|
||||
tx: Tx<Vec<UserWithoutBlocks>>,
|
||||
},
|
||||
@@ -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<S: StorageIO>(state: &HandlerState<S>, 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<S: StorageIO>(state: &HandlerState<S>, req: EventRequest) {
|
||||
fn dispatch_event<S: StorageIO + 'static>(state: &HandlerState<S>, req: EventRequest) {
|
||||
match req {
|
||||
EventRequest::InsertCommitEvent { data, tx } => {
|
||||
let result = state.event_ops.insert_commit_event(&data);
|
||||
@@ -2940,11 +2918,13 @@ fn dispatch_event<S: StorageIO>(state: &HandlerState<S>, 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<S: StorageIO>(state: &HandlerState<S>, 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<S: StorageIO>(state: &HandlerState<S>, 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<S: StorageIO>(state: &HandlerState<S>, req: CommitRequest) {
|
||||
fn dispatch_commit<S: StorageIO + 'static>(state: &HandlerState<S>, req: CommitRequest) {
|
||||
match req {
|
||||
CommitRequest::ApplyCommit { input, tx } => {
|
||||
let _ = tx.send(state.commit_ops.apply_commit(*input));
|
||||
@@ -3026,14 +3001,6 @@ fn dispatch_commit<S: StorageIO>(state: &HandlerState<S>, 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<S: StorageIO>(state: &HandlerState<S>, req: BacklinkRequest
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_blob<S: StorageIO>(state: &HandlerState<S>, req: BlobRequest) {
|
||||
fn dispatch_blob<S: StorageIO + 'static>(state: &HandlerState<S>, req: BlobRequest) {
|
||||
match req {
|
||||
BlobRequest::InsertBlob {
|
||||
cid,
|
||||
@@ -4160,6 +4127,13 @@ fn dispatch_infra<S: StorageIO>(state: &HandlerState<S>, 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<S: StorageIO>(state: &HandlerState<S>, req: OAuthRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch<S: StorageIO>(state: &HandlerState<S>, request: MetastoreRequest) {
|
||||
fn dispatch<S: StorageIO + 'static>(state: &HandlerState<S>, request: MetastoreRequest) {
|
||||
match request {
|
||||
MetastoreRequest::Repo(r) => dispatch_repo(state, r),
|
||||
MetastoreRequest::Record(r) => dispatch_record(state, r),
|
||||
@@ -5023,7 +4997,7 @@ fn dispatch<S: StorageIO>(state: &HandlerState<S>, request: MetastoreRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_user<S: StorageIO>(state: &HandlerState<S>, req: UserRequest) {
|
||||
fn dispatch_user<S: StorageIO + 'static>(state: &HandlerState<S>, req: UserRequest) {
|
||||
let user = state.metastore.user_ops();
|
||||
match req {
|
||||
UserRequest::GetByDid { did, tx } => {
|
||||
@@ -5768,7 +5742,17 @@ fn dispatch_user<S: StorageIO>(state: &HandlerState<S>, 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<S: StorageIO>(state: &HandlerState<S>, 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<S: StorageIO>(state: &HandlerState<S>, req: UserRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
fn handler_loop<S: StorageIO>(
|
||||
fn handler_loop<S: StorageIO + 'static>(
|
||||
metastore: Metastore,
|
||||
bridge: Arc<EventLogBridge<S>>,
|
||||
blockstore: Option<TranquilBlockStore>,
|
||||
|
||||
@@ -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<UserHashMap>,
|
||||
comms_seq: Arc<std::sync::atomic::AtomicU32>,
|
||||
}
|
||||
|
||||
impl InfraOps {
|
||||
@@ -44,6 +46,7 @@ impl InfraOps {
|
||||
repo_data: Keyspace,
|
||||
users: Keyspace,
|
||||
user_hashes: Arc<UserHashMap>,
|
||||
comms_seq: Arc<std::sync::atomic::AtomicU32>,
|
||||
) -> 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<Option<(SmallVec<[u8; 128]>, 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> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<UserHashMap>,
|
||||
counter_lock: Arc<parking_lot::Mutex<()>>,
|
||||
comms_seq: Arc<std::sync::atomic::AtomicU32>,
|
||||
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<S: crate::io::StorageIO>(
|
||||
pub fn event_ops<S: crate::io::StorageIO + 'static>(
|
||||
&self,
|
||||
bridge: Arc<crate::eventlog::EventLogBridge<S>>,
|
||||
) -> event_ops::EventOps<S> {
|
||||
@@ -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<S: crate::io::StorageIO>(
|
||||
pub fn commit_ops<S: crate::io::StorageIO + 'static>(
|
||||
&self,
|
||||
bridge: Arc<crate::eventlog::EventLogBridge<S>>,
|
||||
) -> commit_ops::CommitOps<S> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user