Compare commits
28
Commits
5e9836c931
...
408138b3c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
408138b3c8 | ||
|
|
94da39bb79 | ||
|
|
108059146d | ||
|
|
3d2d0ea025 | ||
|
|
273229ac9b | ||
|
|
0739966e55 | ||
|
|
58d3bc92d7 | ||
|
|
98fbac07f2 | ||
|
|
e34de4acc9 | ||
|
|
56bec62a6e | ||
|
|
b833a21848 | ||
|
|
5905dbc06f | ||
|
|
0fe1530e25 | ||
|
|
294ab9181f | ||
|
|
fe2d4fc5f0 | ||
|
|
08960a9bcf | ||
|
|
349063968b | ||
|
|
3c0e3b14ff | ||
|
|
fede839eb6 | ||
|
|
cf913b1d52 | ||
|
|
59de99ca0b | ||
|
|
2c58db5105 | ||
|
|
8f154b2157 | ||
|
|
40b6791fea | ||
|
|
8e97a5620d | ||
|
|
dde7f38af0 | ||
|
|
7557c38e6c | ||
|
|
c02bbc57e3 |
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# Go build artifacts
|
||||||
|
*.test
|
||||||
|
*.prof
|
||||||
|
*.memprof
|
||||||
|
*.out
|
||||||
|
coverage.out
|
||||||
|
coverage.txt
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Project tool artifacts (session caches, review output, evidence dumps)
|
||||||
|
review.json
|
||||||
|
.omo/run-continuation/
|
||||||
|
.omo/evidence/
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
|
||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- errcheck
|
||||||
|
- govet
|
||||||
|
- staticcheck
|
||||||
|
- unused
|
||||||
|
- gosimple
|
||||||
|
- ineffassign
|
||||||
|
- typecheck
|
||||||
|
- misspell
|
||||||
|
- gofmt
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
errcheck:
|
||||||
|
check-type-assertions: true
|
||||||
|
govet:
|
||||||
|
enable-all: true
|
||||||
|
|
||||||
|
issues:
|
||||||
|
max-issues-per-linter: 50
|
||||||
|
max-same-issues: 5
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 2,
|
||||||
|
"active_work_id": "phase1-wal-3bc18f0c",
|
||||||
|
"works": {
|
||||||
|
"phase1-wal-3bc18f0c": {
|
||||||
|
"work_id": "phase1-wal-3bc18f0c",
|
||||||
|
"active_plan": "/home/dailz/workspace/src/go-kv/.omo/plans/phase1-wal.md",
|
||||||
|
"plan_name": "phase1-wal",
|
||||||
|
"status": "active",
|
||||||
|
"started_at": "2026-06-12T05:09:32.588Z",
|
||||||
|
"updated_at": "2026-06-12T06:29:05.569Z",
|
||||||
|
"session_ids": [
|
||||||
|
"opencode:ses_145c3bae9ffeTB2zbsTym0Cev8"
|
||||||
|
],
|
||||||
|
"session_origins": {
|
||||||
|
"opencode:ses_145c3bae9ffeTB2zbsTym0Cev8": "direct"
|
||||||
|
},
|
||||||
|
"agent": "atlas",
|
||||||
|
"task_sessions": {
|
||||||
|
"todo:1": {
|
||||||
|
"task_key": "todo:1",
|
||||||
|
"task_label": "1",
|
||||||
|
"task_title": "Project scaffolding + go.mod + config types",
|
||||||
|
"session_id": "opencode:ses_145c254a5ffemom68fK0rmB26W",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "quick",
|
||||||
|
"updated_at": "2026-06-12T05:15:14.028Z",
|
||||||
|
"started_at": "2026-06-12T05:14:15.365Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T05:15:14.028Z",
|
||||||
|
"elapsed_ms": 58663
|
||||||
|
},
|
||||||
|
"todo:2": {
|
||||||
|
"task_key": "todo:2",
|
||||||
|
"task_label": "2",
|
||||||
|
"task_title": "Error types + constants",
|
||||||
|
"session_id": "opencode:ses_145bdf583ffe6PSExxk7J357zT",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "quick",
|
||||||
|
"updated_at": "2026-06-12T05:22:47.301Z",
|
||||||
|
"started_at": "2026-06-12T05:19:53.997Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T05:22:47.301Z",
|
||||||
|
"elapsed_ms": 173304
|
||||||
|
},
|
||||||
|
"todo:6": {
|
||||||
|
"task_key": "todo:6",
|
||||||
|
"task_label": "6",
|
||||||
|
"task_title": "WAL Batch encode/decode + fragment collector",
|
||||||
|
"session_id": "opencode:ses_145b65d08ffebmSv4sk5I69JXZ",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "unspecified-high",
|
||||||
|
"updated_at": "2026-06-12T05:26:58.955Z",
|
||||||
|
"started_at": "2026-06-12T05:26:43.370Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T05:26:58.955Z",
|
||||||
|
"elapsed_ms": 15585
|
||||||
|
},
|
||||||
|
"todo:8": {
|
||||||
|
"task_key": "todo:8",
|
||||||
|
"task_label": "8",
|
||||||
|
"task_title": "SkipList (mutex write + lock-free read)",
|
||||||
|
"session_id": "opencode:ses_145b2e10affeQnDhHreILvKkkW",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "deep",
|
||||||
|
"updated_at": "2026-06-12T05:33:52.854Z",
|
||||||
|
"started_at": "2026-06-12T05:32:48.230Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T05:33:52.854Z",
|
||||||
|
"elapsed_ms": 64624
|
||||||
|
},
|
||||||
|
"todo:10": {
|
||||||
|
"task_key": "todo:10",
|
||||||
|
"task_label": "10",
|
||||||
|
"task_title": "Segment writer (file write + block writer)",
|
||||||
|
"session_id": "opencode:ses_145acc017ffe2fra67VT2L28LQ",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "unspecified-high",
|
||||||
|
"updated_at": "2026-06-12T05:43:11.052Z",
|
||||||
|
"started_at": "2026-06-12T05:42:38.284Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T05:43:11.052Z",
|
||||||
|
"elapsed_ms": 32768
|
||||||
|
},
|
||||||
|
"todo:11": {
|
||||||
|
"task_key": "todo:11",
|
||||||
|
"task_label": "11",
|
||||||
|
"task_title": "Segment rotation + durable-ready protocol",
|
||||||
|
"session_id": "opencode:ses_145a44184ffeEK5zu36ABE7jHM",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "unspecified-high",
|
||||||
|
"updated_at": "2026-06-12T05:49:50.501Z",
|
||||||
|
"started_at": "2026-06-12T05:48:04.491Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T05:49:50.501Z",
|
||||||
|
"elapsed_ms": 106010
|
||||||
|
},
|
||||||
|
"todo:13": {
|
||||||
|
"task_key": "todo:13",
|
||||||
|
"task_label": "13",
|
||||||
|
"task_title": "Commit queue + WAL writer main loop",
|
||||||
|
"session_id": "opencode:ses_1459dbbd5ffewqrKSfu5f6t38q",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "deep",
|
||||||
|
"updated_at": "2026-06-12T05:57:16.412Z",
|
||||||
|
"started_at": "2026-06-12T05:56:53.263Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T05:57:16.412Z",
|
||||||
|
"elapsed_ms": 23149
|
||||||
|
},
|
||||||
|
"todo:17": {
|
||||||
|
"task_key": "todo:17",
|
||||||
|
"task_label": "17",
|
||||||
|
"task_title": "WAL recovery: tail truncation + main flow",
|
||||||
|
"session_id": "opencode:ses_14597418effebiQE4kDHOCEW0E",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "unspecified-high",
|
||||||
|
"updated_at": "2026-06-12T06:02:11.867Z",
|
||||||
|
"started_at": "2026-06-12T06:01:55.154Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T06:02:11.867Z",
|
||||||
|
"elapsed_ms": 16713
|
||||||
|
},
|
||||||
|
"todo:19": {
|
||||||
|
"task_key": "todo:19",
|
||||||
|
"task_label": "19",
|
||||||
|
"task_title": "DB integration: Open/Close/Put/Delete/Get",
|
||||||
|
"session_id": "opencode:ses_1459293beffeLZq5Ac22xWZfBy",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "deep",
|
||||||
|
"updated_at": "2026-06-12T06:09:22.858Z",
|
||||||
|
"started_at": "2026-06-12T06:07:14.120Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T06:09:22.858Z",
|
||||||
|
"elapsed_ms": 128738
|
||||||
|
},
|
||||||
|
"todo:20": {
|
||||||
|
"task_key": "todo:20",
|
||||||
|
"task_label": "20",
|
||||||
|
"task_title": "End-to-end + crash recovery tests",
|
||||||
|
"session_id": "opencode:ses_1458aa770ffePqE4ip81RsOmNR",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "deep",
|
||||||
|
"updated_at": "2026-06-12T06:20:06.478Z",
|
||||||
|
"started_at": "2026-06-12T06:18:40.751Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T06:20:06.478Z",
|
||||||
|
"elapsed_ms": 85727
|
||||||
|
},
|
||||||
|
"final-wave:f1": {
|
||||||
|
"task_key": "final-wave:f1",
|
||||||
|
"task_label": "F1",
|
||||||
|
"task_title": "**Plan Compliance Audit** — `oracle`",
|
||||||
|
"session_id": "opencode:ses_1457cb69affe9rYiUhKWDB2kDd",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "unspecified-high",
|
||||||
|
"started_at": "2026-06-12T06:23:49.269Z",
|
||||||
|
"status": "running",
|
||||||
|
"updated_at": "2026-06-12T06:29:05.572Z"
|
||||||
|
},
|
||||||
|
"final-wave:f3": {
|
||||||
|
"task_key": "final-wave:f3",
|
||||||
|
"task_label": "F3",
|
||||||
|
"task_title": "Real Manual QA — execute all QA scenarios from the plan and verify cross-module integration.",
|
||||||
|
"session_id": "opencode:ses_1458206c0ffeyx0AQoGlHap1Wb",
|
||||||
|
"agent": "oracle",
|
||||||
|
"updated_at": "2026-06-12T06:23:50.560Z",
|
||||||
|
"started_at": "2026-06-12T06:23:50.560Z",
|
||||||
|
"status": "running"
|
||||||
|
},
|
||||||
|
"final-wave:f2": {
|
||||||
|
"task_key": "final-wave:f2",
|
||||||
|
"task_label": "F2",
|
||||||
|
"task_title": "Code Quality Review — run static analysis and review all .go files for quality issues.",
|
||||||
|
"session_id": "opencode:ses_145823a08ffeN5NdqAbplhAFfu",
|
||||||
|
"agent": "oracle",
|
||||||
|
"updated_at": "2026-06-12T06:23:51.328Z",
|
||||||
|
"started_at": "2026-06-12T06:23:51.328Z",
|
||||||
|
"status": "running"
|
||||||
|
},
|
||||||
|
"final-wave:f4": {
|
||||||
|
"task_key": "final-wave:f4",
|
||||||
|
"task_label": "F4",
|
||||||
|
"task_title": "Scope Fidelity Check — verify each task's implementation matches its spec with no scope creep.",
|
||||||
|
"session_id": "opencode:ses_14581c42cffeKPlQGJV5yvUNk5",
|
||||||
|
"agent": "oracle",
|
||||||
|
"updated_at": "2026-06-12T06:24:29.653Z",
|
||||||
|
"started_at": "2026-06-12T06:24:29.653Z",
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active_plan": "/home/dailz/workspace/src/go-kv/.omo/plans/phase1-wal.md",
|
||||||
|
"started_at": "2026-06-12T05:09:32.588Z",
|
||||||
|
"status": "active",
|
||||||
|
"updated_at": "2026-06-12T06:29:05.569Z",
|
||||||
|
"session_ids": [
|
||||||
|
"opencode:ses_145c3bae9ffeTB2zbsTym0Cev8"
|
||||||
|
],
|
||||||
|
"session_origins": {
|
||||||
|
"opencode:ses_145c3bae9ffeTB2zbsTym0Cev8": "direct"
|
||||||
|
},
|
||||||
|
"plan_name": "phase1-wal",
|
||||||
|
"task_sessions": {
|
||||||
|
"todo:1": {
|
||||||
|
"task_key": "todo:1",
|
||||||
|
"task_label": "1",
|
||||||
|
"task_title": "Project scaffolding + go.mod + config types",
|
||||||
|
"session_id": "opencode:ses_145c254a5ffemom68fK0rmB26W",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "quick",
|
||||||
|
"updated_at": "2026-06-12T05:15:14.028Z",
|
||||||
|
"started_at": "2026-06-12T05:14:15.365Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T05:15:14.028Z",
|
||||||
|
"elapsed_ms": 58663
|
||||||
|
},
|
||||||
|
"todo:2": {
|
||||||
|
"task_key": "todo:2",
|
||||||
|
"task_label": "2",
|
||||||
|
"task_title": "Error types + constants",
|
||||||
|
"session_id": "opencode:ses_145bdf583ffe6PSExxk7J357zT",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "quick",
|
||||||
|
"updated_at": "2026-06-12T05:22:47.301Z",
|
||||||
|
"started_at": "2026-06-12T05:19:53.997Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T05:22:47.301Z",
|
||||||
|
"elapsed_ms": 173304
|
||||||
|
},
|
||||||
|
"todo:6": {
|
||||||
|
"task_key": "todo:6",
|
||||||
|
"task_label": "6",
|
||||||
|
"task_title": "WAL Batch encode/decode + fragment collector",
|
||||||
|
"session_id": "opencode:ses_145b65d08ffebmSv4sk5I69JXZ",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "unspecified-high",
|
||||||
|
"updated_at": "2026-06-12T05:26:58.955Z",
|
||||||
|
"started_at": "2026-06-12T05:26:43.370Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T05:26:58.955Z",
|
||||||
|
"elapsed_ms": 15585
|
||||||
|
},
|
||||||
|
"todo:8": {
|
||||||
|
"task_key": "todo:8",
|
||||||
|
"task_label": "8",
|
||||||
|
"task_title": "SkipList (mutex write + lock-free read)",
|
||||||
|
"session_id": "opencode:ses_145b2e10affeQnDhHreILvKkkW",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "deep",
|
||||||
|
"updated_at": "2026-06-12T05:33:52.854Z",
|
||||||
|
"started_at": "2026-06-12T05:32:48.230Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T05:33:52.854Z",
|
||||||
|
"elapsed_ms": 64624
|
||||||
|
},
|
||||||
|
"todo:10": {
|
||||||
|
"task_key": "todo:10",
|
||||||
|
"task_label": "10",
|
||||||
|
"task_title": "Segment writer (file write + block writer)",
|
||||||
|
"session_id": "opencode:ses_145acc017ffe2fra67VT2L28LQ",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "unspecified-high",
|
||||||
|
"updated_at": "2026-06-12T05:43:11.052Z",
|
||||||
|
"started_at": "2026-06-12T05:42:38.284Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T05:43:11.052Z",
|
||||||
|
"elapsed_ms": 32768
|
||||||
|
},
|
||||||
|
"todo:11": {
|
||||||
|
"task_key": "todo:11",
|
||||||
|
"task_label": "11",
|
||||||
|
"task_title": "Segment rotation + durable-ready protocol",
|
||||||
|
"session_id": "opencode:ses_145a44184ffeEK5zu36ABE7jHM",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "unspecified-high",
|
||||||
|
"updated_at": "2026-06-12T05:49:50.501Z",
|
||||||
|
"started_at": "2026-06-12T05:48:04.491Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T05:49:50.501Z",
|
||||||
|
"elapsed_ms": 106010
|
||||||
|
},
|
||||||
|
"todo:13": {
|
||||||
|
"task_key": "todo:13",
|
||||||
|
"task_label": "13",
|
||||||
|
"task_title": "Commit queue + WAL writer main loop",
|
||||||
|
"session_id": "opencode:ses_1459dbbd5ffewqrKSfu5f6t38q",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "deep",
|
||||||
|
"updated_at": "2026-06-12T05:57:16.412Z",
|
||||||
|
"started_at": "2026-06-12T05:56:53.263Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T05:57:16.412Z",
|
||||||
|
"elapsed_ms": 23149
|
||||||
|
},
|
||||||
|
"todo:17": {
|
||||||
|
"task_key": "todo:17",
|
||||||
|
"task_label": "17",
|
||||||
|
"task_title": "WAL recovery: tail truncation + main flow",
|
||||||
|
"session_id": "opencode:ses_14597418effebiQE4kDHOCEW0E",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "unspecified-high",
|
||||||
|
"updated_at": "2026-06-12T06:02:11.867Z",
|
||||||
|
"started_at": "2026-06-12T06:01:55.154Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T06:02:11.867Z",
|
||||||
|
"elapsed_ms": 16713
|
||||||
|
},
|
||||||
|
"todo:19": {
|
||||||
|
"task_key": "todo:19",
|
||||||
|
"task_label": "19",
|
||||||
|
"task_title": "DB integration: Open/Close/Put/Delete/Get",
|
||||||
|
"session_id": "opencode:ses_1459293beffeLZq5Ac22xWZfBy",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "deep",
|
||||||
|
"updated_at": "2026-06-12T06:09:22.858Z",
|
||||||
|
"started_at": "2026-06-12T06:07:14.120Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T06:09:22.858Z",
|
||||||
|
"elapsed_ms": 128738
|
||||||
|
},
|
||||||
|
"todo:20": {
|
||||||
|
"task_key": "todo:20",
|
||||||
|
"task_label": "20",
|
||||||
|
"task_title": "End-to-end + crash recovery tests",
|
||||||
|
"session_id": "opencode:ses_1458aa770ffePqE4ip81RsOmNR",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "deep",
|
||||||
|
"updated_at": "2026-06-12T06:20:06.478Z",
|
||||||
|
"started_at": "2026-06-12T06:18:40.751Z",
|
||||||
|
"status": "completed",
|
||||||
|
"ended_at": "2026-06-12T06:20:06.478Z",
|
||||||
|
"elapsed_ms": 85727
|
||||||
|
},
|
||||||
|
"final-wave:f1": {
|
||||||
|
"task_key": "final-wave:f1",
|
||||||
|
"task_label": "F1",
|
||||||
|
"task_title": "**Plan Compliance Audit** — `oracle`",
|
||||||
|
"session_id": "opencode:ses_1457cb69affe9rYiUhKWDB2kDd",
|
||||||
|
"agent": "Sisyphus-Junior",
|
||||||
|
"category": "unspecified-high",
|
||||||
|
"started_at": "2026-06-12T06:23:49.269Z",
|
||||||
|
"status": "running",
|
||||||
|
"updated_at": "2026-06-12T06:29:05.572Z"
|
||||||
|
},
|
||||||
|
"final-wave:f3": {
|
||||||
|
"task_key": "final-wave:f3",
|
||||||
|
"task_label": "F3",
|
||||||
|
"task_title": "Real Manual QA — execute all QA scenarios from the plan and verify cross-module integration.",
|
||||||
|
"session_id": "opencode:ses_1458206c0ffeyx0AQoGlHap1Wb",
|
||||||
|
"agent": "oracle",
|
||||||
|
"updated_at": "2026-06-12T06:23:50.560Z",
|
||||||
|
"started_at": "2026-06-12T06:23:50.560Z",
|
||||||
|
"status": "running"
|
||||||
|
},
|
||||||
|
"final-wave:f2": {
|
||||||
|
"task_key": "final-wave:f2",
|
||||||
|
"task_label": "F2",
|
||||||
|
"task_title": "Code Quality Review — run static analysis and review all .go files for quality issues.",
|
||||||
|
"session_id": "opencode:ses_145823a08ffeN5NdqAbplhAFfu",
|
||||||
|
"agent": "oracle",
|
||||||
|
"updated_at": "2026-06-12T06:23:51.328Z",
|
||||||
|
"started_at": "2026-06-12T06:23:51.328Z",
|
||||||
|
"status": "running"
|
||||||
|
},
|
||||||
|
"final-wave:f4": {
|
||||||
|
"task_key": "final-wave:f4",
|
||||||
|
"task_label": "F4",
|
||||||
|
"task_title": "Scope Fidelity Check — verify each task's implementation matches its spec with no scope creep.",
|
||||||
|
"session_id": "opencode:ses_14581c42cffeKPlQGJV5yvUNk5",
|
||||||
|
"agent": "oracle",
|
||||||
|
"updated_at": "2026-06-12T06:24:29.653Z",
|
||||||
|
"started_at": "2026-06-12T06:24:29.653Z",
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent": "atlas"
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
# C1 修复方案:CRC 多项式从 IEEE 改为 crc32c
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
> **目标**:把 WAL 所有 CRC 计算从 `crc32.ChecksumIEEE`(IEEE 802.3,多项式 0xEDB88320)改为 `crc32.Checksum(data, crc32cTable)`(Castagnoli,多项式 0x82F63B78)。设计文档明确要求 crc32c,当前代码自洽但和规范脱钩。
|
||||||
|
>
|
||||||
|
> **交付**:
|
||||||
|
> - 新建包级 `crc32cTable` 变量(`crc32.MakeTable(crc32.Castagnoli)`)
|
||||||
|
> - 5 处 `ChecksumIEEE` 调用替换为 `Checksum(data, crc32cTable)`
|
||||||
|
> - 1 个固定向量测试(防回归关键)
|
||||||
|
> - 单次 commit
|
||||||
|
>
|
||||||
|
> **预估工时**:1 小时
|
||||||
|
> **风险**:低。改动局限在 5 处函数调用。**Breaking change**:旧 WAL 文件无法读,但 Phase 1 未 release
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
### Bug 摘要
|
||||||
|
|
||||||
|
5 处 CRC 计算用错多项式:
|
||||||
|
|
||||||
|
| 文件 | 行 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| `wal/header.go:44` | encode WAL header CRC |
|
||||||
|
| `wal/header.go:83` | decode verify WAL header CRC |
|
||||||
|
| `wal/record.go:30` | encode physical record CRC |
|
||||||
|
| `wal/record.go:55` | decode verify physical record CRC |
|
||||||
|
| `wal/block_writer_test.go:349` | test helper |
|
||||||
|
|
||||||
|
当前都用 `crc32.ChecksumIEEE`,但设计要求 `crc32c`。
|
||||||
|
|
||||||
|
### 设计依据
|
||||||
|
|
||||||
|
`docs/design.md` §3.2 line 359(Physical Record 字段说明):
|
||||||
|
|
||||||
|
> | crc32c | 校验 `length + type + payload`,用于识别 torn write、partial write 和数据损坏 |
|
||||||
|
|
||||||
|
WAL 格式中所有 CRC 都是 crc32c(Castagnoli),headerCRC 也应该一致。
|
||||||
|
|
||||||
|
### 为什么这是 bug(即使当前能跑)
|
||||||
|
|
||||||
|
1. **不符合设计**:文档代码不一致
|
||||||
|
2. **失去硬件加速**:Intel SSE4.2 的 `CRC32` 指令只支持 Castagnoli 多项式
|
||||||
|
3. **未来兼容性**:跨实现 / 跨工具读写需要正确的 CRC
|
||||||
|
4. **行业标准**:ext4 / SQLite / RocksDB / LevelDB 都用 crc32c
|
||||||
|
|
||||||
|
### Phase 1 修复成本最低
|
||||||
|
|
||||||
|
- Phase 1 未 release,没有真实数据需要迁移
|
||||||
|
- 越晚改,迁移成本越高(要维护 v1/v2 两套 CRC)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行计划
|
||||||
|
|
||||||
|
### Phase A:代码改动(15 分钟)
|
||||||
|
|
||||||
|
#### A.1 新建 `wal/crc32c.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
package wal
|
||||||
|
|
||||||
|
import "hash/crc32"
|
||||||
|
|
||||||
|
// crc32cTable is the CRC-32 table using the Castagnoli polynomial (0x82F63B78
|
||||||
|
// reflected). Required by design §3.2 line 359 for all WAL CRC computations.
|
||||||
|
//
|
||||||
|
// Distinct from crc32.IEEE (Ethernet/PNG polynomial 0xEDB88320) — the two
|
||||||
|
// produce unrelated checksums for the same input. SSE4.2 native CRC32
|
||||||
|
// instruction only supports Castagnoli, so this table also enables hardware
|
||||||
|
// acceleration via Go's standard library internals.
|
||||||
|
var crc32cTable = crc32.MakeTable(crc32.Castagnoli)
|
||||||
|
```
|
||||||
|
|
||||||
|
> **必要 docstring**:解释为什么单独抽这个变量(C1 bug 防回归),引用设计行号。属于 security-related 必要注释。
|
||||||
|
|
||||||
|
#### A.2 替换 5 处 `ChecksumIEEE`
|
||||||
|
|
||||||
|
`wal/header.go:44`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 改前:
|
||||||
|
h.HeaderCRC = crc32.ChecksumIEEE(buf[0:28])
|
||||||
|
// 改后:
|
||||||
|
h.HeaderCRC = crc32.Checksum(buf[0:28], crc32cTable)
|
||||||
|
```
|
||||||
|
|
||||||
|
`wal/header.go:83`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 改前:
|
||||||
|
gotCRC := crc32.ChecksumIEEE(data[0:28])
|
||||||
|
// 改后:
|
||||||
|
gotCRC := crc32.Checksum(data[0:28], crc32cTable)
|
||||||
|
```
|
||||||
|
|
||||||
|
`wal/record.go:30`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 改前:
|
||||||
|
crc := crc32.ChecksumIEEE(buf[4:])
|
||||||
|
// 改后:
|
||||||
|
crc := crc32.Checksum(buf[4:], crc32cTable)
|
||||||
|
```
|
||||||
|
|
||||||
|
`wal/record.go:55`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 改前:
|
||||||
|
expectedCRC := crc32.ChecksumIEEE(data[4 : 7+length])
|
||||||
|
// 改后:
|
||||||
|
expectedCRC := crc32.Checksum(data[4 : 7+length], crc32cTable)
|
||||||
|
```
|
||||||
|
|
||||||
|
`wal/block_writer_test.go:349`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 改前:
|
||||||
|
computedCRC := crc32.ChecksumIEEE(crcData)
|
||||||
|
// 改后:
|
||||||
|
computedCRC := crc32.Checksum(crcData, crc32cTable)
|
||||||
|
```
|
||||||
|
|
||||||
|
**注释更新**:`wal/header.go:43` 注释 `// CRC32 IEEE over bytes 0–27` 改成 `// CRC32C over bytes 0–27 (per design §3.2 line 341, 359)`。
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_0323e6b9)**:原计划只引用 line 359,但 line 359 描述的是 physical record CRC,header CRC 应该引用 line 341(headerCRC 字段定义)。改成同时引用两条,或写"per design §3.2 CRC field spec"。
|
||||||
|
|
||||||
|
### Phase B:测试(30 分钟)
|
||||||
|
|
||||||
|
#### B.1 固定向量测试(关键防回归)
|
||||||
|
|
||||||
|
新增 `wal/crc32c_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"hash/crc32"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Regression guard for C1: verify all WAL CRC uses Castagnoli polynomial
|
||||||
|
// (crc32c), not IEEE. Per design §3.2 line 359.
|
||||||
|
//
|
||||||
|
// The standard CRC-32C test vector from RFC 3720 Appendix B is the 9-byte
|
||||||
|
// ASCII string "123456789":
|
||||||
|
// - crc32c: 0xE3069283
|
||||||
|
// - crc32 IEEE: 0xCBF43926
|
||||||
|
//
|
||||||
|
// If anyone changes crc32cTable back to IEEE, this test fails immediately.
|
||||||
|
func TestCRC32CStandardVector(t *testing.T) {
|
||||||
|
got := crc32.Checksum([]byte("123456789"), crc32cTable)
|
||||||
|
const want = uint32(0xE3069283)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("crc32c('123456789') = 0x%X, want 0x%X (Castagnoli)", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negative regression: confirm IEEE would produce a DIFFERENT value. This
|
||||||
|
// catches the case where someone "fixes" crc32cTable to use IEEE by mistake.
|
||||||
|
func TestCRC32CEdistinctFromIEEE(t *testing.T) {
|
||||||
|
data := []byte("123456789")
|
||||||
|
ieee := crc32.ChecksumIEEE(data)
|
||||||
|
castagnoli := crc32.Checksum(data, crc32cTable)
|
||||||
|
if ieee == castagnoli {
|
||||||
|
t.Errorf("IEEE and Castagnoli produced the same CRC (impossible unless table is wrong); both = 0x%X", ieee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B.2 直接断言 header CRC 用的是 crc32c(Oracle 新增)
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_0323e6b9)NICE-TO-HAVE**:现有 `TestHeaderRoundtrip` 不能抓"encode + decode 同时改回 IEEE"(自洽)。需要直接断言存储的 CRC 值。
|
||||||
|
|
||||||
|
新增到 `wal/header_test.go`(或 `crc32c_test.go`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Regression guard for C1: header CRC must be computed with crc32c, not
|
||||||
|
// IEEE. TestHeaderRoundtrip is self-consistent (encode + decode use same
|
||||||
|
// polynomial), so a paired reversion to IEEE would pass it. This test
|
||||||
|
// directly asserts the stored CRC matches Castagnoli.
|
||||||
|
func TestHeaderCRCUsesCastagnoli(t *testing.T) {
|
||||||
|
hdr := &WalFileHeader{
|
||||||
|
BlockSize: 32 * 1024,
|
||||||
|
SegmentID: 42,
|
||||||
|
StartSequence: 100,
|
||||||
|
}
|
||||||
|
encoded := EncodeWalHeader(hdr)
|
||||||
|
want := crc32.Checksum(encoded[0:28], crc32cTable)
|
||||||
|
got := encoded[28] | uint32(encoded[29])<<8 | uint32(encoded[30])<<16 | uint32(encoded[31])<<24
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("stored headerCRC = 0x%X, want crc32c value 0x%X", got, want)
|
||||||
|
}
|
||||||
|
// Sanity: confirm IEEE would produce a different value (catches paired reversion).
|
||||||
|
ieeeValue := crc32.ChecksumIEEE(encoded[0:28])
|
||||||
|
if got == ieeeValue {
|
||||||
|
t.Errorf("stored headerCRC = 0x%X matches IEEE value (C1 regression)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
同理 physical record 也加一个(可选,但便宜):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Regression guard for C1: physical record CRC must be Castagnoli.
|
||||||
|
func TestPhysicalRecordCRCUsesCastagnoli(t *testing.T) {
|
||||||
|
payload := []byte("test-payload")
|
||||||
|
encoded := EncodePhysicalRecord(RecFull, payload)
|
||||||
|
// Record format: [crc u32][length u16][type u8][payload]
|
||||||
|
want := crc32.Checksum(encoded[4:7+len(payload)], crc32cTable)
|
||||||
|
got := encoded[0] | uint32(encoded[1])<<8 | uint32(encoded[2])<<16 | uint32(encoded[3])<<24
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("stored record CRC = 0x%X, want crc32c value 0x%X", got, want)
|
||||||
|
}
|
||||||
|
ieeeValue := crc32.ChecksumIEEE(encoded[4 : 7+len(payload)])
|
||||||
|
if got == ieeeValue {
|
||||||
|
t.Errorf("stored record CRC = 0x%X matches IEEE value (C1 regression)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B.3 现有测试自动通过(无需改动)
|
||||||
|
|
||||||
|
所有 round-trip 测试(encode → decode → verify)自洽:encode 和 decode 用同一个多项式。改成 crc32c 后两端都用 crc32c,仍然自洽。
|
||||||
|
|
||||||
|
需要确认:
|
||||||
|
- `wal/header_test.go` round-trip 测试通过
|
||||||
|
- `wal/record_test.go` round-trip 测试通过
|
||||||
|
- `wal/block_writer_test.go` round-trip 测试通过
|
||||||
|
- `wal/recover_test.go` 等端到端 recovery 测试通过
|
||||||
|
- `db_test.go` / `db_e2e_test.go` 通过
|
||||||
|
|
||||||
|
### Phase C:验证(15 分钟)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 编译
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
# 2. 重点测试(C1 新增)
|
||||||
|
go test ./wal -run 'TestCRC32C' -count=1 -v
|
||||||
|
|
||||||
|
# 3. wal 包全量
|
||||||
|
go test ./wal/... -count=1
|
||||||
|
|
||||||
|
# 4. 全仓
|
||||||
|
go test ./... -count=1
|
||||||
|
|
||||||
|
# 5. race
|
||||||
|
go test -race ./... -count=1
|
||||||
|
|
||||||
|
# 6. vet
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase D:Commit message draft
|
||||||
|
|
||||||
|
```
|
||||||
|
fix: use CRC-32C (Castagnoli) instead of IEEE for WAL (C1)
|
||||||
|
|
||||||
|
Per design §3.2 line 359, all WAL CRC computations must use crc32c
|
||||||
|
(Castagnoli polynomial 0x82F63B78). The previous code used crc32.IEEE
|
||||||
|
(Ethernet/PNG polynomial 0xEDB88320) in 5 places. While the system was
|
||||||
|
self-consistent (encode + decode both used IEEE), it diverged from the
|
||||||
|
design spec and lost SSE4.2 hardware acceleration (the native CRC32
|
||||||
|
instruction only supports Castagnoli).
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- wal/crc32c.go (new): package-level crc32cTable = crc32.MakeTable(
|
||||||
|
crc32.Castagnoli). Central definition prevents future drift.
|
||||||
|
- wal/header.go: 2 ChecksumIEEE calls replaced with crc32.Checksum(
|
||||||
|
data, crc32cTable). Comment updated to reference design §3.2 line 359.
|
||||||
|
- wal/record.go: 2 ChecksumIEEE calls replaced.
|
||||||
|
- wal/block_writer_test.go: 1 ChecksumIEEE call in test helper replaced.
|
||||||
|
- wal/crc32c_test.go (new): standard CRC-32C test vector (RFC 3720:
|
||||||
|
crc32c("123456789") = 0xE3069283) + negative test confirming IEEE
|
||||||
|
produces different value. These guards catch any future regression
|
||||||
|
to IEEE.
|
||||||
|
|
||||||
|
BREAKING CHANGE: WAL files written before this fix (with IEEE CRC)
|
||||||
|
cannot be read after this fix (expects crc32c). Phase 1 has not been
|
||||||
|
released, so no real data migration is needed. Production users
|
||||||
|
post-release would need to drain + re-create the database.
|
||||||
|
|
||||||
|
Developers pulling this change should delete any local Phase-1 WAL
|
||||||
|
directories (`rm -rf <db-dir>/segment-*.wal`) before running the code;
|
||||||
|
old IEEE-encoded WALs will fail recovery on local dev machines.
|
||||||
|
|
||||||
|
Verified: all existing round-trip tests pass (encode + decode both use
|
||||||
|
crc32c, still self-consistent). Full suite green including
|
||||||
|
go test -race ./... .
|
||||||
|
|
||||||
|
Audit context: docs/audit-3.2.md C1.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收清单
|
||||||
|
|
||||||
|
- [ ] Phase A.1:`wal/crc32c.go` 存在,定义 `crc32cTable`
|
||||||
|
- [ ] Phase A.2:5 处 `ChecksumIEEE` 全部替换为 `Checksum(data, crc32cTable)`
|
||||||
|
- [ ] Phase A.2:`wal/header.go:43` 注释更新引用设计行号
|
||||||
|
- [ ] Phase B.1:`TestCRC32CStandardVector` + `TestCRC32CEdistinctFromIEEE` 存在
|
||||||
|
- [ ] `go test ./wal/... -count=1` 全绿
|
||||||
|
- [ ] `go test ./... -count=1` 全绿
|
||||||
|
- [ ] `go test -race ./... -count=1` 全绿
|
||||||
|
- [ ] `go vet ./...` 无新增警告
|
||||||
|
- [ ] 单次 commit,message 引用 audit C1 + 标注 BREAKING CHANGE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 不在本次范围内(后续 issue)
|
||||||
|
|
||||||
|
| 编号 | 为什么不放进来 |
|
||||||
|
|------|---------------|
|
||||||
|
| C7 | Put/Close 竞态,独立 |
|
||||||
|
| H1-H7 | 其他 High,独立 |
|
||||||
|
| WAL 格式版本号 | Phase 1 未 release,不需要 v2 迁移机制 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修订记录
|
||||||
|
|
||||||
|
- **v1(原始)**:C1 修复方案初稿,送 Momus 审
|
||||||
|
- **v1.0(Momus 审核 bg_5a6543ac)**:[OKAY],无 blocking。验证全仓只有 5 处 `ChecksumIEEE`,无遗漏
|
||||||
|
- **v1.1(Oracle 审核 bg_0323e6b9)**:**approve**(无 blocking)。3 个 nice-to-have 已采纳:
|
||||||
|
- 加直接断言测试(header / physical record CRC 用的是 crc32c 值,不是 IEEE)—— `TestHeaderRoundtrip` 抓不到"encode + decode 同时改回 IEEE"
|
||||||
|
- header.go 注释引用设计行号改成 `line 341, 359`(line 359 是 physical record,header CRC 应引 line 341)
|
||||||
|
- commit message 加开发者提醒:删本地 Phase-1 WAL 目录
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
# C→A 修复方案:C2+C3 数据安全 Patch
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
> **目标**:消除 Phase 1 WAL recovery 的两个数据丢失路径(C2 + C3),按 Oracle 修订意见先更新审核报告,再统一修复。
|
||||||
|
>
|
||||||
|
> **交付**:
|
||||||
|
> - 更新 `docs/audit-3.2.md`,加入 Oracle 的 4 条修订意见
|
||||||
|
> - 删除 `wal/recover.go` 中 2 处 `manifest.Save` 调用 + 1 处 CURRENT fallback
|
||||||
|
> - 重写 1 个测试 + 新增 3 个测试
|
||||||
|
> - 单次 commit 提交
|
||||||
|
>
|
||||||
|
> **预估工时**:1.5-3 小时
|
||||||
|
> **风险**:低(纯删除 + 测试调整,无新逻辑)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
### 为什么 C2 和 C3 必须一起修
|
||||||
|
|
||||||
|
Oracle 验证(`bg_ef425776`)发现一个我原审核漏掉的关键事实:
|
||||||
|
|
||||||
|
- Phase 1 没有 flush,**MANIFEST 在正常运行中始终是 0**
|
||||||
|
- `resolveRecoverySegmentID` 看到 MANIFEST=0 就 fallback 到 CURRENT
|
||||||
|
- `segment_manager.go:47,89` 每次 create/rotate segment 都顺手写 CURRENT
|
||||||
|
- 所以 Phase 1 默认状态下,首次 Recover 就会从 CURRENT 指向的 active segment 开始,跳过更早的 segment
|
||||||
|
|
||||||
|
→ 单独修 C3(删 `manifest.Save`)没用,C2 路径照样丢数据。
|
||||||
|
|
||||||
|
### Oracle 的其他修订
|
||||||
|
|
||||||
|
1. **C4** 多一个失败模式:`Recover` 总是 truncate `segments[last]`,但 corruption 可能出现在非尾段,导致截断错误的 segment
|
||||||
|
2. **M1**(findValidOffset)应升级为 High:只校验物理记录不跟踪 batch 边界,对 `First+Middle*` 没 `Last` 的情况返回错误的截断点
|
||||||
|
3. **测试方向**:连续两次 Recover 的"幂等"测试不能一刀切,要分干净 WAL 和尾部损坏两种场景
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行计划
|
||||||
|
|
||||||
|
### Phase C:更新审核报告(15 分钟)
|
||||||
|
|
||||||
|
**目标**:把 Oracle 的修订固化到 `docs/audit-3.2.md`,避免下次重新审计时遗漏。
|
||||||
|
|
||||||
|
#### C.1 修订 C2 条目
|
||||||
|
|
||||||
|
在 C2 的"后果"段后追加:
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_ef425776)**:C2 在 Phase 1 比 C3 更严重。Phase 1 没有 flush,MANIFEST 一直保持 0;`segment_manager.go:47,89` 每次创建/轮转 segment 都会写 CURRENT 指向 active segment。**默认状态下首次 Recover 就会触发 fallback**,不需要"CURRENT 缺失/落后"这种特殊场景。修 C3 之前必须先修 C2,否则数据丢失窗口依然存在。
|
||||||
|
|
||||||
|
#### C.2 修订 C3 条目
|
||||||
|
|
||||||
|
在 C3 的"修复"段后追加:
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_ef425776)**:单修 C3 不足以解决 Phase 1 数据丢失,必须和 C2 一起修。测试方向需要区分干净 WAL 和尾部损坏 WAL 两种幂等性。
|
||||||
|
|
||||||
|
#### C.3 修订 C4 条目
|
||||||
|
|
||||||
|
在 C4 的"后果"段后追加:
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_ef425776)**:C4 还有一个更严重的失败模式。`wal/recover.go:61` 总是对 `segments[len(segments)-1]` 调用 `truncateSegment`,但 `RecoverFromSegments` 的 `TailCorruptionError` 可能来自非尾段。结果:真正损坏的 segment 不动,最后一段的有效数据被错误截掉。
|
||||||
|
|
||||||
|
#### C.4 升级 M1 → H8
|
||||||
|
|
||||||
|
把 M1(`findValidOffset` 与原始解析不一致)从 Medium 升到 High,改编号 H8,加入具体证据:
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_ef425776)**:升级为 High。`findValidOffset` 只用 `DecodePhysicalRecord` 校验物理记录,**不跟踪完整 batch 边界**。对尾部 `First + Middle*` 没 `Last` 的情况,`recovery.go:133` 在最后一个完整 batch 结尾报尾部损坏,而 `findValidOffset` 可能返回不完整 fragment 之后的 EOF,导致截断点错位、残留半截 fragment、下次启动反复 repair。
|
||||||
|
|
||||||
|
#### C.5 修订优先级表
|
||||||
|
|
||||||
|
把"必须立刻修的"表格更新为:
|
||||||
|
|
||||||
|
| 编号 | 一句话 | 风险等级 | Phase 1 是否默认触发 |
|
||||||
|
|------|--------|----------|---------------------|
|
||||||
|
| C1 | CRC 多项式错 | 格式不符 | N/A |
|
||||||
|
| **C2** | Recovery CURRENT 兜底 | **恢复起点错误** | **是(默认触发)** |
|
||||||
|
| **C3** | Recovery 写 MANIFEST | 数据丢失 | 是(修了 C2 才能完全止血) |
|
||||||
|
| C4 | 非尾段 fragment 当尾段损坏 | 截断错误 segment | 偶发 |
|
||||||
|
| C5 | 截断无 fsync | DB 状态不一致 | 偶发 |
|
||||||
|
| C6 | dir fsync 静默 | 已确认写入消失 | 偶发 |
|
||||||
|
| C7 | Put/Close 竞态 | 偶发 panic | 偶发 |
|
||||||
|
|
||||||
|
并更新底部优先级:
|
||||||
|
|
||||||
|
> 修订后优先级:**C2 + C3(一起)→ C6 → C5 + H8(一起,同一文件)→ C4 → C1 → C7**
|
||||||
|
|
||||||
|
#### C.6 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 报告应该读起来前后一致,无矛盾
|
||||||
|
grep -c "Oracle 修订" docs/audit-3.2.md # 期望 4
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase A:修复 C2+C3(45-90 分钟)
|
||||||
|
|
||||||
|
**目标**:消除 Phase 1 默认状态下的两条数据丢失路径。
|
||||||
|
|
||||||
|
#### A.1 修改 `wal/recover.go`
|
||||||
|
|
||||||
|
**变更 1:删除两处 `manifest.Save` 调用**
|
||||||
|
|
||||||
|
文件:`wal/recover.go`
|
||||||
|
|
||||||
|
删除尾部损坏路径(约 line 76-79):
|
||||||
|
```go
|
||||||
|
// 删除:
|
||||||
|
// Update MANIFEST with new recovery state.
|
||||||
|
if saveErr := manifest.Save(dir, result.NextSegmentID); saveErr != nil {
|
||||||
|
return nil, fmt.Errorf("wal: recover: save manifest after truncation: %w", saveErr)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
删除成功路径(约 line 99-102):
|
||||||
|
```go
|
||||||
|
// 删除:
|
||||||
|
// Update MANIFEST with new recovery state.
|
||||||
|
if saveErr := manifest.Save(dir, result.NextSegmentID); saveErr != nil {
|
||||||
|
return nil, fmt.Errorf("wal: recover: save manifest: %w", saveErr)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**变更 2:删除 CURRENT fallback**
|
||||||
|
|
||||||
|
文件:`wal/recover.go`,函数 `resolveRecoverySegmentID`
|
||||||
|
|
||||||
|
修改前:
|
||||||
|
```go
|
||||||
|
func resolveRecoverySegmentID(dir string) (uint64, error) {
|
||||||
|
mf, err := manifest.Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("load manifest: %w", err)
|
||||||
|
}
|
||||||
|
if mf.RecoverySegmentID > 0 {
|
||||||
|
return mf.RecoverySegmentID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MANIFEST had 0 (fresh DB or not yet written). Try CURRENT.
|
||||||
|
if segID, ok := manifest.ReadCurrent(dir); ok {
|
||||||
|
return segID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
修改后:
|
||||||
|
```go
|
||||||
|
// resolveRecoverySegmentID returns the recovery start segment ID from MANIFEST.
|
||||||
|
// MANIFEST is the only authoritative source of recovery start per design §3.2
|
||||||
|
// line 600-06. CURRENT is a write-side hint and must NOT be used here.
|
||||||
|
func resolveRecoverySegmentID(dir string) (uint64, error) {
|
||||||
|
mf, err := manifest.Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("load manifest: %w", err)
|
||||||
|
}
|
||||||
|
return mf.RecoverySegmentID, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**变更 3:更新 import 注释**
|
||||||
|
|
||||||
|
把 `recover.go:27` 注释从 `// Step 1: Determine recovery segment ID from MANIFEST or CURRENT.` 改成 `// Step 1: Determine recovery segment ID from MANIFEST.`
|
||||||
|
|
||||||
|
**变更 4:检查 manifest import 是否还需要**
|
||||||
|
|
||||||
|
`wal/recover.go` 还使用 `manifest.Load`,所以 import 保留。
|
||||||
|
|
||||||
|
#### A.2 检查 manifest.WriteCurrent 是否还有调用方
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "manifest.WriteCurrent\|manifest.ReadCurrent" --include="*.go"
|
||||||
|
```
|
||||||
|
|
||||||
|
预期:
|
||||||
|
- `wal/segment_manager.go:48,90` 仍然调用 `WriteCurrent`(合法,CURRENT 作为写入侧 hint)
|
||||||
|
- `manifest/current.go` 自身定义
|
||||||
|
- `wal/recover.go` 之前调用 `ReadCurrent` 的地方已删除
|
||||||
|
|
||||||
|
→ CURRENT 文件保留,只是 recovery 不再读它。符合设计 §3.2 line 276。
|
||||||
|
|
||||||
|
#### A.3 修改 `wal/recover_test.go`
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_2e86d33b)**:
|
||||||
|
> - BLOCKING:原计划的可选 e2e `TestOpenTwiceKeepsData`(两次 open)不足以验证 C3。C3 数据丢失发生在**第二次 restart**,需要**三次 open**才能抓到。已升级为必选。
|
||||||
|
> - NICE-TO-HAVE:注释中"NewSegmentWriter writes CURRENT"是错的,只有 SegmentManager 写。修正注释。
|
||||||
|
> - NICE-TO-HAVE:MANIFEST 已存在的 subcase,防止"覆盖已有 MANIFEST"的回归。
|
||||||
|
> - 注意:测试中避免强制多 segment 轮转,因为 `segment_manager.go:64-66` 有无关 bug(C8,见审核报告),多 segment recovery 会失败。
|
||||||
|
|
||||||
|
**变更 1:重写 `TestRecoverUpdatesManifest` → `TestRecoverDoesNotUpdateManifest`**
|
||||||
|
|
||||||
|
改名 `TestRecoverDoesNotUpdateManifest`,断言相反:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TestRecoverDoesNotUpdateManifest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Write test data. NewSegmentWriter creates the .wal file via the
|
||||||
|
// durable-ready protocol but does NOT write CURRENT (only SegmentManager
|
||||||
|
// does) and does NOT write MANIFEST.
|
||||||
|
writeTestSegment(t, dir, 0, 50, [][]*WalEntry{
|
||||||
|
{makePutEntry("a", "b")},
|
||||||
|
{makePutEntry("c", "d")},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Snapshot MANIFEST state before recovery. For a fresh DB, MANIFEST does
|
||||||
|
// not exist on disk; manifest.Load returns a zero-value Manifest.
|
||||||
|
beforeMF, err := manifest.Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("manifest.Load before recover: %v", err)
|
||||||
|
}
|
||||||
|
beforeExists := fileExists(t, filepath.Join(dir, "MANIFEST"))
|
||||||
|
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
result, err := Recover(dir, replayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MANIFEST must be unchanged.
|
||||||
|
afterMF, err := manifest.Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("manifest.Load after recover: %v", err)
|
||||||
|
}
|
||||||
|
if afterMF.RecoverySegmentID != beforeMF.RecoverySegmentID {
|
||||||
|
t.Errorf("MANIFEST RecoverySegmentID changed: %d → %d",
|
||||||
|
beforeMF.RecoverySegmentID, afterMF.RecoverySegmentID)
|
||||||
|
}
|
||||||
|
afterExists := fileExists(t, filepath.Join(dir, "MANIFEST"))
|
||||||
|
if beforeExists != afterExists {
|
||||||
|
t.Errorf("MANIFEST file existence changed: before=%v after=%v",
|
||||||
|
beforeExists, afterExists)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecoveryResult.NextSegmentID is in-memory only — not persisted.
|
||||||
|
_ = result // NextSegmentID is allowed to differ from MANIFEST.RecoverySegmentID.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtest: MANIFEST already exists (e.g. from a prior flush in future phases).
|
||||||
|
// Recovery must not overwrite or delete it. Catches accidental writes that
|
||||||
|
// fresh-DB subtest above cannot detect (since fresh DB has no MANIFEST).
|
||||||
|
func TestRecoverPreservesExistingManifest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
writeTestSegment(t, dir, 0, 50, [][]*WalEntry{
|
||||||
|
{makePutEntry("a", "b")},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simulate a prior checkpoint having advanced MANIFEST to segment 0.
|
||||||
|
// (Even though Phase 1 has no flush, future phases will. This test
|
||||||
|
// guards the invariant going forward.)
|
||||||
|
if err := manifest.Save(dir, 0); err != nil {
|
||||||
|
t.Fatalf("manifest.Save setup: %v", err)
|
||||||
|
}
|
||||||
|
beforeBytes, err := os.ReadFile(filepath.Join(dir, "MANIFEST"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile MANIFEST: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
if _, err := Recover(dir, replayer); err != nil {
|
||||||
|
t.Fatalf("Recover: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterBytes, err := os.ReadFile(filepath.Join(dir, "MANIFEST"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile MANIFEST after recover: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(beforeBytes, afterBytes) {
|
||||||
|
t.Errorf("MANIFEST bytes changed:\n before=%q\n after=%q",
|
||||||
|
string(beforeBytes), string(afterBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(t *testing.T, path string) bool {
|
||||||
|
t.Helper()
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t.Fatalf("stat %s: %v", path, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**变更 2:新增 `TestRecoverIdempotentClean`**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// TestRecoverIdempotentClean verifies that recovering a clean WAL twice
|
||||||
|
// produces identical results. This is the "no MANIFEST writes" guarantee
|
||||||
|
// from design §3.2 line 280.
|
||||||
|
func TestRecoverIdempotentClean(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||||
|
{makePutEntry("k1", "v1")},
|
||||||
|
{makePutEntry("k2", "v2")},
|
||||||
|
})
|
||||||
|
|
||||||
|
replayer1 := &mockReplayer{}
|
||||||
|
result1, err := Recover(dir, replayer1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover (1st): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
replayer2 := &mockReplayer{}
|
||||||
|
result2, err := Recover(dir, replayer2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover (2nd): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result1.NextSequence != result2.NextSequence {
|
||||||
|
t.Errorf("NextSequence differs: %d vs %d", result1.NextSequence, result2.NextSequence)
|
||||||
|
}
|
||||||
|
if result1.NextSegmentID != result2.NextSegmentID {
|
||||||
|
t.Errorf("NextSegmentID differs: %d vs %d", result1.NextSegmentID, result2.NextSegmentID)
|
||||||
|
}
|
||||||
|
if result1.Truncated || result2.Truncated {
|
||||||
|
t.Errorf("Truncated should be false for clean WAL: r1=%v r2=%v",
|
||||||
|
result1.Truncated, result2.Truncated)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(replayer1.puts, replayer2.puts) {
|
||||||
|
t.Errorf("replayed puts differ:\n r1=%#v\n r2=%#v", replayer1.puts, replayer2.puts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**变更 3:新增 `TestRecoverIdempotentAfterTruncation`**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// TestRecoverIdempotentAfterTruncation verifies that after the first recovery
|
||||||
|
// truncates a corrupted tail, the second recovery sees a clean WAL with the
|
||||||
|
// same NextSequence. Per Oracle: the first call reports Truncated=true, the
|
||||||
|
// second reports Truncated=false but identical replay state.
|
||||||
|
func TestRecoverIdempotentAfterTruncation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
filePath := writeTestSegment(t, dir, 0, 100, [][]*WalEntry{
|
||||||
|
{makePutEntry("good1", "before-corruption")},
|
||||||
|
{makePutEntry("good2", "also-before")},
|
||||||
|
})
|
||||||
|
appendFileBytes(t, filePath, []byte{0xDE, 0xAD, 0xBE, 0xEF})
|
||||||
|
|
||||||
|
// First recovery: detects tail corruption, truncates, returns Truncated=true.
|
||||||
|
replayer1 := &mockReplayer{}
|
||||||
|
result1, err := Recover(dir, replayer1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover (1st): %v", err)
|
||||||
|
}
|
||||||
|
if !result1.Truncated {
|
||||||
|
t.Fatal("1st Recover: Truncated = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second recovery: file has been truncated, no corruption remains.
|
||||||
|
replayer2 := &mockReplayer{}
|
||||||
|
result2, err := Recover(dir, replayer2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover (2nd): %v", err)
|
||||||
|
}
|
||||||
|
if result2.Truncated {
|
||||||
|
t.Error("2nd Recover: Truncated = true, want false (tail already repaired)")
|
||||||
|
}
|
||||||
|
if result1.NextSequence != result2.NextSequence {
|
||||||
|
t.Errorf("NextSequence differs: %d vs %d", result1.NextSequence, result2.NextSequence)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(replayer1.puts, replayer2.puts) {
|
||||||
|
t.Errorf("replayed puts differ:\n r1=%#v\n r2=%#v", replayer1.puts, replayer2.puts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**变更 4:新增 `TestRecoverIgnoresCurrentFallback`**(C2 回归测试)
|
||||||
|
|
||||||
|
> **Momus 修订(bg_3dfb54a9)**:`writeTestSegment` 走 `NewSegmentWriter`,**不写 CURRENT**(只有 `SegmentManager` 写)。测试必须**显式**调用 `manifest.WriteCurrent` 模拟 Phase 1 默认状态。
|
||||||
|
|
||||||
|
```go
|
||||||
|
// TestRecoverIgnoresCurrentFallback verifies that CURRENT is not used as a
|
||||||
|
// recovery start fallback when MANIFEST.RecoverySegmentID == 0. Per design
|
||||||
|
// §3.2 line 604-06, CURRENT is only a write-side hint and must not affect
|
||||||
|
// recovery start. Without this guarantee, Phase 1 default state (MANIFEST=0,
|
||||||
|
// CURRENT pointing to active segment) causes recovery to skip older segments.
|
||||||
|
//
|
||||||
|
// Note: writeTestSegment uses NewSegmentWriter directly, which does NOT write
|
||||||
|
// CURRENT (only SegmentManager does). We must write CURRENT explicitly to
|
||||||
|
// simulate the Phase 1 default state where SegmentManager has been rotating
|
||||||
|
// segments.
|
||||||
|
func TestRecoverIgnoresCurrentFallback(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Write 3 segments with valid batches.
|
||||||
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||||
|
{makePutEntry("seg0-k1", "v1")},
|
||||||
|
})
|
||||||
|
writeTestSegment(t, dir, 1, 1, [][]*WalEntry{
|
||||||
|
{makePutEntry("seg1-k1", "v1")},
|
||||||
|
})
|
||||||
|
writeTestSegment(t, dir, 2, 2, [][]*WalEntry{
|
||||||
|
{makePutEntry("seg2-k1", "v1")},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simulate Phase 1 default: SegmentManager has been rotating, so CURRENT
|
||||||
|
// exists and points to the last segment (2). MANIFEST still does not exist
|
||||||
|
// because no flush has happened yet.
|
||||||
|
if err := manifest.WriteCurrent(dir, 2); err != nil {
|
||||||
|
t.Fatalf("WriteCurrent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity: CURRENT exists and points to segment 2.
|
||||||
|
currentSegID, ok := manifest.ReadCurrent(dir)
|
||||||
|
if !ok || currentSegID != 2 {
|
||||||
|
t.Fatalf("CURRENT setup wrong: segID=%d ok=%v", currentSegID, ok)
|
||||||
|
}
|
||||||
|
// Sanity: MANIFEST.RecoverySegmentID == 0 (fresh DB).
|
||||||
|
mf, err := manifest.Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if mf.RecoverySegmentID != 0 {
|
||||||
|
t.Fatalf("MANIFEST.RecoverySegmentID = %d, want 0", mf.RecoverySegmentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recover must start from segment 0, not 2.
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
result, err := Recover(dir, replayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All 3 entries must be replayed.
|
||||||
|
wantPuts := []replayPut{
|
||||||
|
{key: "seg0-k1", value: "v1", seq: 0},
|
||||||
|
{key: "seg1-k1", value: "v1", seq: 1},
|
||||||
|
{key: "seg2-k1", value: "v1", seq: 2},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(replayer.puts, wantPuts) {
|
||||||
|
t.Errorf("puts = %#v, want %#v", replayer.puts, wantPuts)
|
||||||
|
}
|
||||||
|
if result.NextSequence != 3 {
|
||||||
|
t.Errorf("NextSequence = %d, want 3", result.NextSequence)
|
||||||
|
}
|
||||||
|
if result.NextSegmentID != 3 {
|
||||||
|
t.Errorf("NextSegmentID = %d, want 3", result.NextSegmentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### A.4 验证步骤
|
||||||
|
|
||||||
|
按顺序执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 编译通过
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
# 2. wal 包测试全绿
|
||||||
|
go test ./wal/... -count=1 -v
|
||||||
|
|
||||||
|
# 3. 全仓测试不回归
|
||||||
|
go test ./... -count=1
|
||||||
|
|
||||||
|
# 4. vet
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
# 5. lint(如果环境装了 golangci-lint;没装就跳过,不阻塞)
|
||||||
|
if command -v golangci-lint >/dev/null 2>&1; then
|
||||||
|
golangci-lint run ./wal/... ./manifest/...
|
||||||
|
else
|
||||||
|
echo "golangci-lint not installed, skipping"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 6. 重点跑这次新增的 4+1 个测试(Oracle 建议)
|
||||||
|
go test ./wal -run 'TestRecover(DoesNotUpdateManifest|PreservesExistingManifest|IdempotentClean|IdempotentAfterTruncation|IgnoresCurrentFallback)$' -count=1 -v
|
||||||
|
go test . -run 'TestOpenThreeTimesKeepsData$' -count=1 -v
|
||||||
|
|
||||||
|
# 7. race 检测(Oracle 建议,wal 包有 goroutine)
|
||||||
|
go test -race ./wal/... -count=1
|
||||||
|
```
|
||||||
|
|
||||||
|
#### A.5 必选 e2e:`TestOpenThreeTimesKeepsData`(Oracle 升级)
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_2e86d33b)BLOCKING**:原计划 `TestOpenTwiceKeepsData` 两次 open 不足以验证 C3。C3 的数据丢失发生在**第二次 restart**(第一次 recovery 写了 MANIFEST,第二次 recovery 才会跳过 segment)。必须用**三次 open**。
|
||||||
|
|
||||||
|
放在 `db_test.go`(package `go_kv`):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// TestOpenThreeTimesKeepsData is the end-to-end regression for C2+C3.
|
||||||
|
//
|
||||||
|
// C3's data-loss bug manifests on the SECOND restart after writes:
|
||||||
|
// - Open 1: write data, close. (Phase 1: no MANIFEST write yet from flush.)
|
||||||
|
// - Open 2: recovery (buggy code) writes MANIFEST=NextSegmentID, then
|
||||||
|
// opens new writer. Data still visible because the in-memory memtable
|
||||||
|
// was rebuilt from WAL.
|
||||||
|
// - Open 3: recovery reads advanced MANIFEST, skips old segments,
|
||||||
|
// data NOT replayed → data permanently invisible.
|
||||||
|
//
|
||||||
|
// Two opens cannot catch this; three opens can.
|
||||||
|
//
|
||||||
|
// Single-segment only: do NOT force rotation, because segment_manager
|
||||||
|
// has an unrelated C8 bug (passes byte offset as startSequence) that
|
||||||
|
// breaks multi-segment recovery. That bug is tracked separately.
|
||||||
|
func TestOpenThreeTimesKeepsData(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
cfg := config.Defaults()
|
||||||
|
// Default MaxSegmentSize=64MB is plenty for these writes; no rotation.
|
||||||
|
|
||||||
|
// Open 1: write keys.
|
||||||
|
db1, err := Open(dir, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open 1: %v", err)
|
||||||
|
}
|
||||||
|
keys := []string{"k1", "k2", "k3", "k4", "k5"}
|
||||||
|
for _, k := range keys {
|
||||||
|
if err := db1.Put([]byte(k), []byte("v-"+k)); err != nil {
|
||||||
|
t.Fatalf("Put %s: %v", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db1.Close(); err != nil {
|
||||||
|
t.Fatalf("Close 1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open 2: verify, then close. (Buggy code: this is where MANIFEST
|
||||||
|
// gets advanced. Fixed code: MANIFEST stays unchanged.)
|
||||||
|
db2, err := Open(dir, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open 2: %v", err)
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
r := db2.Get([]byte(k))
|
||||||
|
if !r.Found {
|
||||||
|
t.Errorf("Open 2: key %s not found", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db2.Close(); err != nil {
|
||||||
|
t.Fatalf("Close 2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open 3: verify again. This is where C3's data loss would manifest
|
||||||
|
// on buggy code (MANIFEST was advanced in Open 2, recovery now skips
|
||||||
|
// the original segments).
|
||||||
|
db3, err := Open(dir, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open 3: %v", err)
|
||||||
|
}
|
||||||
|
defer db3.Close()
|
||||||
|
for _, k := range keys {
|
||||||
|
r := db3.Get([]byte(k))
|
||||||
|
if !r.Found {
|
||||||
|
t.Errorf("Open 3: key %s not found (C3 regression: data lost)", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### A.6 Commit
|
||||||
|
|
||||||
|
单次 commit,message:
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(wal): C2+C3 stop recovery from advancing MANIFEST or using CURRENT
|
||||||
|
|
||||||
|
Phase 1 default state had two data-loss paths in WAL recovery:
|
||||||
|
|
||||||
|
1. (C2) resolveRecoverySegmentID fell back to CURRENT when MANIFEST=0.
|
||||||
|
Since segment_manager writes CURRENT on every segment create/rotate,
|
||||||
|
the first recovery in Phase 1 (MANIFEST always 0 without flush) would
|
||||||
|
start from the active segment, skipping earlier unflushed segments.
|
||||||
|
|
||||||
|
2. (C3) Recover called manifest.Save after every recovery, advancing
|
||||||
|
recoverySegmentID past segments that were still the only durable copy
|
||||||
|
of their data (no SSTable flush yet). Next restart would filter those
|
||||||
|
segments out and permanently lose the data.
|
||||||
|
|
||||||
|
Per design §3.2 line 280, recovery must not update MANIFEST; per line 604-06,
|
||||||
|
CURRENT must not be used as recovery start. Both fixes are required together
|
||||||
|
— fixing C3 alone leaves C2's data-loss window open.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- wal/recover.go: remove manifest.Save calls on both success and tail-repair
|
||||||
|
paths; remove CURRENT fallback in resolveRecoverySegmentID.
|
||||||
|
RecoveryResult.NextSegmentID is now in-memory only (consumed by DB.Open
|
||||||
|
to seed the new writer, but never persisted to MANIFEST).
|
||||||
|
- wal/recover_test.go: rewrite TestRecoverUpdatesManifest as
|
||||||
|
TestRecoverDoesNotUpdateManifest; add TestRecoverPreservesExistingManifest,
|
||||||
|
TestRecoverIdempotentClean, TestRecoverIdempotentAfterTruncation,
|
||||||
|
TestRecoverIgnoresCurrentFallback.
|
||||||
|
- db_test.go: add TestOpenThreeTimesKeepsData (three opens to catch C3's
|
||||||
|
second-restart data loss).
|
||||||
|
|
||||||
|
Refs docs/audit-3.2.md C2+C3 (with Oracle revisions from bg_ef425776 and
|
||||||
|
bg_2e86d33b).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收清单
|
||||||
|
|
||||||
|
- [ ] Phase C:`docs/audit-3.2.md` 包含 4 处"Oracle 修订"标注
|
||||||
|
- [ ] Phase A.1:`wal/recover.go` 中 `manifest.Save` 出现次数 = 0
|
||||||
|
- [ ] Phase A.1:`wal/recover.go` 中 `manifest.ReadCurrent` 出现次数 = 0
|
||||||
|
- [ ] Phase A.3:5 个测试名(1 改 + 4 新)全部存在
|
||||||
|
- [ ] Phase A.5:`TestOpenThreeTimesKeepsData`(必选)通过
|
||||||
|
- [ ] `go test ./wal/... -count=1` 全绿
|
||||||
|
- [ ] `go test . -count=1` 全绿(含 db_test.go)
|
||||||
|
- [ ] `go test ./... -count=1` 全绿
|
||||||
|
- [ ] `go test -race ./wal/... -count=1` 全绿
|
||||||
|
- [ ] `go vet ./...` 无新增警告
|
||||||
|
- [ ] 单次 commit,message 引用 audit C2+C3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 不在本次范围内(后续 issue)
|
||||||
|
|
||||||
|
| 编号 | 为什么不放进来 |
|
||||||
|
|------|---------------|
|
||||||
|
| **C8(新)** | `segment_manager.go:64-66` 把 `active.CurrentOffset()`(字节偏移)当 `newStartSequence` 传给 rotate。多 segment recovery 会因 startSequence 不匹配而失败。和 C2+C3 完全独立,但相关测试必须避免强制轮转 |
|
||||||
|
| C4 | 涉及 `RecoverFromSegments` 接口变更(isLast 参数),改动面更大,独立做 |
|
||||||
|
| C5 + H8 | 都在 truncation 路径,应该一起做(fsync + 删空 segment + dir fsync + findValidOffset batch 边界),但和 C2+C3 不耦合 |
|
||||||
|
| C6 | 涉及 segment_writer + segment_manager,独立做 |
|
||||||
|
| C1 | CRC 改动跨多个文件,需要测试向量,独立做 |
|
||||||
|
| C7 | 并发竞态,独立做 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修订记录
|
||||||
|
|
||||||
|
- **v1(原始)**:C→A 方案初稿,送 Momus 审
|
||||||
|
- **v1.1(Momus 修订 bg_3dfb54a9)**:
|
||||||
|
- Blocking:`TestRecoverIgnoresCurrentFallback` setup 假设 `writeTestSegment` 会写 CURRENT,但实际只有 `SegmentManager` 写。修订:测试中显式调用 `manifest.WriteCurrent(dir, 2)` 模拟 Phase 1 默认状态
|
||||||
|
- Minor:`golangci-lint run ... 2>/dev/null || true` 太宽容,改为 `command -v` 探测,没装就跳过
|
||||||
|
- **v1.2(Oracle 修订 bg_2e86d33b)**:
|
||||||
|
- Blocking:`TestOpenTwiceKeepsData`(两次 open)不足以验证 C3,C3 数据丢失发生在第二次 restart。升级为必选 `TestOpenThreeTimesKeepsData`(三次 open)。测试中避免强制多 segment 轮转,因为 C8 会让多 segment recovery 失败
|
||||||
|
- 新增 `TestRecoverPreservesExistingManifest` 子测试,覆盖"覆盖已有 MANIFEST"的回归
|
||||||
|
- 修正 `TestRecoverDoesNotUpdateManifest` 的注释(NewSegmentWriter 不写 CURRENT)
|
||||||
|
- 加重点测试命令 + `-race`
|
||||||
|
- commit message 补一句 `RecoveryResult.NextSegmentID` 现在是 in-memory
|
||||||
|
- 发现无关 bug C8(segment_manager 轮转 startSequence 错),加入"不在本次范围"表
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
# C4 修复方案:非尾段损坏必须硬错误
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
> **目标**:让 WAL recovery 按 design §3.2 line 704 区分"尾段损坏(可截断)"和"中间段损坏(硬错误)"。当前 `ReplaySegmentFile` 不论在哪个 segment 都把 CollectingFragments / parse error 当 TailCorruption,导致中间段损坏时 `Recover` 错误地截断最后一段的有效数据。
|
||||||
|
>
|
||||||
|
> **交付**:
|
||||||
|
> - `ReplaySegmentFile` 加 `isLastSegment bool` 参数
|
||||||
|
> - `TailCorruptionError` 加 `SegmentPath string` 字段(诊断 + 防御)
|
||||||
|
> - 非尾段遇到 corruption → 硬错误(不再是 TailCorruptionError)
|
||||||
|
> - `RecoverFromSegments` 传递 `isLastSegment` 给 `ReplaySegmentFile`
|
||||||
|
> - `Recover` 用 `tce.SegmentPath` 做截断目标(防御性 fallback 到 segments[last])
|
||||||
|
> - ~5 个新测试覆盖单/多 segment 场景
|
||||||
|
> - 单次 commit
|
||||||
|
>
|
||||||
|
> **预估工时**:2-3 小时
|
||||||
|
> **风险**:中。`ReplaySegmentFile` 签名变更是 breaking change(影响测试调用)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
### Bug 摘要
|
||||||
|
|
||||||
|
`wal/recovery.go:133-138` 在 `ReplaySegmentFile` 末尾:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if collector.State() == FragmentCollecting {
|
||||||
|
return nextSequence, &TailCorruptionError{...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**不区分 segment 是不是最后一个**。同样地,`ParseRecordsFromFile` / `ParseBlock` 返回的 TailCorruptionError 也不带 segment 位置信息。
|
||||||
|
|
||||||
|
`Recover` 拿到 TailCorruptionError 后,**总是对 `segments[len(segments)-1]` 调用 truncate**(C5+H8 修复后的 `wal/recover.go:60-71`)。如果 corruption 实际发生在中间段:
|
||||||
|
- 真正损坏的中间段不动
|
||||||
|
- 最后一段(完全有效)被错误截掉
|
||||||
|
- 中间段之后、最后段之前的有效 batch 全部丢失
|
||||||
|
|
||||||
|
### Oracle 验证(bg_ef425776)
|
||||||
|
|
||||||
|
> C4 还有一个更严重的失败模式。`wal/recover.go:61` 总是对 `segments[len(segments)-1]` 调用 `truncateSegment`,但 `RecoverFromSegments` 的 `TailCorruptionError` 可能来自非尾段。结果:真正损坏的 segment 不动,最后一段的有效数据被错误截掉。
|
||||||
|
|
||||||
|
### 设计依据(§3.2 line 704)
|
||||||
|
|
||||||
|
> 尾部损坏通常来自进程崩溃或机器掉电时最后一次写入的 partial write。**若后面还有需要恢复的 segment,前一个 segment 的尾部异常不能按尾部损坏截断**,否则可能跳过已经持久化历史并破坏 sequence 连续性。中间损坏说明已持久化 WAL 文件被破坏,**默认不静默跳过,不默认 repair**。
|
||||||
|
|
||||||
|
错误分类表(line 697-704)明确:所有 4 类物理损坏(header 半写 / length 越界 / CRC 失败 / 非 0 padding)在"WAL 中间"位置都是**硬错误**。
|
||||||
|
|
||||||
|
### 修复后的行为矩阵
|
||||||
|
|
||||||
|
| Corruption 位置 | isLastSegment | 行为 |
|
||||||
|
|----------------|--------------|------|
|
||||||
|
| 最后一段尾部 | true | TailCorruptionError → Recover 截断(现有行为)|
|
||||||
|
| 中间段任何位置 | false | **硬错误**(新行为)→ Recover 返回错误 → DB.Open 失败 |
|
||||||
|
| 干净 WAL | N/A | 正常 replay,无 corruption |
|
||||||
|
|
||||||
|
### 协同:C5+H8 / C8 关系
|
||||||
|
|
||||||
|
- **C5+H8 已修**:截断路径用 `findLastCompleteBatchEnd`(H8)+ 4 步协议(C5)。C4 修复后,截断路径只在"最后一段尾部损坏"时触发,行为正确。
|
||||||
|
- **C8 未修**:segment_manager.go:64-66 把字节偏移当 startSequence。但 `writeTestSegment` 直接构造 segment,可以指定正确 startSequence,所以 C4 测试不依赖 C8。
|
||||||
|
- **多 segment recovery** 在 C4 修复后行为正确(前提是 startSequence 正确,即 C8 也已修)。但 C4 本身的修复不依赖 C8。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行计划
|
||||||
|
|
||||||
|
### Phase A:代码改动(45-60 分钟)
|
||||||
|
|
||||||
|
#### A.1 扩展 `TailCorruptionError` 加 `SegmentPath`
|
||||||
|
|
||||||
|
文件:`wal/record_parser.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type TailCorruptionError struct {
|
||||||
|
Offset int // offset within the segment file
|
||||||
|
SegmentPath string // NEW: file path of the corrupted segment, for diagnostics + Recover's truncation target
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *TailCorruptionError) Error() string {
|
||||||
|
if e.SegmentPath != "" {
|
||||||
|
return fmt.Sprintf("wal: tail corruption in %s at offset %d: %v", e.SegmentPath, e.Offset, e.Err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("wal: tail corruption at offset %d: %v", e.Offset, e.Err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **必要 docstring**:字段含义 + 用途(diagnostics + Recover 的截断目标)。
|
||||||
|
|
||||||
|
#### A.2 `ReplaySegmentFile` 加 `isLastSegment` 参数
|
||||||
|
|
||||||
|
文件:`wal/recovery.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 改前:
|
||||||
|
func ReplaySegmentFile(filePath string, startSequence uint64, replayer BatchReplayer) (nextSequence uint64, err error)
|
||||||
|
|
||||||
|
// 改后:
|
||||||
|
func ReplaySegmentFile(filePath string, startSequence uint64, isLastSegment bool, replayer BatchReplayer) (nextSequence uint64, err error)
|
||||||
|
```
|
||||||
|
|
||||||
|
函数体改动:
|
||||||
|
|
||||||
|
```go
|
||||||
|
nextSequence = startSequence
|
||||||
|
records, parseErr := ParseRecordsFromFile(filePath)
|
||||||
|
if parseErr != nil && !IsTailCorruption(parseErr) {
|
||||||
|
return nextSequence, fmt.Errorf("wal: parse segment records: %w", parseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach SegmentPath to parseErr for downstream diagnostics + truncation target.
|
||||||
|
// Do this BEFORE the non-last hard-error conversion so both paths benefit.
|
||||||
|
if parseErr != nil {
|
||||||
|
var tce *TailCorruptionError
|
||||||
|
if errors.As(parseErr, &tce) {
|
||||||
|
tce.SegmentPath = filePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// C4 fix: tail corruption in non-last segment is hard corruption per
|
||||||
|
// design §3.2 line 704. Use %v (NOT %w) so IsTailCorruption returns false
|
||||||
|
// for this wrapped error — otherwise errors.As would still find the
|
||||||
|
// underlying *TailCorruptionError and Recover would treat it as truncatable.
|
||||||
|
if parseErr != nil && !isLastSegment {
|
||||||
|
return nextSequence, fmt.Errorf("wal: corruption in non-last segment %s (hard corruption): %v",
|
||||||
|
filePath, parseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
collector := NewFragmentCollector()
|
||||||
|
for _, record := range records {
|
||||||
|
// ... (unchanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parseErr != nil {
|
||||||
|
return nextSequence, parseErr
|
||||||
|
}
|
||||||
|
if collector.State() == FragmentCollecting {
|
||||||
|
if isLastSegment {
|
||||||
|
return nextSequence, &TailCorruptionError{
|
||||||
|
Offset: 0,
|
||||||
|
SegmentPath: filePath,
|
||||||
|
Err: errors.New("incomplete fragmented batch at segment tail"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Non-last segment with incomplete fragments = middle corruption.
|
||||||
|
// Plain error (no TailCorruptionError wrapping) — IsTailCorruption is false.
|
||||||
|
return nextSequence, fmt.Errorf("wal: incomplete fragmented batch in non-last segment %s (hard corruption)", filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextSequence, nil
|
||||||
|
```
|
||||||
|
|
||||||
|
#### A.3 `RecoverFromSegments` 传递 `isLastSegment`
|
||||||
|
|
||||||
|
文件:`wal/recovery.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
nextSequence = segments[0].StartSequence
|
||||||
|
for i, segment := range segments {
|
||||||
|
if segment.StartSequence != nextSequence {
|
||||||
|
return nextSequence, fmt.Errorf("wal: segment start sequence %d does not match expected sequence %d", segment.StartSequence, nextSequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
isLastSegment := i == len(segments)-1
|
||||||
|
nextSequence, err = ReplaySegmentFile(segment.FilePath, nextSequence, isLastSegment, replayer)
|
||||||
|
if err != nil {
|
||||||
|
if IsTailCorruption(err) {
|
||||||
|
return nextSequence, err
|
||||||
|
}
|
||||||
|
return nextSequence, fmt.Errorf("wal: replay segment: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### A.4 `Recover` 使用 `tce.SegmentPath`(防御性)
|
||||||
|
|
||||||
|
文件:`wal/recover.go`,C5+H8 修复后的 truncation 块:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if len(segments) > 0 {
|
||||||
|
// After C4 fix, TailCorruptionError is only returned for the last segment.
|
||||||
|
// Use tce.SegmentPath as authoritative truncation target (defensive:
|
||||||
|
// fall back to segments[last] if missing).
|
||||||
|
corruptedPath := segments[len(segments)-1].FilePath
|
||||||
|
var tce *TailCorruptionError
|
||||||
|
if errors.As(err, &tce) && tce.SegmentPath != "" {
|
||||||
|
corruptedPath = tce.SegmentPath
|
||||||
|
}
|
||||||
|
|
||||||
|
lastCompleteBatchEnd, findErr := findLastCompleteBatchEnd(corruptedPath)
|
||||||
|
if findErr != nil {
|
||||||
|
return nil, fmt.Errorf("wal: recover: find truncation offset: %w", findErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var emptyTrailing []string
|
||||||
|
if err := truncateAndPersist(corruptedPath, lastCompleteBatchEnd, dir, emptyTrailing); err != nil {
|
||||||
|
return nil, fmt.Errorf("wal: recover: persist tail truncation: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键不变量**:C4 fix 后 TailCorruptionError 只来自最后一段,所以 `corruptedPath == segments[last].FilePath`。`tce.SegmentPath` 是防御性显式表达,未来如果不变量破坏能立即发现。
|
||||||
|
|
||||||
|
> **关于 `errors.As` 用法**:`err` 在 Recover 函数签名里是 outer 变量(`nextSequence, err := RecoverFromSegments(...)`),shadowing 后再 `errors.As(err, &tce)` 读的是当前 outer err。需要确认 Go 的语义在这里正确(应该是的,但加注释或测试覆盖)。
|
||||||
|
|
||||||
|
### Phase B:测试(60-90 分钟)
|
||||||
|
|
||||||
|
#### B.1 `ReplaySegmentFile` 单元测试(isLastSegment 参数)
|
||||||
|
|
||||||
|
新增到 `wal/recovery_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Regression guards for C4: isLastSegment controls whether CollectingFragments
|
||||||
|
// at end is tail corruption (truncatable) or hard corruption.
|
||||||
|
|
||||||
|
func TestReplaySegmentFile_LastSegmentCollectingFragmentsIsTailCorruption(t *testing.T) {
|
||||||
|
// Build segment with: [Batch A][First][Middle* no Last].
|
||||||
|
// Call ReplaySegmentFile with isLastSegment=true.
|
||||||
|
// Expect: TailCorruptionError (can be truncated).
|
||||||
|
// Expect: tce.SegmentPath == filePath.
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaySegmentFile_NonLastSegmentCollectingFragmentsIsHardError(t *testing.T) {
|
||||||
|
// Same segment content as above.
|
||||||
|
// Call ReplaySegmentFile with isLastSegment=false.
|
||||||
|
// Expect: hard error (NOT TailCorruptionError).
|
||||||
|
// Expect: error mentions "non-last segment" or "hard corruption".
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaySegmentFile_LastSegmentParseErrorIsTailCorruption(t *testing.T) {
|
||||||
|
// Build segment with: [Batch A][CRC-corrupted bytes].
|
||||||
|
// Call with isLastSegment=true.
|
||||||
|
// Expect: TailCorruptionError.
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaySegmentFile_NonLastSegmentParseErrorIsHardError(t *testing.T) {
|
||||||
|
// Same segment content.
|
||||||
|
// Call with isLastSegment=false.
|
||||||
|
// Expect: hard error.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B.2 `RecoverFromSegments` / `Recover` 集成测试
|
||||||
|
|
||||||
|
新增到 `wal/recover_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Regression guard for C4: middle segment corruption must hard-fail Recover,
|
||||||
|
// NOT truncate the (valid) last segment. This is the key bug Oracle flagged:
|
||||||
|
// "wal/recover.go:61 总是对 segments[len(segments)-1] 调用 truncateSegment,
|
||||||
|
// 但 RecoverFromSegments 的 TailCorruptionError 可能来自非尾段".
|
||||||
|
func TestRecoverMiddleSegmentCorruptionHardFails(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// Construct 3 segments with valid startSequences.
|
||||||
|
// segment-0: [Batch seq 0-1] (2 entries → next=2)
|
||||||
|
// segment-1: [Batch seq 2-3] + [First][Middle no Last] ← middle corruption
|
||||||
|
// (2 complete entries → next=4 if recovery reached end)
|
||||||
|
// segment-2: [Batch seq 4-5] (2 entries; valid; never reached)
|
||||||
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||||
|
{makePutEntry("k0", "v0"), makePutEntry("k1", "v1")},
|
||||||
|
})
|
||||||
|
|
||||||
|
seg1Path := writeTestSegment(t, dir, 1, 2, [][]*WalEntry{
|
||||||
|
{makePutEntry("k2", "v2"), makePutEntry("k3", "v3")},
|
||||||
|
})
|
||||||
|
// Append First+Middle fragments to segment-1 (no Last).
|
||||||
|
appendFileBytes(t, seg1Path, EncodePhysicalRecord(RecFirst, []byte("first-frag")))
|
||||||
|
appendFileBytes(t, seg1Path, EncodePhysicalRecord(RecMiddle, []byte("middle-frag")))
|
||||||
|
|
||||||
|
writeTestSegment(t, dir, 2, 4, [][]*WalEntry{
|
||||||
|
{makePutEntry("k4", "v4"), makePutEntry("k5", "v5")},
|
||||||
|
})
|
||||||
|
|
||||||
|
seg2Path := filepath.Join(dir, "segment-2.wal")
|
||||||
|
fiBefore, _ := os.Stat(seg2Path)
|
||||||
|
|
||||||
|
_, err := Recover(dir, &mockReplayer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected Recover to fail on middle segment corruption")
|
||||||
|
}
|
||||||
|
if IsTailCorruption(err) {
|
||||||
|
t.Errorf("expected hard error (not tail corruption) for middle segment; got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: segment-2 must NOT be truncated (it's completely valid).
|
||||||
|
fiAfter, _ := os.Stat(seg2Path)
|
||||||
|
if fiAfter.Size() != fiBefore.Size() {
|
||||||
|
t.Errorf("segment-2 was modified: before=%d after=%d (C4-2 regression)",
|
||||||
|
fiBefore.Size(), fiAfter.Size())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C4: single-segment tail corruption still truncates
|
||||||
|
// correctly (existing behavior preserved).
|
||||||
|
func TestRecoverLastSegmentCorruptionTruncatesCorrectly(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||||
|
{makePutEntry("k0", "v0")},
|
||||||
|
{makePutEntry("k1", "v1")},
|
||||||
|
})
|
||||||
|
encA, _ := EncodeWalBatch(0, []*WalEntry{makePutEntry("k0", "v0")})
|
||||||
|
endOfBatch1 := int64(WalFileHeaderSize) +
|
||||||
|
int64(PhysicalRecordHeaderSize+len(encA)) +
|
||||||
|
int64(PhysicalRecordHeaderSize+len(encA))
|
||||||
|
|
||||||
|
// Append partial fragments to last (only) segment.
|
||||||
|
appendFileBytes(t, filePath, EncodePhysicalRecord(RecFirst, []byte("first")))
|
||||||
|
appendFileBytes(t, filePath, EncodePhysicalRecord(RecMiddle, []byte("middle")))
|
||||||
|
|
||||||
|
result, err := Recover(dir, &mockReplayer{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Truncated {
|
||||||
|
t.Fatal("Truncated = false, want true for last-segment corruption")
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, _ := os.Stat(filePath)
|
||||||
|
if fi.Size() != endOfBatch1 {
|
||||||
|
t.Errorf("file size = %d, want %d", fi.Size(), endOfBatch1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C4: multi-segment with last-segment corruption still
|
||||||
|
// works correctly (the legitimate tail-truncation case).
|
||||||
|
func TestRecoverMultiSegmentLastSegmentCorruptionTruncatesLast(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// segment-0: valid batches
|
||||||
|
// segment-1: valid batches + partial tail (corruption in LAST segment)
|
||||||
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||||
|
{makePutEntry("k0", "v0")},
|
||||||
|
})
|
||||||
|
seg1Path := writeTestSegment(t, dir, 1, 1, [][]*WalEntry{
|
||||||
|
{makePutEntry("k1", "v1")},
|
||||||
|
{makePutEntry("k2", "v2")},
|
||||||
|
})
|
||||||
|
// Append partial fragments to segment-1 (the LAST segment).
|
||||||
|
appendFileBytes(t, seg1Path, EncodePhysicalRecord(RecFirst, []byte("first")))
|
||||||
|
appendFileBytes(t, seg1Path, EncodePhysicalRecord(RecMiddle, []byte("middle")))
|
||||||
|
|
||||||
|
// Compute expected truncation point: end of Batch B in segment-1.
|
||||||
|
encA, _ := EncodeWalBatch(1, []*WalEntry{makePutEntry("k1", "v1")})
|
||||||
|
encB, _ := EncodeWalBatch(2, []*WalEntry{makePutEntry("k2", "v2")})
|
||||||
|
endOfBatch2 := int64(WalFileHeaderSize) +
|
||||||
|
int64(PhysicalRecordHeaderSize+len(encA)) +
|
||||||
|
int64(PhysicalRecordHeaderSize+len(encB))
|
||||||
|
|
||||||
|
result, err := Recover(dir, &mockReplayer{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Truncated {
|
||||||
|
t.Fatal("Truncated = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, _ := os.Stat(seg1Path)
|
||||||
|
if fi.Size() != endOfBatch2 {
|
||||||
|
t.Errorf("segment-1 size = %d, want %d (end of Batch 2)", fi.Size(), endOfBatch2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B.3 现有测试更新
|
||||||
|
|
||||||
|
`wal/recover_test.go` 中的 `TestRecoverIdempotentAfterTruncation` 和 `TestRecoverPartialFragmentTailIdempotent` 都是单 segment 场景,行为不变。但它们间接调用了 `Recover` → `RecoverFromSegments` → `ReplaySegmentFile`,需要确认新签名 `isLastSegment=true` 在 RecoverFromSegments 内部正确传递。
|
||||||
|
|
||||||
|
`wal/recovery_test.go` 现有的直接调用 `ReplaySegmentFile` 的测试需要补 `isLastSegment` 参数。**3 个调用点**(Oracle bg_22edec11 提醒):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "ReplaySegmentFile" wal/recovery_test.go wal/recover_test.go
|
||||||
|
```
|
||||||
|
|
||||||
|
- `wal/recovery_test.go:124`(TestReplaySegmentFile 之类)→ 加 `true`
|
||||||
|
- `wal/recovery_test.go:150`(另一个 ReplaySegmentFile 测试)→ 加 `true`
|
||||||
|
- `wal/recover_test.go:121`(TestRecoverWithTailCorruption 内部,验证截断后重放)→ 加 `true`
|
||||||
|
|
||||||
|
每个调用点加 `true`(默认按 last segment 处理,保留现有行为)。
|
||||||
|
|
||||||
|
#### B.4 补 middle-segment parser corruption 集成测试(Oracle 新增)
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_22edec11)NICE-TO-HAVE**:原计划只覆盖 middle-segment CollectingFragments,没覆盖 middle-segment parser corruption(CRC 失败)。需要加一个。
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Regression guard for C4 (parser corruption half): middle segment CRC
|
||||||
|
// corruption must hard-fail Recover, NOT truncate the (valid) last segment.
|
||||||
|
// This tests the path where ParseBlock returns TailCorruptionError and
|
||||||
|
// ReplaySegmentFile converts it to hard error for non-last segment.
|
||||||
|
func TestRecoverMiddleSegmentCRCCorruptionHardFails(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// segment-0: [Batch seq 0-1] (2 entries → next=2)
|
||||||
|
// segment-1: [Batch seq 2-3] + [CRC-corrupted bytes] ← middle parser corruption
|
||||||
|
// segment-2: [Batch seq 4-5] (valid; never reached)
|
||||||
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||||
|
{makePutEntry("k0", "v0"), makePutEntry("k1", "v1")},
|
||||||
|
})
|
||||||
|
|
||||||
|
seg1Path := writeTestSegment(t, dir, 1, 2, [][]*WalEntry{
|
||||||
|
{makePutEntry("k2", "v2"), makePutEntry("k3", "v3")},
|
||||||
|
})
|
||||||
|
// Append CRC-corrupted bytes (will fail DecodePhysicalRecord's CRC check).
|
||||||
|
// 10 bytes of 0xFF — looks like a record header but CRC won't match.
|
||||||
|
appendFileBytes(t, seg1Path, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF})
|
||||||
|
|
||||||
|
writeTestSegment(t, dir, 2, 4, [][]*WalEntry{
|
||||||
|
{makePutEntry("k4", "v4"), makePutEntry("k5", "v5")},
|
||||||
|
})
|
||||||
|
|
||||||
|
seg2Path := filepath.Join(dir, "segment-2.wal")
|
||||||
|
fiBefore, _ := os.Stat(seg2Path)
|
||||||
|
|
||||||
|
_, err := Recover(dir, &mockReplayer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected Recover to fail on middle segment CRC corruption")
|
||||||
|
}
|
||||||
|
if IsTailCorruption(err) {
|
||||||
|
t.Errorf("expected hard error (not tail corruption) for middle segment CRC; got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: segment-2 must NOT be truncated.
|
||||||
|
fiAfter, _ := os.Stat(seg2Path)
|
||||||
|
if fiAfter.Size() != fiBefore.Size() {
|
||||||
|
t.Errorf("segment-2 was modified: before=%d after=%d (C4-2 regression)",
|
||||||
|
fiBefore.Size(), fiAfter.Size())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase C:验证(15 分钟)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 编译
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
# 2. 重点测试
|
||||||
|
go test ./wal -run 'TestReplaySegmentFile|TestRecoverMiddleSegment|TestRecoverLastSegment|TestRecoverMultiSegment|TestRecoverIdempotent|TestRecoverPartialFragmentTail' -count=1 -v
|
||||||
|
|
||||||
|
# 3. wal 包全量
|
||||||
|
go test ./wal/... -count=1
|
||||||
|
|
||||||
|
# 4. 全仓
|
||||||
|
go test ./... -count=1
|
||||||
|
|
||||||
|
# 5. race
|
||||||
|
go test -race ./... -count=1
|
||||||
|
|
||||||
|
# 6. vet
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase D:Commit message draft
|
||||||
|
|
||||||
|
```
|
||||||
|
fix: treat non-last segment corruption as hard error (C4)
|
||||||
|
|
||||||
|
Per design §3.2 line 704, tail corruption in a non-last WAL segment is
|
||||||
|
middle corruption, which must hard-fail recovery instead of being
|
||||||
|
silently truncated. The previous code in wal/recovery.go always returned
|
||||||
|
TailCorruptionError for CollectingFragments state or parse errors,
|
||||||
|
regardless of segment position. Recover then always truncated
|
||||||
|
segments[last], which could corrupt a valid last segment when the actual
|
||||||
|
corruption was in a middle segment.
|
||||||
|
|
||||||
|
Oracle bg_ef425776 flagged an additional failure mode: "wal/recover.go:61
|
||||||
|
总是对 segments[len(segments)-1] 调用 truncateSegment,但
|
||||||
|
RecoverFromSegments 的 TailCorruptionError 可能来自非尾段".
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- wal/record_parser.go: add SegmentPath field to TailCorruptionError for
|
||||||
|
diagnostics and defensive truncation target identification.
|
||||||
|
- wal/recovery.go:
|
||||||
|
- ReplaySegmentFile now takes isLastSegment bool parameter.
|
||||||
|
- When parse error or CollectingFragments occurs in non-last segment,
|
||||||
|
return hard error instead of TailCorruptionError.
|
||||||
|
- When in last segment, return TailCorruptionError with SegmentPath set.
|
||||||
|
- RecoverFromSegments passes isLastSegment based on iteration index.
|
||||||
|
- wal/recover.go:
|
||||||
|
- Use tce.SegmentPath as authoritative truncation target (defensive
|
||||||
|
fallback to segments[last] if missing). After C4 fix, TailCorruptionError
|
||||||
|
is only returned for last segment, so this is always segments[last]
|
||||||
|
in practice.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- wal/recovery_test.go: 4 unit tests for ReplaySegmentFile covering
|
||||||
|
last/non-last × CollectingFragments/parse-error matrix. Existing tests
|
||||||
|
that directly call ReplaySegmentFile updated to pass isLastSegment=true.
|
||||||
|
- wal/recover_test.go: 3 integration tests covering middle-segment
|
||||||
|
corruption (must hard-fail), last-segment corruption in single-segment
|
||||||
|
WAL (must truncate), last-segment corruption in multi-segment WAL
|
||||||
|
(must truncate only last segment).
|
||||||
|
|
||||||
|
Verified: each new test fails on pre-fix code (non-last corruption
|
||||||
|
silently truncated valid last segment) and passes after the fix. Full
|
||||||
|
suite green including go test -race ./... .
|
||||||
|
|
||||||
|
Audit context: docs/audit-3.2.md C4 (Oracle-verified bg_ef425776).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收清单
|
||||||
|
|
||||||
|
- [ ] Phase A.1:`TailCorruptionError.SegmentPath` 字段存在
|
||||||
|
- [ ] Phase A.2:`ReplaySegmentFile` 签名包含 `isLastSegment bool`
|
||||||
|
- [ ] Phase A.2:非尾段 CollectingFragments 返回硬错误(不是 TailCorruptionError)
|
||||||
|
- [ ] Phase A.2:非尾段 parse error(TailCorruption from parser)转硬错误
|
||||||
|
- [ ] Phase A.3:`RecoverFromSegments` 传递正确 `isLastSegment`
|
||||||
|
- [ ] Phase A.4:`Recover` 用 `tce.SegmentPath` 做截断目标
|
||||||
|
- [ ] Phase B.1:4 个 `ReplaySegmentFile` 单元测试存在
|
||||||
|
- [ ] Phase B.2:3 个集成测试存在(middle/last-single/last-multi)
|
||||||
|
- [ ] Phase B.3:现有 `ReplaySegmentFile` 直接调用更新签名
|
||||||
|
- [ ] `go test ./wal/... -count=1` 全绿
|
||||||
|
- [ ] `go test ./... -count=1` 全绿
|
||||||
|
- [ ] `go test -race ./... -count=1` 全绿
|
||||||
|
- [ ] `go vet ./...` 无新增警告
|
||||||
|
- [ ] 单次 commit,message 引用 audit C4
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 不在本次范围内(后续 issue)
|
||||||
|
|
||||||
|
| 编号 | 为什么不放进来 |
|
||||||
|
|------|---------------|
|
||||||
|
| C8 | segment_manager.go:64-66 把字节偏移当 startSequence。本 patch 测试用 `writeTestSegment` 绕过,但生产环境多 segment recovery 仍受 C8 影响 |
|
||||||
|
| C1 | CRC 多项式 IEEE → crc32c,独立 |
|
||||||
|
| C7 | Put/Close 竞态,独立 |
|
||||||
|
| H1-H7 | 其他 High,独立 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修订记录
|
||||||
|
|
||||||
|
- **v1(原始)**:C4 修复方案初稿,送 Momus 审
|
||||||
|
- **v1.0(Momus 审核 bg_423473e7)**:[OKAY],无 blocking。思考过程敏锐发现 `TestRecoverMiddleSegmentCorruptionHardFails` 中 segment-2 的 startSequence=6 与 segment-1 的实际 complete batch 数不匹配(应该是 4)。修正为 4
|
||||||
|
- **v1.1(Oracle 修订 bg_22edec11)**:
|
||||||
|
- **BLOCKING**:非尾段 parser corruption 用 `%v` 而不是 `%w` —— `IsTailCorruption` 用 `errors.As` 遍历链,`%w` 包装后仍能匹配 `*TailCorruptionError`,会被当 tail corruption 处理
|
||||||
|
- **BLOCKING**:漏了 `wal/recover_test.go:121` 的 `ReplaySegmentFile` 直接调用,需要更新签名。共 3 个直接调用点
|
||||||
|
- **NEW**:加 `TestRecoverMiddleSegmentCRCCorruptionHardFails` 集成测试,覆盖 parser corruption 中段损坏(CRC fail)的端到端路径
|
||||||
|
- **MINOR**:把 SegmentPath attachment 移到非尾段硬错误转换之前,让两条路径都能设置字段(虽然非尾段硬错误不需要,但保持顺序一致)
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
# C5 + H8 修复方案:truncation 持久化 + batch-aware 截断点
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
> **目标**:让 WAL 尾部截断真正按设计 §3.2 line 787-800 执行 4 步协议(ftruncate + fsync segment + 删空 segment + fsync dir),并让截断点基于完整 batch 边界而不是物理 record 边界。两条 bug 在同一段代码(truncation 路径),捆绑修最经济。
|
||||||
|
>
|
||||||
|
> **交付**:
|
||||||
|
> - 新函数 `findLastCompleteBatchEnd` —— batch-aware 的截断点计算(替换 `findValidOffset`)
|
||||||
|
> - 新函数 `truncateAndPersist` —— 4 步串行执行(替换 `truncateSegment`)
|
||||||
|
> - `Recover` 调用新函数,**失败硬错误**(不再吞)
|
||||||
|
> - 复用 C6 的 `dirFsyncFn` 做 step 4
|
||||||
|
> - 新增 ~6 个测试覆盖各场景
|
||||||
|
> - 单次 commit
|
||||||
|
>
|
||||||
|
> **预估工时**:3.5-5 小时(Oracle 修订后加了 block-boundary、padding、retry、invalid-batch 等测试)
|
||||||
|
> **风险**:中。改动局限在 `wal/recover.go` 一个文件 + 一处 record_parser 钩子,但 truncation 涉及多步 fsync,要小心顺序
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
### Bug 摘要
|
||||||
|
|
||||||
|
#### C5(truncation 4 步缺失)
|
||||||
|
|
||||||
|
`wal/recover.go:62-68`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
validOffset, truncErr := findValidOffset(lastSeg.FilePath)
|
||||||
|
if truncErr != nil {
|
||||||
|
result.TruncateError = fmt.Errorf("%w (find valid offset: %v)", err, truncErr) // ← 吞
|
||||||
|
} else if truncErr := truncateSegment(lastSeg.FilePath, validOffset); truncErr != nil {
|
||||||
|
result.TruncateError = fmt.Errorf("%w (truncate: %v)", err, truncErr) // ← 吞
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`truncateSegment` 只调 `os.Truncate`,**缺**:
|
||||||
|
- Step 2: fsync 被截断的 segment
|
||||||
|
- Step 3: 删除空的后续 segment
|
||||||
|
- Step 4: fsync WAL directory
|
||||||
|
|
||||||
|
错误被吞到 `result.TruncateError`,recovery 仍返回成功。DB 进入可写状态。
|
||||||
|
|
||||||
|
#### H8(截断点不跟踪 batch 边界)
|
||||||
|
|
||||||
|
`findValidOffset` 只用 `DecodePhysicalRecord` 校验物理记录,**不跟踪 fragment 状态机**。尾部 `First + Middle*` 没 `Last` 时返回错误截断点(残留半截 fragment),导致下次启动再次报 tail corruption,**反复 repair**。
|
||||||
|
|
||||||
|
### 设计依据
|
||||||
|
|
||||||
|
`docs/design.md` §3.2 line 786-800:
|
||||||
|
|
||||||
|
> 截断目标始终是**最后一个完整 WAL Batch 的结束位置** `lastCompleteBatchEnd`
|
||||||
|
|
||||||
|
```
|
||||||
|
1. ftruncate 当前 active segment 到 lastCompleteBatchEnd
|
||||||
|
2. fsync 被截断的 segment
|
||||||
|
3. 删除 startSequence == expectedSequence 且不含任何 complete batch 的后续空 segment
|
||||||
|
4. fsync WAL directory
|
||||||
|
```
|
||||||
|
|
||||||
|
> 若 ftruncate、segment fsync、空 segment 删除或 WAL directory fsync **任一步失败,recovery 必须报错,DB 不得进入可写状态**
|
||||||
|
|
||||||
|
### 协同:复用 C6 的 `dirFsyncFn`
|
||||||
|
|
||||||
|
C6 已经抽出 `dirFsyncFn`(package-level 变量支持测试注入)。本次 fix 的 Step 4 直接复用,无需再抽一个。
|
||||||
|
|
||||||
|
### 错误传播
|
||||||
|
|
||||||
|
`Recover` 失败 → `DB.Open` 失败 → 用户看到 error,DB 没进可写状态 ✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行计划
|
||||||
|
|
||||||
|
### Phase A:代码改动(90-120 分钟)
|
||||||
|
|
||||||
|
#### A.1 新增 `findLastCompleteBatchEnd`(替换 `findValidOffset`)
|
||||||
|
|
||||||
|
文件:`wal/recover.go`
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_f1e4db4f)BLOCKING**:原版看到 zero padding 立即 return,但 WAL 格式允许 full block 末尾 padding + 下一个 block 继续写 record。必须区分"full block 内 padding(continue 下一块)"和"short block 尾部 padding(return)"。
|
||||||
|
|
||||||
|
```go
|
||||||
|
// findLastCompleteBatchEnd walks the segment file, runs physical records
|
||||||
|
// through the FragmentCollector state machine, and returns the byte offset
|
||||||
|
// of the END of the last complete WAL Batch.
|
||||||
|
//
|
||||||
|
// This is the correct truncation target per design §3.2 line 786. The
|
||||||
|
// previous findValidOffset only checked physical record CRCs, missing
|
||||||
|
// the case where a First + Middle* fragment chain has no Last (H8 bug):
|
||||||
|
// physical CRCs pass but no complete batch exists at that offset.
|
||||||
|
//
|
||||||
|
// Block-boundary handling: WAL format allows a full block to end with
|
||||||
|
// zero padding when the next record doesn't fit (see BlockWriter.paddingNeeded).
|
||||||
|
// This function must CONTINUE to the next block on padding in a full block,
|
||||||
|
// and only RETURN on padding in a short (final) block or actual corruption.
|
||||||
|
func findLastCompleteBatchEnd(filePath string) (int64, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("open %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if _, err := f.Seek(WalFileHeaderSize, 0); err != nil {
|
||||||
|
return 0, fmt.Errorf("seek past header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
collector := NewFragmentCollector()
|
||||||
|
lastCompleteEnd := int64(WalFileHeaderSize)
|
||||||
|
blockStartOffset := int64(WalFileHeaderSize)
|
||||||
|
buf := make([]byte, WalBlockSize)
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, readErr := f.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
blockData := buf[:n]
|
||||||
|
isFullBlock := n == WalBlockSize && readErr == nil
|
||||||
|
pos := 0
|
||||||
|
for pos < len(blockData) {
|
||||||
|
remaining := len(blockData) - pos
|
||||||
|
|
||||||
|
if remaining < PhysicalRecordHeaderSize {
|
||||||
|
if isFullBlock {
|
||||||
|
break // padding in full block, continue to next block
|
||||||
|
}
|
||||||
|
return lastCompleteEnd, nil // tail padding in short block
|
||||||
|
}
|
||||||
|
|
||||||
|
if isAllZeros(blockData[pos : pos+PhysicalRecordHeaderSize]) {
|
||||||
|
if isFullBlock {
|
||||||
|
break // zero-led padding in full block, continue
|
||||||
|
}
|
||||||
|
return lastCompleteEnd, nil // tail padding
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, consumed, err := DecodePhysicalRecord(blockData[pos:])
|
||||||
|
if err != nil {
|
||||||
|
return lastCompleteEnd, nil // physical corruption
|
||||||
|
}
|
||||||
|
if err := collector.Append(rec.Type, rec.Payload); err != nil {
|
||||||
|
return lastCompleteEnd, nil // fragment state machine rejected
|
||||||
|
}
|
||||||
|
|
||||||
|
pos += consumed
|
||||||
|
recordEndAbsolute := blockStartOffset + int64(pos)
|
||||||
|
|
||||||
|
if collector.IsComplete() {
|
||||||
|
lastCompleteEnd = recordEndAbsolute
|
||||||
|
collector.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blockStartOffset += int64(n)
|
||||||
|
}
|
||||||
|
if readErr != nil || n < WalBlockSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastCompleteEnd, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键不变量**:
|
||||||
|
- `lastCompleteEnd` 只在 collector 回到 Idle(完整 batch 形成)时推进
|
||||||
|
- 跨 block padding:`isFullBlock` 判断决定 continue vs return
|
||||||
|
- 任何中断(short block padding / 物理损坏 / fragment 顺序错)→ 返回当前 `lastCompleteEnd`
|
||||||
|
- EOF 时如果 collector 在 Collecting 状态 → 不推进(半截 batch 不算)
|
||||||
|
|
||||||
|
#### A.2 新增 `truncateAndPersist`(替换 `truncateSegment`)
|
||||||
|
|
||||||
|
文件:`wal/recover.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// segmentFsyncFn is the package-level indirection for fsyncing a truncated
|
||||||
|
// segment file. Tests that override this must not use t.Parallel().
|
||||||
|
var segmentFsyncFn = segmentFsync
|
||||||
|
|
||||||
|
func segmentFsync(filePath string) error {
|
||||||
|
f, err := os.OpenFile(filePath, os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open for fsync: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("fsync: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateAndPersist executes the 4-step tail-truncation protocol per
|
||||||
|
// design §3.2 line 787-794. Any step failure is fatal: per line 799,
|
||||||
|
// DB must NOT enter writable state if truncation cannot be persisted.
|
||||||
|
//
|
||||||
|
// Steps:
|
||||||
|
// 1. ftruncate segment to lastCompleteBatchEnd
|
||||||
|
// 2. fsync the truncated segment
|
||||||
|
// 3. delete trailing empty segments (startSequence == expectedSequence
|
||||||
|
// and no complete batch)
|
||||||
|
// 4. fsync WAL directory (reuses dirFsyncFn from C6)
|
||||||
|
func truncateAndPersist(
|
||||||
|
filePath string,
|
||||||
|
lastCompleteBatchEnd int64,
|
||||||
|
dir string,
|
||||||
|
emptyTrailingSegments []string,
|
||||||
|
) error {
|
||||||
|
// Step 1: ftruncate
|
||||||
|
if err := os.Truncate(filePath, lastCompleteBatchEnd); err != nil {
|
||||||
|
return fmt.Errorf("ftruncate %s to %d: %w", filePath, lastCompleteBatchEnd, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: fsync the truncated segment
|
||||||
|
if err := segmentFsyncFn(filePath); err != nil {
|
||||||
|
return fmt.Errorf("fsync truncated segment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: delete empty trailing segments
|
||||||
|
for _, segPath := range emptyTrailingSegments {
|
||||||
|
if err := os.Remove(segPath); err != nil {
|
||||||
|
return fmt.Errorf("remove empty segment %s: %w", segPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: fsync WAL directory (reuses C6's dirFsyncFn)
|
||||||
|
if err := dirFsyncFn(dir); err != nil {
|
||||||
|
return fmt.Errorf("fsync WAL dir after truncation: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **关于 hook 触发的注释**:docstring 引用设计行号 + 列出 4 步,是必要的回归防护。`segmentFsyncFn` 的 not-parallel-safe 注释和 C6 的 `dirFsyncFn` 一致。
|
||||||
|
|
||||||
|
#### A.3 重构 `Recover` 失败路径
|
||||||
|
|
||||||
|
文件:`wal/recover.go`,替换 line 59-69 的 truncation 块:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 改前(C5 bug:吞错误):
|
||||||
|
if len(segments) > 0 {
|
||||||
|
lastSeg := segments[len(segments)-1]
|
||||||
|
validOffset, truncErr := findValidOffset(lastSeg.FilePath)
|
||||||
|
if truncErr != nil {
|
||||||
|
result.TruncateError = fmt.Errorf("%w (find valid offset: %v)", err, truncErr)
|
||||||
|
} else if truncErr := truncateSegment(lastSeg.FilePath, validOffset); truncErr != nil {
|
||||||
|
result.TruncateError = fmt.Errorf("%w (truncate: %v)", err, truncErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改后(按设计 line 787-800 执行 4 步,失败硬错误):
|
||||||
|
if len(segments) > 0 {
|
||||||
|
lastSeg := segments[len(segments)-1]
|
||||||
|
lastCompleteBatchEnd, findErr := findLastCompleteBatchEnd(lastSeg.FilePath)
|
||||||
|
if findErr != nil {
|
||||||
|
return nil, fmt.Errorf("wal: recover: find truncation offset: %w", findErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: truncated segment is always segments[last], no trailing empty
|
||||||
|
// segments to clean up. C4 fix will need to identify trailing empties
|
||||||
|
// based on the actually-corrupted segment's index.
|
||||||
|
var emptyTrailing []string
|
||||||
|
|
||||||
|
if err := truncateAndPersist(lastSeg.FilePath, lastCompleteBatchEnd, dir, emptyTrailing); err != nil {
|
||||||
|
// Per design §3.2 line 799: DB must NOT enter writable state.
|
||||||
|
return nil, fmt.Errorf("wal: recover: persist tail truncation: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **TruncateError 字段语义澄清(Oracle 修订)**:`result.TruncateError` 在 `Recover` line 42-46 创建 result 时被设置为 `err`(the original tail corruption error)。这个字段保留为 **informational**:"tail corruption was found and repaired"。现有测试 `TestRecoverWithTailCorruption` 和 `TestRecoverIdempotentAfterTruncation` 依赖这个非 nil 检查。
|
||||||
|
>
|
||||||
|
> **不要再**用 `TruncateError` 报告 truncation 持久化失败 —— 那种失败现在硬错误返回。两种语义不混。
|
||||||
|
|
||||||
|
#### A.4 `emptyTrailingSegments` Phase 1 简化(Oracle 修订)
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_f1e4db4f)**:原计划的 `identifyEmptyTrailingSegments` 函数在 Phase 1 永远返回 nil,且其签名不足以支持 C4 修复(C4 需要 corrupted segment 的 index/path,不只是 `segments` 和 `expectedSequence`)。**删除函数**,改用 inline 注释 + `nil`。C4 修复时再补正确的实现。
|
||||||
|
|
||||||
|
`Recover` 内部直接:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Phase 1: truncated segment is always segments[last], no trailing empty
|
||||||
|
// segments to clean up. C4 fix will need to identify trailing empties
|
||||||
|
// based on the actually-corrupted segment's index.
|
||||||
|
var emptyTrailing []string // always nil in Phase 1
|
||||||
|
|
||||||
|
if err := truncateAndPersist(lastSeg.FilePath, lastCompleteBatchEnd, dir, emptyTrailing); err != nil {
|
||||||
|
return nil, fmt.Errorf("wal: recover: persist tail truncation: %w", err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`truncateAndPersist` 的签名保留 `emptyTrailingSegments []string` 参数(forward compat),Phase 1 永远传 nil。`hasCompleteBatch` helper 也不需要了。
|
||||||
|
|
||||||
|
#### A.5 删除老函数
|
||||||
|
|
||||||
|
`wal/recover.go` 中删除:
|
||||||
|
- `truncateSegment`(被 `truncateAndPersist` 替换)
|
||||||
|
- `findValidOffset`(被 `findLastCompleteBatchEnd` 替换)
|
||||||
|
|
||||||
|
### Phase B:测试(60-90 分钟)
|
||||||
|
|
||||||
|
#### B.1 `findLastCompleteBatchEnd` 单元测试
|
||||||
|
|
||||||
|
新增 `wal/recover_offset_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Regression guards for H8: findLastCompleteBatchEnd must return the
|
||||||
|
// offset of the last COMPLETE batch, not the last physical record.
|
||||||
|
|
||||||
|
func TestFindLastCompleteBatchEnd_CleanSegment(t *testing.T) {
|
||||||
|
// 2 complete batches, no partial tail.
|
||||||
|
// Expect: offset = end of batch 2.
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindLastCompleteBatchEnd_PartialTailFragment(t *testing.T) {
|
||||||
|
// 1 complete batch + First + Middle* (no Last).
|
||||||
|
// Expect: offset = end of batch 1 (NOT after the Middle fragment).
|
||||||
|
// This is the H8 regression case.
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindLastCompleteBatchEnd_NoBatches(t *testing.T) {
|
||||||
|
// Empty segment (only header).
|
||||||
|
// Expect: offset = WalFileHeaderSize.
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindLastCompleteBatchEnd_PhysicalCorruption(t *testing.T) {
|
||||||
|
// 1 complete batch + corrupted bytes (bad CRC).
|
||||||
|
// Expect: offset = end of batch 1.
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindLastCompleteBatchEnd_PartialBatchOnly(t *testing.T) {
|
||||||
|
// Only First + Middle* at the start (no complete batch ever).
|
||||||
|
// Expect: offset = WalFileHeaderSize.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oracle BLOCKING test: must distinguish "padding in full block (continue
|
||||||
|
// to next block)" from "padding in short block (return)".
|
||||||
|
func TestFindLastCompleteBatchEnd_BlockBoundaryPadding(t *testing.T) {
|
||||||
|
// Construct: [Batch A in block 1 (filling)] [padding to end of block 1]
|
||||||
|
// [Batch B in block 2]
|
||||||
|
// Expect: offset = end of Batch B in block 2 (NOT end of Batch A).
|
||||||
|
// This catches the bug where the original plan returned on first padding.
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindLastCompleteBatchEnd_NonZeroTailPadding(t *testing.T) {
|
||||||
|
// 1 complete batch + 3 non-zero bytes (< PhysicalRecordHeaderSize).
|
||||||
|
// Expect: offset = end of batch 1.
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindLastCompleteBatchEnd_ZeroTailPadding(t *testing.T) {
|
||||||
|
// 1 complete batch + 3 zero bytes.
|
||||||
|
// Expect: offset = end of batch 1.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B.2 `truncateAndPersist` 测试
|
||||||
|
|
||||||
|
新增到 `wal/recover_offset_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Regression guards for C5: 4-step protocol must execute all steps.
|
||||||
|
|
||||||
|
func TestTruncateAndPersist_Success(t *testing.T) {
|
||||||
|
// Create segment, write some bytes past lastCompleteBatchEnd.
|
||||||
|
// Call truncateAndPersist.
|
||||||
|
// Verify:
|
||||||
|
// - File size == lastCompleteBatchEnd (Step 1)
|
||||||
|
// - File fsync'd (hard to verify directly; trust the call)
|
||||||
|
// - Empty trailing segments deleted (Step 3)
|
||||||
|
// - Dir fsync'd (Step 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateAndPersist_FtruncateFailure(t *testing.T) {
|
||||||
|
// Pass non-existent file path.
|
||||||
|
// Expect: error mentioning "ftruncate".
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateAndPersist_DirFsyncFailure(t *testing.T) {
|
||||||
|
// Inject dirFsyncFn failure (reuse C6 injection).
|
||||||
|
// Expect: error mentioning "fsync WAL dir".
|
||||||
|
// Note: file IS truncated (step 1 succeeded), but step 4 failed.
|
||||||
|
// Per design, Recover must return error → DB.Open fails.
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateAndPersist_SegmentFsyncFailure(t *testing.T) {
|
||||||
|
// Inject segmentFsyncFn failure.
|
||||||
|
// Expect: error mentioning "fsync truncated segment".
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oracle nice-to-have: verify retry after dir-fsync failure.
|
||||||
|
func TestTruncateAndPersist_RetryAfterDirFsyncFailure(t *testing.T) {
|
||||||
|
// First call: inject dirFsyncFn failure → error.
|
||||||
|
// Second call: restore dirFsyncFn, call again → success.
|
||||||
|
// Verifies state stays recoverable across failures.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B.3 `Recover` 集成测试
|
||||||
|
|
||||||
|
修改或新增到 `wal/recover_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Regression guard for C5+H8: end-to-end recovery with partial fragment
|
||||||
|
// tail must persist truncation correctly and be idempotent.
|
||||||
|
func TestRecoverPartialFragmentTailIdempotent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// Build segment with: [Batch A] [Batch B] [First + Middle* no Last]
|
||||||
|
// ...
|
||||||
|
|
||||||
|
replayer1 := &mockReplayer{}
|
||||||
|
result1, err := Recover(dir, replayer1)
|
||||||
|
if err != nil { t.Fatalf("1st Recover: %v", err) }
|
||||||
|
if !result1.Truncated { t.Fatal("Truncated = false, want true") }
|
||||||
|
|
||||||
|
// Verify file size == end of Batch B (H8 fix)
|
||||||
|
fi, _ := os.Stat(segmentPath)
|
||||||
|
if fi.Size() != expectedBatchBEnd {
|
||||||
|
t.Errorf("file size = %d, want %d (last complete batch end)",
|
||||||
|
fi.Size(), expectedBatchBEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second recovery should not see corruption (file is clean now)
|
||||||
|
replayer2 := &mockReplayer{}
|
||||||
|
result2, err := Recover(dir, replayer2)
|
||||||
|
if err != nil { t.Fatalf("2nd Recover: %v", err) }
|
||||||
|
if result2.Truncated { t.Error("2nd Recover should not see corruption") }
|
||||||
|
if result1.NextSequence != result2.NextSequence {
|
||||||
|
t.Errorf("NextSequence differs: %d vs %d", result1.NextSequence, result2.NextSequence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C5: any truncation step failure must fail DB.Open.
|
||||||
|
func TestRecoverTruncationFailureFailsRecovery(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// Build segment with tail corruption
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// Inject dirFsyncFn failure
|
||||||
|
orig := dirFsyncFn
|
||||||
|
dirFsyncFn = func(string) error { return errors.New("simulated") }
|
||||||
|
t.Cleanup(func() { dirFsyncFn = orig })
|
||||||
|
|
||||||
|
_, err := Recover(dir, &mockReplayer{})
|
||||||
|
if err == nil { t.Fatal("expected Recover to fail when truncation persist fails") }
|
||||||
|
if !strings.Contains(err.Error(), "persist tail truncation") {
|
||||||
|
t.Errorf("error should mention 'persist tail truncation', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oracle nice-to-have: CRC-valid but batch-content-invalid must hard-fail
|
||||||
|
// through DecodeWalBatch, NOT enter truncation path. Per design line 778-781.
|
||||||
|
func TestRecoverInvalidBatchNotTruncatable(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// Construct segment with: physical records CRC-valid, but assembled
|
||||||
|
// batch has invalid header (e.g., entryCount=0).
|
||||||
|
// Expect: Recover returns error (NOT tail corruption, NOT success).
|
||||||
|
// Expect: file is NOT truncated.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:现有的 `TestRecoverIdempotentAfterTruncation` 测试(C2+C3 加的)已经覆盖了"截断后第二次 Recover 干净"的场景。本次新增的 `TestRecoverPartialFragmentTailIdempotent` 专门覆盖 **H8**(partial fragment tail),是更严格的回归测试。
|
||||||
|
>
|
||||||
|
> **Oracle 提醒**:检查现有 `TestRecoverIdempotentAfterTruncation` 的 `TruncateError` 期望是否需要更新(原期望非 nil,新行为下仍非 nil 因为 `result.TruncateError` 在 result 创建时就设了 original err,仍保留 informational 语义)。
|
||||||
|
|
||||||
|
### Phase C:验证(15 分钟)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 编译
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
# 2. 重点测试
|
||||||
|
go test ./wal -run 'TestFindLastCompleteBatchEnd|TestTruncateAndPersist|TestRecoverPartialFragmentTail|TestRecoverTruncationFailure|TestRecoverIdempotent' -count=1 -v
|
||||||
|
|
||||||
|
# 3. wal 包全量
|
||||||
|
go test ./wal/... -count=1
|
||||||
|
|
||||||
|
# 4. 全仓
|
||||||
|
go test ./... -count=1
|
||||||
|
|
||||||
|
# 5. race 全仓
|
||||||
|
go test -race ./... -count=1
|
||||||
|
|
||||||
|
# 6. vet
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
# 7. lint(如果装了)
|
||||||
|
if command -v golangci-lint >/dev/null 2>&1; then
|
||||||
|
golangci-lint run ./wal/...
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase D:Commit message draft
|
||||||
|
|
||||||
|
```
|
||||||
|
fix: persist WAL tail truncation per design protocol (C5+H8)
|
||||||
|
|
||||||
|
Recovery tail-truncation had two compounding bugs in wal/recover.go:
|
||||||
|
|
||||||
|
C5: truncateSegment only called os.Truncate. Missing per design §3.2
|
||||||
|
line 787-794:
|
||||||
|
- Step 2: fsync the truncated segment
|
||||||
|
- Step 3: delete empty trailing segments
|
||||||
|
- Step 4: fsync WAL directory
|
||||||
|
And all errors were swallowed into result.TruncateError with recovery
|
||||||
|
still returning success, violating design line 799: "若 ftruncate、
|
||||||
|
segment fsync、空 segment 删除或 WAL directory fsync 任一步失败,
|
||||||
|
recovery 必须报错,DB 不得进入可写状态".
|
||||||
|
|
||||||
|
H8: findValidOffset only checked physical record CRCs, ignoring the
|
||||||
|
FragmentCollector state machine. For a tail of First + Middle*
|
||||||
|
without Last, it returned the offset AFTER the last Middle fragment
|
||||||
|
instead of the last COMPLETE batch end. Result: residual half-batch
|
||||||
|
fragments caused repeated tail-corruption reports on every restart.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- wal/recover.go:
|
||||||
|
- Add findLastCompleteBatchEnd: batch-aware offset finder using
|
||||||
|
FragmentCollector state machine. Returns true last batch boundary.
|
||||||
|
- Add truncateAndPersist: 4-step protocol (ftruncate + fsync segment +
|
||||||
|
delete empty trailing + fsync dir). Any step failure is fatal.
|
||||||
|
- Add segmentFsyncFn (package-level var for test injection, same
|
||||||
|
pattern as C6's dirFsyncFn).
|
||||||
|
- Refactor Recover failure path: use new functions, hard-error on
|
||||||
|
truncation persist failure (was: swallow to TruncateError).
|
||||||
|
- Delete findValidOffset and truncateSegment (replaced).
|
||||||
|
- wal/recover_offset_test.go (new): 5 unit tests for
|
||||||
|
findLastCompleteBatchEnd covering clean/partial-tail/no-batch/
|
||||||
|
physical-corruption/partial-only cases. 4 unit tests for
|
||||||
|
truncateAndPersist covering success/ftruncate-fail/dir-fsync-fail/
|
||||||
|
segment-fsync-fail.
|
||||||
|
- wal/recover_test.go: add TestRecoverPartialFragmentTailIdempotent
|
||||||
|
(H8 e2e regression) and TestRecoverTruncationFailureFailsRecovery
|
||||||
|
(C5 e2e regression).
|
||||||
|
|
||||||
|
Injection note: segmentFsyncFn and dirFsyncFn (from C6) are package-level
|
||||||
|
vars; tests that override either must not use t.Parallel().
|
||||||
|
|
||||||
|
Verified: each new test fails on pre-fix code and passes after the fix.
|
||||||
|
Full suite green including go test -race ./... .
|
||||||
|
|
||||||
|
Phase 1 simplification: emptyTrailingSegments is always nil in Phase 1
|
||||||
|
(truncated segment is always segments[last]). The parameter is kept in
|
||||||
|
truncateAndPersist's signature for forward compatibility with the C4 fix.
|
||||||
|
|
||||||
|
Audit context: docs/audit-3.2.md C5 and H8 (H8 Oracle-verified bg_ef425776).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收清单
|
||||||
|
|
||||||
|
- [ ] Phase A.1:`findLastCompleteBatchEnd` 存在,使用 FragmentCollector
|
||||||
|
- [ ] Phase A.2:`truncateAndPersist` 存在,4 步串行执行
|
||||||
|
- [ ] Phase A.2:`segmentFsyncFn` package-level 变量存在
|
||||||
|
- [ ] Phase A.3:`Recover` 失败路径调用新函数,失败硬错误
|
||||||
|
- [ ] Phase A.4:`identifyEmptyTrailingSegments` + `hasCompleteBatch` helper 存在
|
||||||
|
- [ ] Phase A.5:`truncateSegment` 和 `findValidOffset` 已删除
|
||||||
|
- [ ] Phase B.1:5 个 `findLastCompleteBatchEnd` 单元测试存在
|
||||||
|
- [ ] Phase B.2:4 个 `truncateAndPersist` 单元测试存在
|
||||||
|
- [ ] Phase B.3:2 个 Recover 集成测试存在
|
||||||
|
- [ ] `go test ./wal/... -count=1` 全绿
|
||||||
|
- [ ] `go test ./... -count=1` 全绿
|
||||||
|
- [ ] `go test -race ./... -count=1` 全绿
|
||||||
|
- [ ] `go vet ./...` 无新增警告
|
||||||
|
- [ ] 单次 commit,message 引用 audit C5+H8
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 不在本次范围内(后续 issue)
|
||||||
|
|
||||||
|
| 编号 | 为什么不放进来 |
|
||||||
|
|------|---------------|
|
||||||
|
| C4 | 同一文件但不耦合。C4 修好后 `identifyEmptyTrailingSegments` 才会返回非 nil,但本 patch 已经写了前向兼容代码 |
|
||||||
|
| C8 | segment_manager.go startSequence bug,独立 |
|
||||||
|
| C1 | CRC 多项式错,独立 |
|
||||||
|
| C7 | Put/Close 竞态,独立 |
|
||||||
|
| H1-H7 | 其他 High,独立 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修订记录
|
||||||
|
|
||||||
|
- **v1(原始)**:C5+H8 修复方案初稿,送 Momus 审
|
||||||
|
- **v1.0(Momus 审核 bg_c8823195)**:[OKAY],无 blocking。思考过程提到 batch-content validity 担忧,Oracle Q2 确认非阻塞(upstream ReplaySegmentFile 已经处理)
|
||||||
|
- **v1.1(Oracle 修订 bg_f1e4db4f)**:
|
||||||
|
- **BLOCKING**:`findLastCompleteBatchEnd` 跨 block padding bug — full block 末尾 padding 应 continue 到下一块,不是 return。加 `isFullBlock` 判断
|
||||||
|
- **BLOCKING**:缺跨 block 测试,加 `TestFindLastCompleteBatchEnd_BlockBoundaryPadding`
|
||||||
|
- **NEW**:加非零 padding / 零 padding 测试(Q1 f/g)
|
||||||
|
- **NEW**:加 `TestTruncateAndPersist_RetryAfterDirFsyncFailure`
|
||||||
|
- **NEW**:加 `TestRecoverInvalidBatchNotTruncatable`(CRC-valid 但 batch 内容无效,按 design line 778-781 应硬错误不截断)
|
||||||
|
- **CHANGE**:删除 `identifyEmptyTrailingSegments` + `hasCompleteBatch`(dead code,签名不足以支持 C4)。改 inline `var emptyTrailing []string` + 注释
|
||||||
|
- **CLARIFY**:`TruncateError` 字段保留 informational 语义("tail corruption was found and repaired"),不再用于报告 truncation 持久化失败
|
||||||
|
- **FIX**:commit message 去掉 "C5 Oracle-verified bg_ef425776"(C5 实际未经过 Oracle 修订,只有 C2/C3/C4/H8 有)
|
||||||
|
- **BUMP**:估时 2.5-3.5h → 3.5-5h
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
# C6 修复方案:SegmentWriter 目录 fsync 失败必须硬错误
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
> **目标**:修复 `wal/segment_writer.go:86-89` 静默吞掉目录 fsync 错误的 durability 漏洞。让 `NewSegmentWriter` 在目录 fsync 失败时返回错误,传播到 `SegmentManager.AppendBatch` → 触发 `ErrCommitUnknown` + write-stopped(或 `DB.Open` 失败)。
|
||||||
|
>
|
||||||
|
> **交付**:
|
||||||
|
> - 抽出 `dirFsync` helper 函数(独立可测)
|
||||||
|
> - `NewSegmentWriter` 调用 helper,失败时清理资源并返回错误
|
||||||
|
> - 用 package-level 变量支持测试注入失败
|
||||||
|
> - 新增 3 个测试:helper 单元测试 × 2 + NewSegmentWriter 集成测试 × 1
|
||||||
|
> - 单次 commit
|
||||||
|
>
|
||||||
|
> **预估工时**:1.25-1.75 小时
|
||||||
|
> **风险**:低(单文件改动 + 测试,production 行为变化是"原本被吞的错误现在会冒出来",这正是设计意图)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
### Bug 摘要(audit C6)
|
||||||
|
|
||||||
|
`wal/segment_writer.go:85-89`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Sync directory to make rename durable (best-effort on Linux).
|
||||||
|
if dirFD, derr := os.Open(dir); derr == nil {
|
||||||
|
dirFD.Sync() // 错误被完全忽略
|
||||||
|
dirFD.Close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
两层错误都被吞:
|
||||||
|
1. `os.Open(dir)` 失败 → 整个 fsync 被跳过
|
||||||
|
2. `dirFD.Sync()` 失败 → 错误丢失
|
||||||
|
|
||||||
|
注释写"best-effort on Linux"误导,但设计明确这是硬条件。
|
||||||
|
|
||||||
|
### 设计依据(§3.2 line 248-273)
|
||||||
|
|
||||||
|
新 segment durable-ready 协议第 5 步:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. create segment-N.wal.tmp
|
||||||
|
2. write WAL File Header
|
||||||
|
3. fsync segment-N.wal.tmp
|
||||||
|
4. rename segment-N.wal.tmp → segment-N.wal
|
||||||
|
5. fsync WAL directory ← 关键
|
||||||
|
6. segment-N enters durable-ready state
|
||||||
|
7. WAL writer may append batches whose recovery depends on segment-N
|
||||||
|
```
|
||||||
|
|
||||||
|
line 272:
|
||||||
|
|
||||||
|
> 如果新 segment 进入 durable-ready 之前任一步失败,**该 segment 不得成为 active segment,也不得承载可确认写入**。如果此时尚未分配 sequence,可以重试创建或切换到其他 segment;如果 sequence 已分配或已有 WAL Batch 依赖该 segment,则按 WAL write failure 处理,**引擎进入 write-stopped 状态**。
|
||||||
|
|
||||||
|
### 为什么是 Always 模式最严重的 durability 漏洞
|
||||||
|
|
||||||
|
Linux rename 是内存中的原子操作,**不保证掉电后目录项可恢复**。要让 rename 落盘必须 fsync 目录本身。掉电场景下:
|
||||||
|
|
||||||
|
- rename 已执行但目录 fsync 没成功 → 重启后 segment 文件可能从目录里消失
|
||||||
|
- 但 WAL writer 已经按 "Always 不丢已确认写入" 向调用方返回成功
|
||||||
|
|
||||||
|
这个 bug **平时不发作**(正常环境下 rename + 异步 write-back 都会成功),**只在掉电那一刻有概率中招**。是最阴险的类型。
|
||||||
|
|
||||||
|
### 错误传播路径(已正确,只需源头报错)
|
||||||
|
|
||||||
|
```
|
||||||
|
NewSegmentWriter 失败(dir fsync error)
|
||||||
|
├─ 在 NewSegmentManager 路径(DB.Open 首次创建 segment)
|
||||||
|
│ → NewSegmentManager 失败 → DB.Open 失败
|
||||||
|
│ → 用户看到 error,没数据承诺被违反 ✓
|
||||||
|
└─ 在 SegmentManager.rotate 路径(segment 满了轮转)
|
||||||
|
→ AppendBatch 失败 → WalWriter.stopWithError(ErrCommitUnknown)
|
||||||
|
→ write-stopped ✓(符合设计 line 272)
|
||||||
|
```
|
||||||
|
|
||||||
|
`segment_manager.go:35-37, 81-83` 已经透传 error,本次 fix 不需要改它。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行计划
|
||||||
|
|
||||||
|
### Phase A:代码改动(30 分钟)
|
||||||
|
|
||||||
|
#### A.1 抽出 `dirFsync` helper
|
||||||
|
|
||||||
|
新文件 `wal/dir_fsync.go`(或加入现有 `segment_writer.go` 末尾):
|
||||||
|
|
||||||
|
> **Momus 修订(bg_90bcf359)**:helper 应该校验 path 是目录,否则传文件路径会"成功 fsync 了一个文件"但语义错误。加 `os.Stat` + `IsDir` 检查。
|
||||||
|
>
|
||||||
|
> **Oracle 修订(bg_48d53274)**:进一步用 `os.Open → f.Stat → IsDir → f.Sync` 顺序,消除 `os.Stat` + `os.Open` 之间的 TOCTOU 窗口。
|
||||||
|
|
||||||
|
```go
|
||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dirFsync opens the directory and fsyncs it. Required for durable-ready
|
||||||
|
// state per design §3.2 line 258. This is a hard requirement, not
|
||||||
|
// best-effort: rename is atomic in memory but not guaranteed to survive
|
||||||
|
// power loss without a directory fsync.
|
||||||
|
//
|
||||||
|
// dirFsyncFn is a package-level variable so tests can inject failures
|
||||||
|
// without an interface indirection in production code.
|
||||||
|
//
|
||||||
|
// NOT PARALLEL-SAFE: tests that override dirFsyncFn must not use t.Parallel().
|
||||||
|
// All existing wal tests run serially within the package.
|
||||||
|
var dirFsyncFn = dirFsync
|
||||||
|
|
||||||
|
func dirFsync(dir string) error {
|
||||||
|
f, err := os.Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open dir %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat dir %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return fmt.Errorf("path %q is not a directory", dir)
|
||||||
|
}
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("fsync dir %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **关于 hook 触发的注释**:上面 docstring 是必要的 —— 它解释了**为什么这是硬条件而不是 best-effort**(防止未来工程师把它"简化"成 silent again)。属于 security-related 注释,符合 hook 规则的 necessary comments。
|
||||||
|
|
||||||
|
#### A.2 修改 `NewSegmentWriter`
|
||||||
|
|
||||||
|
替换 `wal/segment_writer.go:85-89`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 改前(C6 bug):
|
||||||
|
// Sync directory to make rename durable (best-effort on Linux).
|
||||||
|
if dirFD, derr := os.Open(dir); derr == nil {
|
||||||
|
dirFD.Sync()
|
||||||
|
dirFD.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改后:
|
||||||
|
// Per design §3.2 line 258, directory fsync is a hard requirement for
|
||||||
|
// durable-ready. Without it, the rename above is not guaranteed to survive
|
||||||
|
// power loss, violating the Always-mode "no loss of acknowledged writes"
|
||||||
|
// promise.
|
||||||
|
if err := dirFsyncFn(dir); err != nil {
|
||||||
|
closeErr := fd.Close()
|
||||||
|
removeErr := os.Remove(finalPath)
|
||||||
|
if closeErr != nil || removeErr != nil {
|
||||||
|
// Surface cleanup errors alongside the primary fsync error so they
|
||||||
|
// are not lost (Go 1.20+ errors.Join).
|
||||||
|
cleanup := errors.Join(closeErr, removeErr)
|
||||||
|
return nil, fmt.Errorf("wal: fsync directory after segment rename (cleanup: %v): %w", cleanup, err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("wal: fsync directory after segment rename: %w", err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
清理规则:失败时关闭新打开的 `fd`,删除已 rename 出来的 `finalPath`,让磁盘状态回到"没有 segment-N"。如果清理本身也失败,用 `errors.Join` 把清理错误和主错误一起返回(不丢任何信息)。
|
||||||
|
|
||||||
|
> **关于 hook 触发的注释**:这条注释引用设计行号 + 解释 failure 处理逻辑,防止未来"简化"成 silent。属于 security-related 注释。
|
||||||
|
|
||||||
|
### Phase B:测试(45-60 分钟)
|
||||||
|
|
||||||
|
#### B.1 helper 单元测试
|
||||||
|
|
||||||
|
新增 `wal/dir_fsync_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDirFsyncSuccess(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := dirFsync(dir); err != nil {
|
||||||
|
t.Errorf("dirFsync on valid dir: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirFsyncNonExistentDir(t *testing.T) {
|
||||||
|
err := dirFsync("/nonexistent/path/that/should/not/exist")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on non-existent dir")
|
||||||
|
}
|
||||||
|
// After Oracle修订 (Open first), non-existent dir fails at os.Open.
|
||||||
|
if !strings.Contains(err.Error(), "open dir") {
|
||||||
|
t.Errorf("error should mention 'open dir', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirFsyncNotADirectory(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := dir + "/notadir"
|
||||||
|
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
err := dirFsync(filePath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when fsyncing a file as dir")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not a directory") {
|
||||||
|
t.Errorf("error should mention 'not a directory', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B.2 NewSegmentWriter 集成测试(注入失败)
|
||||||
|
|
||||||
|
新增到 `wal/segment_writer_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Regression guard for C6: NewSegmentWriter must return an error when
|
||||||
|
// directory fsync fails, instead of silently succeeding. Per design §3.2
|
||||||
|
// line 272, segment must NOT become active if durable-ready fails.
|
||||||
|
func TestNewSegmentWriterDirFsyncFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
|
||||||
|
// Inject dir fsync failure.
|
||||||
|
orig := dirFsyncFn
|
||||||
|
dirFsyncFn = func(d string) error {
|
||||||
|
return errors.New("simulated dir fsync failure")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { dirFsyncFn = orig })
|
||||||
|
|
||||||
|
sw, err := NewSegmentWriter(dir, 0, 0, &cfg)
|
||||||
|
if err == nil {
|
||||||
|
if sw != nil {
|
||||||
|
sw.Close()
|
||||||
|
}
|
||||||
|
t.Fatal("NewSegmentWriter: expected error on dir fsync failure, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "fsync directory") {
|
||||||
|
t.Errorf("error should mention 'fsync directory', got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify cleanup: no segment file should remain.
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDir: %v", err)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if strings.Contains(name, "segment-0") {
|
||||||
|
t.Errorf("segment file should be cleaned up, found: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C6: ensure normal path still works after the fix.
|
||||||
|
func TestNewSegmentWriterNormalPathStillWorks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
|
||||||
|
sw, err := NewSegmentWriter(dir, 0, 0, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentWriter normal path: %v", err)
|
||||||
|
}
|
||||||
|
defer sw.Close()
|
||||||
|
|
||||||
|
// Verify segment file exists.
|
||||||
|
if _, err := os.Stat(sw.SegmentPath()); err != nil {
|
||||||
|
t.Errorf("segment file should exist: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B.3 验证错误传播
|
||||||
|
|
||||||
|
`wal/segment_manager_test.go` 已经测过 NewSegmentManager 正常路径。需补充测试验证错误传播。
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_48d53274)**:原计划的 `_, err = sm.AppendBatch(encoded)` 是编译错误(`AppendBatch` 只返回 `error`)。另外,单个 `{k,v}` batch 编码只有 ~24 字节,远不到 512-32=480 阈值,**不会触发轮转**。需要循环填到 `sm.RemainingPayload() < worstCaseSize` 再注入失败。
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Regression guard for C6: SegmentManager.AppendBatch must propagate
|
||||||
|
// rotation failure (which now includes dir fsync failure) as error.
|
||||||
|
func TestSegmentManagerRotateFailsOnDirFsyncFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := tinyWalConfig()
|
||||||
|
|
||||||
|
sm, err := NewSegmentManager(dir, 0, 0, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentManager: %v", err)
|
||||||
|
}
|
||||||
|
defer sm.Close()
|
||||||
|
|
||||||
|
encoded, err := EncodeWalBatch(0, []*WalEntry{
|
||||||
|
{OpType: OpPut, ValueKind: VKInline, Key: []byte("k"), Value: []byte("v")},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill the active segment until next AppendBatch would trigger rotation.
|
||||||
|
// segment_manager.go:62 triggers rotate when
|
||||||
|
// RemainingPayload() < len(encoded) + 2*PhysicalRecordHeaderSize
|
||||||
|
worstCaseSize := uint64(len(encoded)) + 2*uint64(PhysicalRecordHeaderSize)
|
||||||
|
for sm.RemainingPayload() >= worstCaseSize {
|
||||||
|
if err := sm.AppendBatch(encoded); err != nil {
|
||||||
|
t.Fatalf("fill AppendBatch: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject dir fsync failure for the next segment creation.
|
||||||
|
orig := dirFsyncFn
|
||||||
|
dirFsyncFn = func(string) error { return errors.New("simulated dir fsync failure") }
|
||||||
|
t.Cleanup(func() { dirFsyncFn = orig })
|
||||||
|
|
||||||
|
// Next AppendBatch must trigger rotation and fail with propagated error.
|
||||||
|
if err := sm.AppendBatch(encoded); err == nil {
|
||||||
|
t.Fatal("AppendBatch: expected rotation failure, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注意:C8 的存在让"填满 segment 触发轮转"的实际行为不可预测(segment_manager.go:64-66 把字节偏移当 startSequence)。这个测试主要验证 dir fsync 失败的传播路径,不验证轮转后的 recovery。如果测试在 C8 修复前运行不稳定,可放宽为"只需证明错误被传播",不验证后续 recovery。
|
||||||
|
|
||||||
|
#### B.4 验证 NewSegmentManager 路径 + 清理(Oracle 新增)
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_48d53274)**:原计划只覆盖了 NewSegmentWriter 单元层。需要单独覆盖 NewSegmentManager 包装路径(segment_manager.go:35-37),以及验证 dir fsync 失败后 retry 能成功(清理干净)。
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Regression guard for C6: NewSegmentManager must propagate dir fsync
|
||||||
|
// failure from initial segment creation. This is the DB.Open failure path.
|
||||||
|
func TestNewSegmentManagerFailsOnDirFsyncFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := tinyWalConfig()
|
||||||
|
|
||||||
|
orig := dirFsyncFn
|
||||||
|
dirFsyncFn = func(string) error { return errors.New("simulated dir fsync failure") }
|
||||||
|
t.Cleanup(func() { dirFsyncFn = orig })
|
||||||
|
|
||||||
|
sm, err := NewSegmentManager(dir, 0, 0, cfg)
|
||||||
|
if err == nil {
|
||||||
|
if sm != nil {
|
||||||
|
sm.Close()
|
||||||
|
}
|
||||||
|
t.Fatal("NewSegmentManager: expected error on dir fsync failure, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "create initial segment") {
|
||||||
|
t.Errorf("error should be wrapped as 'create initial segment', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C6: after a failed NewSegmentWriter due to dir
|
||||||
|
// fsync, retrying with fsync restored must succeed and not leak state
|
||||||
|
// (no leftover segment files).
|
||||||
|
func TestNewSegmentWriterRetryAfterDirFsyncFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
|
||||||
|
// First attempt: inject failure.
|
||||||
|
orig := dirFsyncFn
|
||||||
|
dirFsyncFn = func(string) error { return errors.New("simulated") }
|
||||||
|
_, err := NewSegmentWriter(dir, 0, 0, &cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected first NewSegmentWriter to fail")
|
||||||
|
}
|
||||||
|
dirFsyncFn = orig
|
||||||
|
|
||||||
|
// Second attempt: must succeed (cleanup was effective).
|
||||||
|
sw, err := NewSegmentWriter(dir, 0, 0, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("retry NewSegmentWriter: %v", err)
|
||||||
|
}
|
||||||
|
defer sw.Close()
|
||||||
|
|
||||||
|
// Verify no leftover .tmp files from the failed attempt.
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDir: %v", err)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.HasSuffix(e.Name(), ".tmp") {
|
||||||
|
t.Errorf("leftover .tmp file: %s", e.Name())
|
||||||
|
}
|
||||||
|
// segment-0.wal should exist (from successful retry).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase C:验证(15 分钟)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 编译通过
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
# 2. 重点测试(C6 新增的全部)
|
||||||
|
go test ./wal -run 'TestDirFsync|TestNewSegmentWriter|TestSegmentManagerRotateFailsOnDirFsyncFailure|TestNewSegmentManagerFailsOnDirFsyncFailure' -count=1 -v
|
||||||
|
|
||||||
|
# 3. wal 包全量
|
||||||
|
go test ./wal/... -count=1
|
||||||
|
|
||||||
|
# 4. 全仓
|
||||||
|
go test ./... -count=1
|
||||||
|
|
||||||
|
# 5. race 全仓(Oracle 修订:不只 wal + root,跑 ./...)
|
||||||
|
go test -race ./... -count=1
|
||||||
|
|
||||||
|
# 6. vet
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
# 7. lint(如果装了)
|
||||||
|
if command -v golangci-lint >/dev/null 2>&1; then
|
||||||
|
golangci-lint run ./wal/...
|
||||||
|
else
|
||||||
|
echo "golangci-lint not installed, skipping"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase D:Commit message draft
|
||||||
|
|
||||||
|
```
|
||||||
|
fix: make WAL segment directory fsync failure fatal (C6)
|
||||||
|
|
||||||
|
Per design §3.2 line 248-272, segment directory fsync is a hard
|
||||||
|
requirement for durable-ready state, not best-effort. rename is atomic
|
||||||
|
in memory but not guaranteed to survive power loss without a directory
|
||||||
|
fsync. The previous code silently swallowed both os.Open(dir) and
|
||||||
|
dirFD.Sync() errors, leaving WAL writer to confirm batches as durable
|
||||||
|
when their segment might not exist after a crash.
|
||||||
|
|
||||||
|
Failure propagation:
|
||||||
|
- Initial segment creation: NewSegmentWriter fails -> NewSegmentManager
|
||||||
|
fails -> DB.Open fails (user sees error, no data promise violated).
|
||||||
|
- Rotation during AppendBatch: NewSegmentWriter fails -> AppendBatch
|
||||||
|
fails -> WalWriter.stopWithError(ErrCommitUnknown) -> write-stopped
|
||||||
|
(per design line 272).
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- wal/segment_writer.go: extract dirFsync helper (Open -> f.Stat ->
|
||||||
|
IsDir -> f.Sync, avoiding TOCTOU window), replace silent swallow with
|
||||||
|
fatal error; on failure clean up resources (fd.Close + os.Remove) and
|
||||||
|
surface cleanup errors via errors.Join so nothing is silently lost.
|
||||||
|
- wal/dir_fsync_test.go (new): unit test the helper with valid dir,
|
||||||
|
non-existent dir (fails at os.Open), and not-a-dir (fails at IsDir).
|
||||||
|
- wal/segment_writer_test.go: add TestNewSegmentWriterDirFsyncFailure
|
||||||
|
(injects failure via package-level dirFsyncFn override; documents the
|
||||||
|
not-parallel-safe constraint), TestNewSegmentWriterNormalPathStillWorks
|
||||||
|
(regression), and TestNewSegmentWriterRetryAfterDirFsyncFailure
|
||||||
|
(verifies cleanup is effective for retry).
|
||||||
|
- wal/segment_manager_test.go: add TestSegmentManagerRotateFailsOnDirFsyncFailure
|
||||||
|
(fills segment until rotation triggers, injects failure, verifies
|
||||||
|
propagation through AppendBatch path) and TestNewSegmentManagerFailsOnDirFsyncFailure
|
||||||
|
(covers the DB.Open failure path).
|
||||||
|
|
||||||
|
dirFsyncFn injection note: tests that override this package-level var
|
||||||
|
must not use t.Parallel(). All existing wal tests run serially within
|
||||||
|
the package; this is the lightest mechanism that doesn't require
|
||||||
|
interface indirection in production code.
|
||||||
|
|
||||||
|
Verified: each new test fails on pre-fix code (silent swallow returned
|
||||||
|
nil error) and passes after the fix. Full suite green including
|
||||||
|
go test -race ./... .
|
||||||
|
|
||||||
|
Audit context: docs/audit-3.2.md C6 (Oracle-verified bg_ef425776).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收清单
|
||||||
|
|
||||||
|
- [ ] Phase A.1:`dirFsync` helper 存在,docstring 引用设计行号,注释 "NOT PARALLEL-SAFE"
|
||||||
|
- [ ] Phase A.2:`NewSegmentWriter` 调用 `dirFsyncFn`,失败时 `errors.Join` 合并 fd.Close + os.Remove 错误
|
||||||
|
- [ ] Phase B.1:3 个 helper 单元测试存在,错误期望正确("open dir" / "not a directory")
|
||||||
|
- [ ] Phase B.2:2 个 NewSegmentWriter 测试(failure + regression)存在
|
||||||
|
- [ ] Phase B.3:rotate 失败测试存在,编译正确,用循环填到 `RemainingPayload() < worstCaseSize`
|
||||||
|
- [ ] Phase B.4:NewSegmentManager 失败测试 + retry-after-failure 测试存在
|
||||||
|
- [ ] `go test ./wal/... -count=1` 全绿
|
||||||
|
- [ ] `go test ./... -count=1` 全绿
|
||||||
|
- [ ] `go test -race ./... -count=1` 全绿
|
||||||
|
- [ ] `go vet ./...` 无新增警告
|
||||||
|
- [ ] 单次 commit,message 引用 audit C6
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 不在本次范围内(后续 issue)
|
||||||
|
|
||||||
|
| 编号 | 为什么不放进来 |
|
||||||
|
|------|---------------|
|
||||||
|
| **C8** | segment_manager 把字节偏移当 startSequence,多 segment recovery 直接坏。和 C6 完全独立,但优先级也很高,可单独做 |
|
||||||
|
| C4 | 涉及 `RecoverFromSegments` 接口变更(isLast 参数),改动面更大 |
|
||||||
|
| C5 + H8 | 都在 truncation 路径,应一起做(fsync + 删空 segment + dir fsync + findValidOffset batch 边界) |
|
||||||
|
| C1 | CRC 多项式错,跨多个文件,需要测试向量 |
|
||||||
|
| C7 | Put/Close 并发竞态,独立 |
|
||||||
|
| H1-H7 | 其他 High 级别问题 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修订记录
|
||||||
|
|
||||||
|
- **v1(原始)**:C6 修复方案初稿,送 Momus 审
|
||||||
|
- **v1.1(Momus 修订 bg_90bcf359)**:
|
||||||
|
- helper 加 `os.Stat` + `IsDir` 校验,避免传文件路径误"成功"
|
||||||
|
- rotate 测试改用现成的 `tinyWalConfig()` helper(同时减 BlockSize + MaxBatchSize + MaxSegmentSize),避免单独设小 segment 触发 config.Validate 失败
|
||||||
|
- **v1.2(Oracle 修订 bg_48d53274)**:
|
||||||
|
- **BLOCKING**:helper 改为 `os.Open → f.Stat → IsDir → f.Sync` 顺序,消除 TOCTOU 窗口;同步修正 `TestDirFsyncNonExistentDir` 错误期望("open dir" 而非 "stat dir")
|
||||||
|
- **BLOCKING**:`TestDirFsyncNotADirectory` 缺 `os` import,补齐
|
||||||
|
- **BLOCKING**:`TestSegmentManagerRotateFailsOnDirFsyncFailure` 编译错(`_, err =` 应为 `err =`);单个 batch 不会触发轮转,改为循环填到 `RemainingPayload() < worstCaseSize`
|
||||||
|
- **NEW**:加 `TestNewSegmentManagerFailsOnDirFsyncFailure`(覆盖 NewSegmentManager→DB.Open 失败路径)
|
||||||
|
- **NEW**:加 `TestNewSegmentWriterRetryAfterDirFsyncFailure`(验证清理后 retry 能成功)
|
||||||
|
- **MINOR**:A.2 cleanup 用 `errors.Join` 把 close/remove 错误和主错误合并,不丢任何信息
|
||||||
|
- **MINOR**:`dirFsyncFn` 旁注释 "NOT PARALLEL-SAFE"
|
||||||
|
- **MINOR**:race 改成 `go test -race ./...`(全仓,不只 wal + root)
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
# C7 修复方案:Put/Delete vs Close 并发竞态
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
> **目标**:消除 Put/Delete 检查 `writeStopped` 和 `Submit` 之间的时间窗,防止 Close 并发关闭 channel 时 send-on-closed panic。
|
||||||
|
>
|
||||||
|
> **交付**:
|
||||||
|
> - `WalWriter` 加 `submitMu sync.RWMutex`
|
||||||
|
> - Put/Delete 持读锁保护 check+Submit 临界区
|
||||||
|
> - Close 持写锁保护 writeStopped+queue.Close
|
||||||
|
> - 2 个并发测试(Put+Close、Delete+Close)
|
||||||
|
> - 单次 commit
|
||||||
|
>
|
||||||
|
> **预估工时**:1.5-2 小时
|
||||||
|
> **风险**:低。改动局限在 `WalWriter` 一个文件,3 个函数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
### Bug 摘要
|
||||||
|
|
||||||
|
`wal/writer.go:313-326` Put(Delete 同理 328-341):
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (ww *WalWriter) Put(key, value []byte) error {
|
||||||
|
if ww.writeStopped.Load() { // ① 检查
|
||||||
|
return errkit.ErrWriteStopped
|
||||||
|
}
|
||||||
|
req := ww.queue.Submit(...) // ② Submit(内部 `cq.ch <- req`)
|
||||||
|
result := <-req.Result
|
||||||
|
return result.Err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`wal/writer.go:369-382` Close:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (ww *WalWriter) Close() error {
|
||||||
|
ww.closeOnce.Do(func() {
|
||||||
|
ww.writeStopped.Store(true) // A 设标志
|
||||||
|
ww.queue.Close() // B 关 channel
|
||||||
|
close(ww.done)
|
||||||
|
ww.wg.Wait()
|
||||||
|
...
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`wal/commit_queue.go:30-37` Submit:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (cq *CommitQueue) Submit(entries []*WalEntry) *CommitRequest {
|
||||||
|
req := &CommitRequest{...}
|
||||||
|
cq.ch <- req // ← send on possibly-closed channel
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`CommitQueue.Close` 的注释**自己写着** "Callers must stop submitting before Close" —— 但 Put/Delete 并没有保证这一点。
|
||||||
|
|
||||||
|
### 竞态场景
|
||||||
|
|
||||||
|
```
|
||||||
|
Goroutine 1 (Put) Goroutine 2 (Close)
|
||||||
|
──────────────── ────────────────────
|
||||||
|
writeStopped.Load() = false
|
||||||
|
writeStopped.Store(true)
|
||||||
|
queue.Close() → close(cq.ch)
|
||||||
|
queue.Submit():
|
||||||
|
cq.ch <- req ← PANIC
|
||||||
|
"send on closed channel"
|
||||||
|
```
|
||||||
|
|
||||||
|
时间窗:Put 的 ①→② 之间,Close 的 A→B 发生。
|
||||||
|
|
||||||
|
### 为什么没被抓到
|
||||||
|
|
||||||
|
现有测试串行调用 Put → Close,不模拟并发。要抓 C7 需要:
|
||||||
|
- 多 goroutine 并发 Put + 1 goroutine Close
|
||||||
|
- `defer recover` 捕获 panic
|
||||||
|
- 高并发 + 多次运行提高触发概率
|
||||||
|
|
||||||
|
属于"低概率高破坏"型 —— 平时不发作,发作就 panic 整个进程。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行计划
|
||||||
|
|
||||||
|
### Phase A:代码改动(30 分钟)
|
||||||
|
|
||||||
|
#### A.1 加 `submitMu sync.RWMutex` 到 WalWriter
|
||||||
|
|
||||||
|
```go
|
||||||
|
type WalWriter struct {
|
||||||
|
...
|
||||||
|
writeStopped atomic.Bool
|
||||||
|
submitMu sync.RWMutex // C7 fix: 保护 Submit vs Close 临界区
|
||||||
|
done chan struct{}
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### A.2 修改 Put
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 改后:
|
||||||
|
func (ww *WalWriter) Put(key, value []byte) error {
|
||||||
|
// Construct entry outside the lock to minimize critical section
|
||||||
|
// (Oracle nice-to-have: reduces contention under high concurrency).
|
||||||
|
entry := &WalEntry{
|
||||||
|
OpType: OpPut,
|
||||||
|
ValueKind: VKInline,
|
||||||
|
Key: cloneBytes(key),
|
||||||
|
Value: cloneBytes(value),
|
||||||
|
}
|
||||||
|
|
||||||
|
// C7 fix: RLock 保护 check + Submit 临界区。Close 持写锁时,
|
||||||
|
// 所有 RLock 释放后才能 queue.Close,保证 Submit 不会 send on closed channel。
|
||||||
|
ww.submitMu.RLock()
|
||||||
|
if ww.writeStopped.Load() {
|
||||||
|
ww.submitMu.RUnlock()
|
||||||
|
return errkit.ErrWriteStopped
|
||||||
|
}
|
||||||
|
req := ww.queue.Submit([]*WalEntry{entry})
|
||||||
|
ww.submitMu.RUnlock()
|
||||||
|
|
||||||
|
result := <-req.Result
|
||||||
|
return result.Err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键**:RLock 在 Submit 之后、`<-req.Result` 之前释放。这样:
|
||||||
|
- Submit 在锁保护内(防止 Close 并发关 channel)
|
||||||
|
- `<-req.Result` 在锁外(不阻塞其他 Put,不阻塞 Close 拿写锁)
|
||||||
|
|
||||||
|
#### A.3 修改 Delete
|
||||||
|
|
||||||
|
同 Put 模式。
|
||||||
|
|
||||||
|
#### A.4 修改 Close
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 改后:
|
||||||
|
func (ww *WalWriter) Close() error {
|
||||||
|
ww.closeOnce.Do(func() {
|
||||||
|
// C7 fix: 写锁等待所有 Put/Delete 的 RLock 释放。
|
||||||
|
// 持锁期间关 channel 是安全的:没有 Submit 在进行中。
|
||||||
|
ww.submitMu.Lock()
|
||||||
|
ww.writeStopped.Store(true)
|
||||||
|
ww.queue.Close()
|
||||||
|
ww.submitMu.Unlock()
|
||||||
|
|
||||||
|
close(ww.done)
|
||||||
|
ww.wg.Wait()
|
||||||
|
if err := ww.segManager.Sync(); err != nil {
|
||||||
|
ww.closeErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ww.closeErr = ww.segManager.Close()
|
||||||
|
})
|
||||||
|
return ww.closeErr
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键顺序**:
|
||||||
|
1. 写锁(等所有 in-flight Submit 完成)
|
||||||
|
2. `writeStopped = true`(新 Put 看到后返回错误)
|
||||||
|
3. `queue.Close`(此刻无 Submit 在进行,安全)
|
||||||
|
4. 写锁释放
|
||||||
|
5. `close(done)` + `wg.Wait`(runLoop 退出)
|
||||||
|
|
||||||
|
#### 死锁分析
|
||||||
|
|
||||||
|
| 场景 | 分析 |
|
||||||
|
|------|------|
|
||||||
|
| Put 持 RLock + Submit 阻塞(channel 满)| runLoop 持续消费(不持任何锁),Submit 最终完成,Put 释放 RLock |
|
||||||
|
| Close 等写锁 | 等 RLock 释放;RLock 释放依赖 Submit 完成;Submit 完成依赖 runLoop 消费;runLoop 不受锁影响 → 最终完成 |
|
||||||
|
| processBatch 失败后 writeStopped=true | 不影响 runLoop 继续消费 channel,不持任何锁,无死锁 |
|
||||||
|
|
||||||
|
**无死锁**。Close 可能因 Submit 阻塞而延迟(等 I/O),但最终完成。这是 graceful shutdown 的正确语义 —— **保留 backpressure,不创建锁环**。
|
||||||
|
|
||||||
|
### Phase B:测试(45 分钟)
|
||||||
|
|
||||||
|
#### B.1 TestConcurrentPutCloseNoPanic
|
||||||
|
|
||||||
|
新增到 `wal/writer_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Regression guard for C7: concurrent Put + Close must not panic.
|
||||||
|
// Buggy code has a race window between writeStopped check and Submit's
|
||||||
|
// send on channel; Close closing the channel during that window causes
|
||||||
|
// "send on closed channel" panic.
|
||||||
|
func TestConcurrentPutCloseNoPanic(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
ww, err := NewWalWriter(&cfg, dir, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goroutines = 100
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var panicCount atomic.Int32
|
||||||
|
start := make(chan struct{})
|
||||||
|
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
panicCount.Add(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
<-start
|
||||||
|
_ = ww.Put([]byte("k"), []byte("v"))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
_ = ww.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if panicCount.Load() > 0 {
|
||||||
|
t.Fatalf("concurrent Put + Close caused %d panic(s) — C7 regression",
|
||||||
|
panicCount.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B.2 TestConcurrentDeleteCloseNoPanic
|
||||||
|
|
||||||
|
同 B.1 但调 `ww.Delete([]byte("k"))`。
|
||||||
|
|
||||||
|
#### B.3 测试稳定性说明
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_96db47f5)**:测试定位是"fix 后**保证不 panic**",不是"fix 前**必 panic**"。buggy 代码的 race 窗口非常窄(Put check → Submit 之间几条指令),默认 queue capacity=10000,100 goroutines 通常不足以触发 channel 满阻塞。`-count=50` 能提高触发概率但不保证每次都抓到。
|
||||||
|
>
|
||||||
|
> 建议用 `go test -race ./wal -run TestConcurrent -count=10` 作为可选补充。确定性测试需要在 check 和 Submit 之间注入 test seam(过度侵入性,Phase 1 不值得)。
|
||||||
|
|
||||||
|
### Phase C:验证(15 分钟)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 编译
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
# 2. 并发测试(单次)
|
||||||
|
go test ./wal -run 'TestConcurrent' -count=1 -v
|
||||||
|
|
||||||
|
# 3. 并发测试(多次,抓 flaky race)
|
||||||
|
go test ./wal -run 'TestConcurrent' -count=50
|
||||||
|
|
||||||
|
# 4. wal 包全量
|
||||||
|
go test ./wal/... -count=1
|
||||||
|
|
||||||
|
# 5. 全仓
|
||||||
|
go test ./... -count=1
|
||||||
|
|
||||||
|
# 6. race
|
||||||
|
go test -race ./... -count=1
|
||||||
|
|
||||||
|
# 7. vet
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase D:Commit message draft
|
||||||
|
|
||||||
|
```
|
||||||
|
fix: prevent send-on-closed-channel panic in concurrent Put/Delete + Close (C7)
|
||||||
|
|
||||||
|
Put/Delete checked writeStopped then called queue.Submit (which sends on
|
||||||
|
a channel). Close set writeStopped then closed the channel. With no
|
||||||
|
synchronization between the check and Submit, a concurrent Close could
|
||||||
|
close the channel during the window, causing "send on closed channel"
|
||||||
|
panic in Put/Delete.
|
||||||
|
|
||||||
|
The CommitQueue.Close comment said "Callers must stop submitting before
|
||||||
|
Close" but Put/Delete didn't enforce this.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- wal/writer.go: add submitMu sync.RWMutex. Put/Delete hold RLock during
|
||||||
|
writeStopped check + Submit; release before <-req.Result. Close holds
|
||||||
|
write lock during writeStopped.Store + queue.Close, guaranteeing no
|
||||||
|
Submit is in progress when the channel is closed.
|
||||||
|
- wal/writer_test.go: add TestConcurrentPutCloseNoPanic and
|
||||||
|
TestConcurrentDeleteCloseNoPanic. 100 goroutines + 1 closer, recover
|
||||||
|
panics. On buggy code, this test triggers "send on closed channel"
|
||||||
|
panic (may require -count=N for reliable reproduction due to narrow
|
||||||
|
race window). On fixed code, never panics.
|
||||||
|
|
||||||
|
Verified: all existing tests pass. New concurrent tests pass with
|
||||||
|
-count=50. go test -race ./... green.
|
||||||
|
|
||||||
|
Audit context: docs/audit-3.2.md C7.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收清单
|
||||||
|
|
||||||
|
- [ ] Phase A.1:`submitMu sync.RWMutex` 字段存在
|
||||||
|
- [ ] Phase A.2:Put 在 RLock 内 check + Submit,RUnlock 后 `<-req.Result`
|
||||||
|
- [ ] Phase A.3:Delete 同 Put 模式
|
||||||
|
- [ ] Phase A.4:Close 在写锁内 writeStopped + queue.Close
|
||||||
|
- [ ] Phase B.1:`TestConcurrentPutCloseNoPanic` 存在
|
||||||
|
- [ ] Phase B.2:`TestConcurrentDeleteCloseNoPanic` 存在
|
||||||
|
- [ ] `go test ./wal/... -count=1` 全绿
|
||||||
|
- [ ] `go test ./wal -run TestConcurrent -count=50` 全绿
|
||||||
|
- [ ] `go test -race ./... -count=1` 全绿
|
||||||
|
- [ ] `go vet ./...` 无新增警告
|
||||||
|
- [ ] 单次 commit,message 引用 audit C7
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 不在本次范围内
|
||||||
|
|
||||||
|
| 项 | 为什么不放进来 |
|
||||||
|
|------|---------------|
|
||||||
|
| CommitQueue API 改动 | 不需要。RWMutex 在 WalWriter 层就够 |
|
||||||
|
| DB 层并发保护 | 不需要。WalWriter 的保护对 DB 层透明生效(db.Put 调 writer.Put,writer 的 submitMu 已经保护)|
|
||||||
|
| processBatch 失败后的 channel 状态 | 不影响。writeStopped 后 runLoop 继续消费,不关 channel |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修订记录
|
||||||
|
|
||||||
|
- **v1(原始)**:C7 修复方案初稿,送 Momus 审
|
||||||
|
- **v1.0(Momus 审核 bg_66fea405)**:[OKAY],无 blocking
|
||||||
|
- **v1.1(Oracle 审核 bg_96db47f5)**:**approve**(无 blocking)。3 个 nice-to-have 已采纳:
|
||||||
|
- 缩小临界区:WalEntry 构造移到 RLock 之前
|
||||||
|
- 死锁分析补充"保留 backpressure,不创建锁环"
|
||||||
|
- 测试定位澄清:是"fix 后保证不 panic",不是"fix 前必 panic"
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
# C8 修复方案:SegmentManager 轮转时传正确的 startSequence
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
> **目标**:让 SegmentManager 在 segment 轮转时把**真正的 sequence number** 写入新 segment header,而不是当前的 `CurrentOffset()`(字节偏移)。修复后 Phase 1 多 segment 场景的 recovery 能正常通过 `segment.startSequence == expectedSequence` 校验。
|
||||||
|
>
|
||||||
|
> **交付**:
|
||||||
|
> - `SegmentManager.AppendBatch` 加 `batchStartSequence uint64` 参数
|
||||||
|
> - `rotate` 调用从 `sm.active.CurrentOffset()` 改为 `batchStartSequence`
|
||||||
|
> - `WalWriter.processBatch` 传 `baseSequence` 给 AppendBatch
|
||||||
|
> - 6 个测试调用点 + 1 个 production 调用点签名更新
|
||||||
|
> - 2 个新测试:单元测试验证 segment header startSequence + e2e 多 segment recovery
|
||||||
|
> - 单次 commit
|
||||||
|
>
|
||||||
|
> **预估工时**:1.5-2 小时
|
||||||
|
> **风险**:低。改动局限在签名 + 一个调用值
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
### Bug 摘要
|
||||||
|
|
||||||
|
`wal/segment_manager.go:64-67`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if sm.active.RemainingPayload() < worstCaseSize {
|
||||||
|
if err := sm.rotate(sm.active.CurrentOffset()); err != nil { // ← bug
|
||||||
|
return fmt.Errorf("wal: rotate segment: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`CurrentOffset()` 返回字节偏移(从 `WalFileHeaderSize=32` 累加),不是 sequence number。`rotate(newStartSequence uint64)` 把这个值写入新 segment header 的 `startSequence` 字段。
|
||||||
|
|
||||||
|
**类型 mismatch 隐藏**:`rotate` 的参数叫 `newStartSequence`(uint64),`CurrentOffset()` 返回 uint64,编译器抓不到。
|
||||||
|
|
||||||
|
### 失败场景
|
||||||
|
|
||||||
|
```
|
||||||
|
segment-0: startSequence=0, 写入 100 batches (seq 0..99)
|
||||||
|
currentOffset = 32 + encoded_bytes (e.g. 50000)
|
||||||
|
触发轮转 → segment-1: header.startSequence = 50000 ← bug
|
||||||
|
写 batch seq 100..199 到 segment-1
|
||||||
|
|
||||||
|
重启 recovery:
|
||||||
|
segment-0: startSeq=0, replay 100 batches, nextSequence=100
|
||||||
|
segment-1: startSeq=50000, expected=100 → mismatch → ERROR
|
||||||
|
```
|
||||||
|
|
||||||
|
### Oracle 发现(bg_ef425776)
|
||||||
|
|
||||||
|
> C8 是隐藏炸弹:单 segment 时一切正常,第一次轮转后就坏。
|
||||||
|
|
||||||
|
### 设计依据
|
||||||
|
|
||||||
|
`docs/design.md` §3.2 line 639-663 Segment 连续性校验:
|
||||||
|
|
||||||
|
```text
|
||||||
|
for segment in recoverySegments:
|
||||||
|
require segment.segmentID == expectedSegmentID
|
||||||
|
require segment.startSequence == expectedSequence ← C8 在这失败
|
||||||
|
recover all complete batches in segment
|
||||||
|
expectedSegmentID += 1
|
||||||
|
expectedSequence = next sequence after last recovered batch
|
||||||
|
```
|
||||||
|
|
||||||
|
### 协同:C4 / C5+H8
|
||||||
|
|
||||||
|
- **C4 已修**:非尾段损坏硬错误
|
||||||
|
- **C5+H8 已修**:truncation 持久化 + batch-aware offset
|
||||||
|
- **C8 修复后**:多 segment recovery 能跑通 → C4/C5+H8 的多 segment 行为才能在生产中体现
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行计划
|
||||||
|
|
||||||
|
### Phase A:代码改动(15-30 分钟)
|
||||||
|
|
||||||
|
#### A.1 `SegmentManager.AppendBatch` 加 `batchStartSequence` 参数
|
||||||
|
|
||||||
|
文件:`wal/segment_manager.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 改前:
|
||||||
|
func (sm *SegmentManager) AppendBatch(encodedBatch []byte) error {
|
||||||
|
worstCaseSize := uint64(len(encodedBatch)) + uint64(PhysicalRecordHeaderSize) + uint64(PhysicalRecordHeaderSize)
|
||||||
|
if sm.active.RemainingPayload() < worstCaseSize {
|
||||||
|
if err := sm.rotate(sm.active.CurrentOffset()); err != nil { // ← bug
|
||||||
|
return fmt.Errorf("wal: rotate segment: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sm.active.AppendBatch(encodedBatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改后:
|
||||||
|
// AppendBatch writes encodedBatch to the active segment, rotating first if
|
||||||
|
// the batch doesn't fit. batchStartSequence is the sequence number of the
|
||||||
|
// FIRST entry in this batch — used as the new segment's startSequence when
|
||||||
|
// rotation occurs, so multi-segment recovery's continuity check passes per
|
||||||
|
// design §3.2 line 639-663.
|
||||||
|
func (sm *SegmentManager) AppendBatch(encodedBatch []byte, batchStartSequence uint64) error {
|
||||||
|
worstCaseSize := uint64(len(encodedBatch)) + uint64(PhysicalRecordHeaderSize) + uint64(PhysicalRecordHeaderSize)
|
||||||
|
if sm.active.RemainingPayload() < worstCaseSize {
|
||||||
|
// C8 fix: new segment's first batch is THIS batch, so its
|
||||||
|
// startSequence must equal batchStartSequence (not byte offset).
|
||||||
|
if err := sm.rotate(batchStartSequence); err != nil {
|
||||||
|
return fmt.Errorf("wal: rotate segment: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sm.active.AppendBatch(encodedBatch)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **必要 docstring**:解释 `batchStartSequence` 的用途(防止未来调用方传错值,类似当前 C8 bug)。属于 security-related 注释。
|
||||||
|
|
||||||
|
#### A.2 `WalWriter.processBatch` 传 `baseSequence`
|
||||||
|
|
||||||
|
文件:`wal/writer.go:210`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 改前:
|
||||||
|
if err := ww.segManager.AppendBatch(encoded); err != nil {
|
||||||
|
|
||||||
|
// 改后:
|
||||||
|
if err := ww.segManager.AppendBatch(encoded, baseSequence); err != nil {
|
||||||
|
```
|
||||||
|
|
||||||
|
`baseSequence` 在 processBatch 函数内已经通过 `seqManager.AllocateBatch` 分配(writer.go:197),直接传给 AppendBatch。
|
||||||
|
|
||||||
|
#### A.3 测试调用点更新
|
||||||
|
|
||||||
|
文件:`wal/segment_manager_test.go`(6 个调用点)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 改前(每个调用):
|
||||||
|
if err := sm.AppendBatch(batch); err != nil {
|
||||||
|
|
||||||
|
// 改后(每个调用,加 sequence 参数):
|
||||||
|
if err := sm.AppendBatch(batch, currentSeq); err != nil {
|
||||||
|
t.Fatalf("AppendBatch: %v", err)
|
||||||
|
}
|
||||||
|
currentSeq += uint64(len(batch.entries)) // 模拟 sequence 推进
|
||||||
|
```
|
||||||
|
|
||||||
|
需要为每个测试维护一个本地 `currentSeq` 变量,模拟 seqManager 的行为。具体修改见 B.3。
|
||||||
|
|
||||||
|
### Phase B:测试(60-90 分钟)
|
||||||
|
|
||||||
|
#### B.1 单元测试:segment header startSequence 正确性
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_282d3b17)BLOCKING**:原版循环跑 100 个 batch,每次轮转覆盖 `rotationSeq`。但 segment-1 的 header 是**第一次**轮转时创建的,多次轮转后断言会用错误的 sequence。修复:第一次轮转后就 break,记录 `firstRotationSeq`。同时加 `!= oldOffset` 断言显式抓 C8 regression。
|
||||||
|
|
||||||
|
新增到 `wal/segment_manager_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Regression guard for C8: after rotation, the new segment's header
|
||||||
|
// startSequence must equal the rotated batch's baseSequence, NOT the
|
||||||
|
// previous segment's byte offset.
|
||||||
|
func TestSegmentManagerRotationWritesCorrectStartSequence(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := tinyWalConfig()
|
||||||
|
|
||||||
|
sm, err := NewSegmentManager(dir, 0, 0, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentManager: %v", err)
|
||||||
|
}
|
||||||
|
defer sm.Close()
|
||||||
|
|
||||||
|
var firstRotationSeq uint64
|
||||||
|
var oldOffset uint64 // byte offset of segment-0 right before first rotation
|
||||||
|
var hadRotation bool
|
||||||
|
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
// Snapshot offset + segment ID BEFORE append to capture pre-rotation state.
|
||||||
|
offsetBefore := sm.active.CurrentOffset()
|
||||||
|
segIDBefore := sm.ActiveSegmentID()
|
||||||
|
|
||||||
|
encoded, err := EncodeWalBatch(uint64(i), []*WalEntry{
|
||||||
|
makePutEntry(fmt.Sprintf("k%d", i), "v"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if err := sm.AppendBatch(encoded, uint64(i)); err != nil {
|
||||||
|
t.Fatalf("AppendBatch %d: %v", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hadRotation && sm.ActiveSegmentID() != segIDBefore {
|
||||||
|
// First rotation just happened.
|
||||||
|
hadRotation = true
|
||||||
|
firstRotationSeq = uint64(i)
|
||||||
|
oldOffset = offsetBefore
|
||||||
|
break // Stop at first rotation to avoid overwriting.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hadRotation {
|
||||||
|
t.Skip("no rotation occurred with tinyWalConfig; test setup needs adjustment")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read segment-1 header and verify startSequence.
|
||||||
|
seg1Path := filepath.Join(dir, "segment-1.wal")
|
||||||
|
if _, err := os.Stat(seg1Path); err != nil {
|
||||||
|
t.Fatalf("segment-1 should exist after rotation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seg1Header := readSegmentHeader(t, seg1Path)
|
||||||
|
if seg1Header.StartSequence != firstRotationSeq {
|
||||||
|
t.Errorf("segment-1 startSequence = %d, want %d (first rotation sequence)",
|
||||||
|
seg1Header.StartSequence, firstRotationSeq)
|
||||||
|
}
|
||||||
|
// Explicit C8 regression check: buggy code would set startSequence = oldOffset.
|
||||||
|
if seg1Header.StartSequence == oldOffset {
|
||||||
|
t.Errorf("segment-1 startSequence = %d matches old byte offset (C8 regression)",
|
||||||
|
seg1Header.StartSequence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSegmentHeader(t *testing.T, path string) *WalFileHeader {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open %s: %v", path, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
hdrBuf := make([]byte, WalFileHeaderSize)
|
||||||
|
if _, err := io.ReadFull(f, hdrBuf); err != nil {
|
||||||
|
t.Fatalf("read header: %v", err)
|
||||||
|
}
|
||||||
|
hdr, err := DecodeWalHeader(hdrBuf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeWalHeader: %v", err)
|
||||||
|
}
|
||||||
|
return hdr
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B.2 集成测试:多 segment recovery 端到端
|
||||||
|
|
||||||
|
新增到 `wal/segment_manager_test.go` 或 `db_test.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Regression guard for C8: writing across multiple segments, then closing
|
||||||
|
// and reopening, must successfully recover ALL data. Before C8 fix, the
|
||||||
|
// second segment's startSequence was a byte offset, causing recovery's
|
||||||
|
// continuity check to fail.
|
||||||
|
func TestSegmentManagerMultiSegmentRecoveryRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := tinyWalConfig()
|
||||||
|
|
||||||
|
sm, err := NewSegmentManager(dir, 0, 0, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentManager: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write enough batches to force rotation.
|
||||||
|
const totalBatches = 50
|
||||||
|
for i := 0; i < totalBatches; i++ {
|
||||||
|
encoded, err := EncodeWalBatch(uint64(i), []*WalEntry{
|
||||||
|
makePutEntry(fmt.Sprintf("k%d", i), fmt.Sprintf("v%d", i)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if err := sm.AppendBatch(encoded, uint64(i)); err != nil {
|
||||||
|
t.Fatalf("AppendBatch %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sm.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify multiple segments were created.
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
segCount := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
if _, ok := ParseSegmentFilename(e.Name()); ok {
|
||||||
|
segCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if segCount < 2 {
|
||||||
|
t.Fatalf("expected at least 2 segments after rotation, got %d", segCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recover and verify all batches replay.
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
result, err := Recover(dir, replayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover failed (C8 regression): %v", err)
|
||||||
|
}
|
||||||
|
if result.NextSequence != totalBatches {
|
||||||
|
t.Errorf("NextSequence = %d, want %d", result.NextSequence, totalBatches)
|
||||||
|
}
|
||||||
|
if len(replayer.puts) != totalBatches {
|
||||||
|
t.Errorf("replayed puts = %d, want %d", len(replayer.puts), totalBatches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B.3 现有测试调用点签名更新
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_282d3b17)**:原计划写 `currentSeq += uint64(len(batch.entries))` 是错的,因为现有 `segment_manager_test.go` 的调用点用的是 dummy `[]byte`(不是带 entries 的 batch struct)。改成"每次 append 后 currentSeq++"(假设每个 batch 1 entry)。
|
||||||
|
|
||||||
|
`wal/segment_manager_test.go` 的 6 个调用点:
|
||||||
|
|
||||||
|
- Line 84, 119, 132, 180, 295, 304
|
||||||
|
|
||||||
|
每个调用点的 `batchStartSequence` 值需要根据测试上下文确定。大多数测试用 `startSequence=0`,所以维护一个本地 `currentSeq := uint64(0)`,每次 append 后 `currentSeq++`(假设每个 batch 1 entry)。具体修改在执行阶段读上下文做。
|
||||||
|
|
||||||
|
### Phase C:验证(15 分钟)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 编译
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
# 2. 重点测试
|
||||||
|
go test ./wal -run 'TestSegmentManagerRotation|TestSegmentManagerMultiSegment|TestSegmentManager' -count=1 -v
|
||||||
|
|
||||||
|
# 3. wal 包全量
|
||||||
|
go test ./wal/... -count=1
|
||||||
|
|
||||||
|
# 4. 全仓
|
||||||
|
go test ./... -count=1
|
||||||
|
|
||||||
|
# 5. race
|
||||||
|
go test -race ./... -count=1
|
||||||
|
|
||||||
|
# 6. vet
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase D:Commit message draft
|
||||||
|
|
||||||
|
```
|
||||||
|
fix: write correct startSequence on segment rotation (C8)
|
||||||
|
|
||||||
|
SegmentManager.AppendBatch was passing sm.active.CurrentOffset() (byte
|
||||||
|
offset from file header) as the new segment's startSequence on rotation.
|
||||||
|
The result: segment-N+1's header.startSequence was a byte count (e.g.
|
||||||
|
50000), not the actual sequence number. Recovery's continuity check at
|
||||||
|
recovery.go:181-184 (segment.StartSequence != expectedSequence) failed,
|
||||||
|
making Phase 1 multi-segment recovery completely broken.
|
||||||
|
|
||||||
|
Oracle bg_ef425776 noted: "C8 是隐藏炸弹:单 segment 时一切正常,
|
||||||
|
第一次轮转后就坏".
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- wal/segment_manager.go: AppendBatch now takes batchStartSequence uint64
|
||||||
|
parameter. On rotation, passes it to rotate (which writes it to the new
|
||||||
|
segment's header.startSequence). The previous byte-offset argument is
|
||||||
|
replaced by the actual sequence number.
|
||||||
|
- wal/writer.go: processBatch passes baseSequence (already allocated by
|
||||||
|
seqManager.AllocateBatch) to AppendBatch.
|
||||||
|
- wal/segment_manager_test.go: 6 existing AppendBatch call sites updated
|
||||||
|
to pass batchStartSequence. Added 2 new tests:
|
||||||
|
- TestSegmentManagerRotationWritesCorrectStartSequence: verifies new
|
||||||
|
segment's header.startSequence matches the first rotated batch's
|
||||||
|
sequence (and explicitly != old byte offset, catching C8 regression).
|
||||||
|
- TestSegmentManagerMultiSegmentRecoveryRoundTrip: end-to-end test that
|
||||||
|
writes across multiple segments, closes, recovers, and verifies all
|
||||||
|
batches replay. Before C8 fix, recovery failed at continuity check.
|
||||||
|
|
||||||
|
Verified: each new test fails on pre-fix code (segment-1 startSequence
|
||||||
|
is byte offset, recovery fails) and passes after the fix. Full suite
|
||||||
|
green including go test -race ./... .
|
||||||
|
|
||||||
|
Audit context: docs/audit-3.2.md C8 (Oracle-discovered bg_2e86d33b).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收清单
|
||||||
|
|
||||||
|
- [ ] Phase A.1:`AppendBatch(encodedBatch []byte, batchStartSequence uint64)` 签名存在
|
||||||
|
- [ ] Phase A.1:`rotate` 调用从 `CurrentOffset()` 改为 `batchStartSequence`
|
||||||
|
- [ ] Phase A.2:`writer.go:210` 传 `baseSequence`
|
||||||
|
- [ ] Phase A.3:6 个测试调用点全部更新
|
||||||
|
- [ ] Phase B.1:`TestSegmentManagerRotationWritesCorrectStartSequence` 存在
|
||||||
|
- [ ] Phase B.2:`TestSegmentManagerMultiSegmentRecoveryRoundTrip` 存在
|
||||||
|
- [ ] `go test ./wal/... -count=1` 全绿
|
||||||
|
- [ ] `go test ./... -count=1` 全绿
|
||||||
|
- [ ] `go test -race ./... -count=1` 全绿
|
||||||
|
- [ ] `go vet ./...` 无新增警告
|
||||||
|
- [ ] 单次 commit,message 引用 audit C8
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 不在本次范围内(后续 issue)
|
||||||
|
|
||||||
|
| 编号 | 为什么不放进来 |
|
||||||
|
|------|---------------|
|
||||||
|
| C1 | CRC 多项式 IEEE → crc32c,独立 |
|
||||||
|
| C7 | Put/Close 竞态,独立 |
|
||||||
|
| H1-H7 | 其他 High,独立 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修订记录
|
||||||
|
|
||||||
|
- **v1(原始)**:C8 修复方案初稿,送 Momus 审
|
||||||
|
- **v1.0(Momus 审核 bg_22b882bf)**:[OKAY],无 blocking。提醒 B.1 测试需要 `fmt` import(执行时补)
|
||||||
|
- **v1.1(Oracle 修订 bg_282d3b17)**:
|
||||||
|
- **BLOCKING**:B.1 测试逻辑错 —— 循环跑 100 个 batch,每次轮转覆盖 `rotationSeq`,但 segment-1 的 header 是第一次轮转时创建的。修复:第一次轮转后 break,记录 `firstRotationSeq`
|
||||||
|
- **NEW**:B.1 加 `!= oldOffset` 断言,显式抓 C8 regression(buggy 代码下值是字节偏移)
|
||||||
|
- **FIX**:A.3 的 `currentSeq += len(batch.entries)` 误导(现有测试用 dummy `[]byte`),改成"每次 append 后 currentSeq++"
|
||||||
|
- **FIX**:commit message 行号 `recovery.go:155` 过期(C4 fix 后行号变了),改成 `recovery.go:181-184`
|
||||||
|
- **MINOR**:`readSegmentHeader` 用 `io.ReadFull` 而不是 `f.Read`(避免 short read)
|
||||||
|
- **NOTE**:B.2 测试必须留在 wal 包(用 `mockReplayer`,是包内部)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_1465a5321ffevd4inJJZXxFenJ",
|
||||||
|
"updatedAt": "2026-06-12T02:26:27.295Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-12T02:26:27.295Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_1468f0cc5ffew53IA0I4W6HijM",
|
||||||
|
"updatedAt": "2026-06-12T01:41:18.251Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-12T01:41:18.251Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_1469860f1ffeAxT7jbUD3T1ZK5",
|
||||||
|
"updatedAt": "2026-06-12T01:18:59.508Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-12T01:18:59.508Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_146adbaa4ffehxje0lmKRg4ZpT",
|
||||||
|
"updatedAt": "2026-06-12T00:56:53.921Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-12T00:56:53.921Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_146b864adffetMjZkc08p16onz",
|
||||||
|
"updatedAt": "2026-06-12T00:44:22.791Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-12T00:44:22.791Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_146c330caffehDbiMQ7M0jL3Il",
|
||||||
|
"updatedAt": "2026-06-12T00:31:46.022Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-12T00:31:46.022Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_149585098ffeVZ53676PRrr2ic",
|
||||||
|
"updatedAt": "2026-06-11T12:31:44.016Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-11T12:31:44.016Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_14960b98effe7YjeN56yvxsoN3",
|
||||||
|
"updatedAt": "2026-06-11T12:21:18.579Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-11T12:21:18.579Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_14967e191ffeLfQYyfTQHP5TlH",
|
||||||
|
"updatedAt": "2026-06-11T12:12:33.047Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-11T12:12:33.047Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_149a13a61ffehpTGChldqMe0do",
|
||||||
|
"updatedAt": "2026-06-11T11:11:39.805Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-11T11:11:39.805Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_149e4f0c5ffe5hf5hErOGwmaO7",
|
||||||
|
"updatedAt": "2026-06-11T10:01:28.529Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-11T10:01:28.529Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_149f503d8ffeCitCeNNhuI5uPD",
|
||||||
|
"updatedAt": "2026-06-11T09:38:47.486Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-11T09:38:47.486Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_1551b5cb8ffeRq3O3tTE6gADNa",
|
||||||
|
"updatedAt": "2026-06-09T05:49:57.317Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-09T05:49:57.317Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_15535520fffenpCk4v55tlxfPN",
|
||||||
|
"updatedAt": "2026-06-09T05:14:00.942Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-09T05:14:00.942Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_155c694ebffeUxfvz2X0kcpMQz",
|
||||||
|
"updatedAt": "2026-06-09T03:20:21.146Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-09T03:20:21.146Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_162be27e5ffePD7UoGttrWFm7T",
|
||||||
|
"updatedAt": "2026-06-06T14:13:39.321Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-06T14:13:39.321Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_16f5a4050ffe4VOJm8EOoPcnZV",
|
||||||
|
"updatedAt": "2026-06-04T03:24:44.020Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-04T03:24:44.020Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_16f63a221ffew3fp5TM11NaRDD",
|
||||||
|
"updatedAt": "2026-06-04T03:18:04.363Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-04T03:18:04.363Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_17337fda2fferaZJ6tpXI2Cw44",
|
||||||
|
"updatedAt": "2026-06-04T03:25:22.600Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-04T03:25:22.600Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sessionID": "ses_1738cec10ffex8teaUh336WrCD",
|
||||||
|
"updatedAt": "2026-06-03T08:41:49.094Z",
|
||||||
|
"sources": {
|
||||||
|
"background-task": {
|
||||||
|
"state": "idle",
|
||||||
|
"updatedAt": "2026-06-03T08:41:49.094Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
package go_kv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openBenchDB opens a fresh database in b.TempDir() with default config.
|
||||||
|
// Returns the DB handle; caller must Close().
|
||||||
|
func openBenchDB(b *testing.B) *DB {
|
||||||
|
b.Helper()
|
||||||
|
db, err := Open(b.TempDir(), nil)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkSinglePut measures single-threaded sequential Put throughput.
|
||||||
|
func BenchmarkSinglePut(b *testing.B) {
|
||||||
|
db := openBenchDB(b)
|
||||||
|
defer func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
b.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for b.Loop() {
|
||||||
|
key := fmt.Appendf(nil, "key-%08d", i)
|
||||||
|
val := fmt.Appendf(nil, "value-%08d", i)
|
||||||
|
if err := db.Put(key, val); err != nil {
|
||||||
|
b.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkConcurrentPut measures multi-goroutine Put throughput.
|
||||||
|
// Uses b.N directly because b.Loop() cannot be called from spawned goroutines.
|
||||||
|
func BenchmarkConcurrentPut(b *testing.B) {
|
||||||
|
for _, workers := range []int{2, 4, 8} {
|
||||||
|
b.Run(fmt.Sprintf("workers=%d", workers), func(b *testing.B) {
|
||||||
|
db := openBenchDB(b)
|
||||||
|
defer func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
b.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
opsPerWorker := b.N / workers
|
||||||
|
for w := range workers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(workerID int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := range opsPerWorker {
|
||||||
|
key := fmt.Appendf(nil, "key-%d-%08d", workerID, i)
|
||||||
|
val := fmt.Appendf(nil, "value-%d-%08d", workerID, i)
|
||||||
|
if err := db.Put(key, val); err != nil {
|
||||||
|
b.Errorf("Put: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(w)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkGet measures Get latency after pre-loading 1000 keys.
|
||||||
|
func BenchmarkGet(b *testing.B) {
|
||||||
|
const numKeys = 1000
|
||||||
|
|
||||||
|
db := openBenchDB(b)
|
||||||
|
defer func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
b.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Pre-load keys.
|
||||||
|
for i := range numKeys {
|
||||||
|
key := fmt.Appendf(nil, "key-%08d", i)
|
||||||
|
val := fmt.Appendf(nil, "value-%08d", i)
|
||||||
|
if err := db.Put(key, val); err != nil {
|
||||||
|
b.Fatalf("Put preload: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for b.Loop() {
|
||||||
|
key := fmt.Appendf(nil, "key-%08d", i%numKeys)
|
||||||
|
res := db.Get(key)
|
||||||
|
if !res.Found {
|
||||||
|
b.Fatalf("Get key %s: not found", key)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkConcurrentGet measures parallel Get after pre-loading keys.
|
||||||
|
func BenchmarkConcurrentGet(b *testing.B) {
|
||||||
|
const numKeys = 1000
|
||||||
|
|
||||||
|
db := openBenchDB(b)
|
||||||
|
defer func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
b.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Pre-load keys.
|
||||||
|
for i := range numKeys {
|
||||||
|
key := fmt.Appendf(nil, "key-%08d", i)
|
||||||
|
val := fmt.Appendf(nil, "value-%08d", i)
|
||||||
|
if err := db.Put(key, val); err != nil {
|
||||||
|
b.Fatalf("Put preload: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
|
||||||
|
b.RunParallel(func(pb *testing.PB) {
|
||||||
|
i := 0
|
||||||
|
for pb.Next() {
|
||||||
|
key := fmt.Appendf(nil, "key-%08d", i%numKeys)
|
||||||
|
res := db.Get(key)
|
||||||
|
if !res.Found {
|
||||||
|
b.Fatalf("Get key %s: not found", key)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkMixedReadWrite measures a 50% Put + 50% Get workload mix.
|
||||||
|
func BenchmarkMixedReadWrite(b *testing.B) {
|
||||||
|
const numKeys = 1000
|
||||||
|
|
||||||
|
db := openBenchDB(b)
|
||||||
|
defer func() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
b.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Pre-load half the keys so Gets are not all misses.
|
||||||
|
for i := range numKeys / 2 {
|
||||||
|
key := fmt.Appendf(nil, "key-%08d", i)
|
||||||
|
val := fmt.Appendf(nil, "value-%08d", i)
|
||||||
|
if err := db.Put(key, val); err != nil {
|
||||||
|
b.Fatalf("Put preload: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for b.Loop() {
|
||||||
|
key := fmt.Appendf(nil, "key-%08d", i%numKeys)
|
||||||
|
val := fmt.Appendf(nil, "value-%08d", i%numKeys)
|
||||||
|
if i%2 == 0 {
|
||||||
|
if err := db.Put(key, val); err != nil {
|
||||||
|
b.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_ = db.Get(key)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkWALRecovery measures recovery (Open) time for different data sizes.
|
||||||
|
func BenchmarkWALRecovery(b *testing.B) {
|
||||||
|
for _, nKeys := range []int{100, 1000, 10000} {
|
||||||
|
b.Run(fmt.Sprintf("keys=%d", nKeys), func(b *testing.B) {
|
||||||
|
dir := b.TempDir()
|
||||||
|
|
||||||
|
// Write N keys to populate WAL segments.
|
||||||
|
func() {
|
||||||
|
db, err := Open(dir, nil)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("Open for write: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
for i := range nKeys {
|
||||||
|
key := fmt.Appendf(nil, "key-%08d", i)
|
||||||
|
val := fmt.Appendf(nil, "value-%08d", i)
|
||||||
|
if err := db.Put(key, val); err != nil {
|
||||||
|
b.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Now benchmark only the recovery (Open) phase.
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
|
||||||
|
for b.Loop() {
|
||||||
|
db, err := Open(dir, nil)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("Open recovery: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
b.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
// Package config defines configuration types and validation for the go-kv storage engine.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WAL format constants derived from the binary layout specification.
|
||||||
|
const (
|
||||||
|
walFileHeaderSize uint64 = 32
|
||||||
|
physicalRecordHeaderSize uint64 = 7
|
||||||
|
walBatchHeaderSize uint64 = 18 // flags(2) + baseSequence(8) + entryCount(4) + entriesSize(4)
|
||||||
|
)
|
||||||
|
|
||||||
|
// WalConfig holds configuration for the Write-Ahead Log subsystem.
|
||||||
|
// Zero-value WalConfig is valid and uses defaults; call Validate() to apply
|
||||||
|
// defaults and verify invariants.
|
||||||
|
type WalConfig struct {
|
||||||
|
// MaxSegmentSize is the maximum size of a single WAL segment file in bytes.
|
||||||
|
// Default: 64MB. Must be large enough to hold the largest possible WAL Batch.
|
||||||
|
MaxSegmentSize uint64
|
||||||
|
|
||||||
|
// BlockSize is the WAL block size in bytes.
|
||||||
|
// Default: 32KB.
|
||||||
|
BlockSize uint32
|
||||||
|
|
||||||
|
// SyncMode controls when WAL writes are flushed to disk.
|
||||||
|
// Phase 1 only supports "always".
|
||||||
|
SyncMode string
|
||||||
|
|
||||||
|
// MaxBatchEntries is the maximum number of entries in a single WAL Batch.
|
||||||
|
// Default: 10000.
|
||||||
|
MaxBatchEntries uint32
|
||||||
|
|
||||||
|
// MaxBatchSize is the maximum total size of WAL Batch entries in bytes.
|
||||||
|
// Default: 4MB.
|
||||||
|
MaxBatchSize uint32
|
||||||
|
|
||||||
|
// GroupCommitDelay is the maximum time to collect requests before writing a
|
||||||
|
// WAL group commit batch. Default: 500µs. Must be > 0 and < 10ms.
|
||||||
|
GroupCommitDelay time.Duration
|
||||||
|
|
||||||
|
// MaxKeyBytes is the maximum size of a single key in bytes.
|
||||||
|
// Default: 4KB.
|
||||||
|
MaxKeyBytes uint32
|
||||||
|
|
||||||
|
// MaxInlineValue is the maximum size of an inline value in bytes.
|
||||||
|
// Values larger than this must use ValueLogPointer.
|
||||||
|
// Default: 4KB.
|
||||||
|
MaxInlineValue uint32
|
||||||
|
|
||||||
|
// MemTableSize is the target MemTable size in bytes before triggering flush.
|
||||||
|
// Default: 64MB.
|
||||||
|
MemTableSize uint32
|
||||||
|
|
||||||
|
// MaxImmutableCount is the maximum number of immutable MemTables allowed
|
||||||
|
// before writes are stalled. Default: 3.
|
||||||
|
MaxImmutableCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults returns a WalConfig populated with production defaults.
|
||||||
|
func Defaults() WalConfig {
|
||||||
|
return WalConfig{
|
||||||
|
MaxSegmentSize: 64 * 1024 * 1024, // 64MB
|
||||||
|
BlockSize: 32 * 1024, // 32KB
|
||||||
|
SyncMode: "always",
|
||||||
|
MaxBatchEntries: 10000,
|
||||||
|
MaxBatchSize: 4 * 1024 * 1024, // 4MB
|
||||||
|
GroupCommitDelay: 500 * time.Microsecond,
|
||||||
|
MaxKeyBytes: 4 * 1024, // 4KB
|
||||||
|
MaxInlineValue: 4 * 1024, // 4KB
|
||||||
|
MemTableSize: 64 * 1024 * 1024, // 64MB
|
||||||
|
MaxImmutableCount: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDefaults fills zero-valued fields with production defaults.
|
||||||
|
func (c *WalConfig) applyDefaults() {
|
||||||
|
d := Defaults()
|
||||||
|
if c.MaxSegmentSize == 0 {
|
||||||
|
c.MaxSegmentSize = d.MaxSegmentSize
|
||||||
|
}
|
||||||
|
if c.BlockSize == 0 {
|
||||||
|
c.BlockSize = d.BlockSize
|
||||||
|
}
|
||||||
|
if c.SyncMode == "" {
|
||||||
|
c.SyncMode = d.SyncMode
|
||||||
|
}
|
||||||
|
if c.MaxBatchEntries == 0 {
|
||||||
|
c.MaxBatchEntries = d.MaxBatchEntries
|
||||||
|
}
|
||||||
|
if c.MaxBatchSize == 0 {
|
||||||
|
c.MaxBatchSize = d.MaxBatchSize
|
||||||
|
}
|
||||||
|
if c.GroupCommitDelay == 0 {
|
||||||
|
c.GroupCommitDelay = d.GroupCommitDelay
|
||||||
|
}
|
||||||
|
if c.MaxKeyBytes == 0 {
|
||||||
|
c.MaxKeyBytes = d.MaxKeyBytes
|
||||||
|
}
|
||||||
|
if c.MaxInlineValue == 0 {
|
||||||
|
c.MaxInlineValue = d.MaxInlineValue
|
||||||
|
}
|
||||||
|
if c.MemTableSize == 0 {
|
||||||
|
c.MemTableSize = d.MemTableSize
|
||||||
|
}
|
||||||
|
if c.MaxImmutableCount == 0 {
|
||||||
|
c.MaxImmutableCount = d.MaxImmutableCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate applies defaults and verifies that all configuration invariants hold.
|
||||||
|
// The key invariant ensures that the largest possible WAL Batch can fit into
|
||||||
|
// an empty WAL segment:
|
||||||
|
//
|
||||||
|
// maxWalSegmentPayload >= maxEncodedWalBatchSize + worstCasePhysicalRecordOverhead + worstCaseBlockPadding
|
||||||
|
//
|
||||||
|
// All arithmetic is checked for overflow.
|
||||||
|
func (c *WalConfig) Validate() error {
|
||||||
|
c.applyDefaults()
|
||||||
|
|
||||||
|
if c.SyncMode != "always" {
|
||||||
|
return fmt.Errorf("config: SyncMode %q not supported (Phase 1: only \"always\")", c.SyncMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.MaxImmutableCount < 1 {
|
||||||
|
return fmt.Errorf("config: MaxImmutableCount must be >= 1, got %d", c.MaxImmutableCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.GroupCommitDelay <= 0 || c.GroupCommitDelay >= 10*time.Millisecond {
|
||||||
|
return fmt.Errorf("config: GroupCommitDelay must be > 0 and < 10ms, got %s", c.GroupCommitDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Checked arithmetic invariant validation ---
|
||||||
|
// Mirrors the derivation in docs/design.md § WAL Segment Rotation.
|
||||||
|
|
||||||
|
blockSize := uint64(c.BlockSize)
|
||||||
|
prHeaderSize := physicalRecordHeaderSize
|
||||||
|
batchHeaderSize := walBatchHeaderSize
|
||||||
|
maxBatchEntriesSize := uint64(c.MaxBatchSize)
|
||||||
|
|
||||||
|
// maxEncodedWalBatchSize = batchHeaderSize + maxBatchEntriesSize
|
||||||
|
maxEncodedWalBatchSize, err := safeAdd(batchHeaderSize, maxBatchEntriesSize)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config: WAL batch size overflow: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxPhysicalRecordPayload = blockSize - prHeaderSize
|
||||||
|
if blockSize <= prHeaderSize {
|
||||||
|
return fmt.Errorf("config: BlockSize %d must be > physical record header size %d", blockSize, prHeaderSize)
|
||||||
|
}
|
||||||
|
maxPhysicalRecordPayload := blockSize - prHeaderSize
|
||||||
|
|
||||||
|
// maxPhysicalRecordCount = ceil(maxEncodedWalBatchSize / maxPhysicalRecordPayload)
|
||||||
|
maxPhysicalRecordCount := divCeil(maxEncodedWalBatchSize, maxPhysicalRecordPayload)
|
||||||
|
|
||||||
|
// worstCasePhysicalRecordOverhead = maxPhysicalRecordCount * prHeaderSize
|
||||||
|
worstCasePhysicalRecordOverhead, err := safeMul(maxPhysicalRecordCount, prHeaderSize)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config: physical record overhead overflow: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// worstCaseBlockPadding = blockSize - 1 (at most one partial block of padding)
|
||||||
|
// From design doc: worstCaseBlockPadding = 7 bytes with default block size.
|
||||||
|
// Generalized: blockSize - maxPhysicalRecordPayload = prHeaderSize
|
||||||
|
worstCaseBlockPadding := prHeaderSize
|
||||||
|
|
||||||
|
// minWalSegmentPayload = maxEncodedWalBatchSize + worstCasePhysicalRecordOverhead + worstCaseBlockPadding
|
||||||
|
partial, err := safeAdd(maxEncodedWalBatchSize, worstCasePhysicalRecordOverhead)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config: segment payload calculation overflow: %w", err)
|
||||||
|
}
|
||||||
|
minWalSegmentPayload, err := safeAdd(partial, worstCaseBlockPadding)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config: segment payload calculation overflow: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxWalSegmentPayload = MaxSegmentSize - walFileHeaderSize
|
||||||
|
if c.MaxSegmentSize <= walFileHeaderSize {
|
||||||
|
return fmt.Errorf("config: MaxSegmentSize %d must be > WAL file header size %d",
|
||||||
|
c.MaxSegmentSize, walFileHeaderSize)
|
||||||
|
}
|
||||||
|
maxWalSegmentPayload := c.MaxSegmentSize - walFileHeaderSize
|
||||||
|
|
||||||
|
if maxWalSegmentPayload < minWalSegmentPayload {
|
||||||
|
return fmt.Errorf("config: MaxSegmentSize %d too small: "+
|
||||||
|
"segment payload (%d) < minimum required (%d); "+
|
||||||
|
"need MaxSegmentSize >= %d",
|
||||||
|
c.MaxSegmentSize, maxWalSegmentPayload, minWalSegmentPayload,
|
||||||
|
minWalSegmentPayload+walFileHeaderSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeAdd returns a + b or an error if the result overflows uint64.
|
||||||
|
func safeAdd(a, b uint64) (uint64, error) {
|
||||||
|
if a > math.MaxUint64-b {
|
||||||
|
return 0, fmt.Errorf("uint64 overflow: %d + %d", a, b)
|
||||||
|
}
|
||||||
|
return a + b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeMul returns a * b or an error if the result overflows uint64.
|
||||||
|
func safeMul(a, b uint64) (uint64, error) {
|
||||||
|
if a != 0 && b > math.MaxUint64/a {
|
||||||
|
return 0, fmt.Errorf("uint64 overflow: %d * %d", a, b)
|
||||||
|
}
|
||||||
|
return a * b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// divCeil returns ceil(a / b) for b > 0.
|
||||||
|
func divCeil(a, b uint64) uint64 {
|
||||||
|
return (a + b - 1) / b
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateDefaults(t *testing.T) {
|
||||||
|
cfg := WalConfig{}
|
||||||
|
err := cfg.Validate()
|
||||||
|
require.NoError(t, err, "default WalConfig should pass validation")
|
||||||
|
|
||||||
|
d := Defaults()
|
||||||
|
assert.Equal(t, 500*time.Microsecond, d.GroupCommitDelay)
|
||||||
|
assert.Equal(t, d.MaxSegmentSize, cfg.MaxSegmentSize, "MaxSegmentSize should be defaulted")
|
||||||
|
assert.Equal(t, d.BlockSize, cfg.BlockSize, "BlockSize should be defaulted")
|
||||||
|
assert.Equal(t, d.SyncMode, cfg.SyncMode, "SyncMode should be defaulted")
|
||||||
|
assert.Equal(t, d.MaxBatchEntries, cfg.MaxBatchEntries, "MaxBatchEntries should be defaulted")
|
||||||
|
assert.Equal(t, d.MaxBatchSize, cfg.MaxBatchSize, "MaxBatchSize should be defaulted")
|
||||||
|
assert.Equal(t, d.GroupCommitDelay, cfg.GroupCommitDelay, "GroupCommitDelay should be defaulted")
|
||||||
|
assert.Equal(t, d.MaxKeyBytes, cfg.MaxKeyBytes, "MaxKeyBytes should be defaulted")
|
||||||
|
assert.Equal(t, d.MaxInlineValue, cfg.MaxInlineValue, "MaxInlineValue should be defaulted")
|
||||||
|
assert.Equal(t, d.MemTableSize, cfg.MemTableSize, "MemTableSize should be defaulted")
|
||||||
|
assert.Equal(t, d.MaxImmutableCount, cfg.MaxImmutableCount, "MaxImmutableCount should be defaulted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGroupCommitDelay(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
delay time.Duration
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "negative delay", delay: -time.Microsecond, want: "GroupCommitDelay"},
|
||||||
|
{name: "ten milliseconds rejected", delay: 10 * time.Millisecond, want: "GroupCommitDelay"},
|
||||||
|
{name: "above ten milliseconds rejected", delay: 11 * time.Millisecond, want: "GroupCommitDelay"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := Defaults()
|
||||||
|
cfg.GroupCommitDelay = tt.delay
|
||||||
|
|
||||||
|
err := cfg.Validate()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSegmentTooSmall(t *testing.T) {
|
||||||
|
cfg := WalConfig{MaxSegmentSize: 1024}
|
||||||
|
err := cfg.Validate()
|
||||||
|
require.Error(t, err, "MaxSegmentSize=1024 should fail validation")
|
||||||
|
assert.Contains(t, err.Error(), "too small")
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package go_kv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/dailz/go-kv/config"
|
||||||
|
"github.com/dailz/go-kv/memtable"
|
||||||
|
"github.com/dailz/go-kv/wal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DB is the public database handle for the storage engine.
|
||||||
|
type DB struct {
|
||||||
|
cfg *config.WalConfig
|
||||||
|
dir string
|
||||||
|
writer *wal.WalWriter
|
||||||
|
recoveryMem *memtable.MemTable
|
||||||
|
closed atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens a database directory, recovers durable WAL entries, and starts a
|
||||||
|
// fresh WAL writer at the next recovered segment and sequence.
|
||||||
|
func Open(dir string, cfg *config.WalConfig) (*DB, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
defaults := config.Defaults()
|
||||||
|
cfg = &defaults
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("db: validate config: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("db: create directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recoveryMem := memtable.NewMemTable(cfg.MemTableSize)
|
||||||
|
replayer := &memTableReplayer{mem: recoveryMem}
|
||||||
|
result, err := wal.Recover(dir, replayer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: recover WAL: %w", err)
|
||||||
|
}
|
||||||
|
if err := replayer.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("db: replay WAL: %w", err)
|
||||||
|
}
|
||||||
|
recoveryMem.Publish(^uint64(0))
|
||||||
|
|
||||||
|
writer, err := wal.NewWalWriter(cfg, dir, result.NextSegmentID, result.NextSequence)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: create WAL writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DB{
|
||||||
|
cfg: cfg,
|
||||||
|
dir: dir,
|
||||||
|
writer: writer,
|
||||||
|
recoveryMem: recoveryMem,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close drains outstanding writes and releases database resources.
|
||||||
|
func (db *DB) Close() error {
|
||||||
|
if db.closed.Swap(true) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := db.writer.Close(); err != nil {
|
||||||
|
return fmt.Errorf("db: close WAL writer: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put stores key with value.
|
||||||
|
func (db *DB) Put(key, value []byte) error {
|
||||||
|
if db.closed.Load() {
|
||||||
|
return ErrWriteStopped
|
||||||
|
}
|
||||||
|
if err := db.writer.Put(key, value); err != nil {
|
||||||
|
return fmt.Errorf("db: put: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes key.
|
||||||
|
func (db *DB) Delete(key []byte) error {
|
||||||
|
if db.closed.Load() {
|
||||||
|
return ErrWriteStopped
|
||||||
|
}
|
||||||
|
if err := db.writer.Delete(key); err != nil {
|
||||||
|
return fmt.Errorf("db: delete: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the newest visible value for key across recovered and new writes.
|
||||||
|
func (db *DB) Get(key []byte) *memtable.GetResult {
|
||||||
|
recovered := db.recoveryMem.Get(key)
|
||||||
|
current := db.writer.Get(key)
|
||||||
|
|
||||||
|
if current.Found || current.Sequence > recovered.Sequence {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
if !recovered.Found {
|
||||||
|
return &memtable.GetResult{Found: false}
|
||||||
|
}
|
||||||
|
if recovered.Value == nil {
|
||||||
|
return &memtable.GetResult{Found: false, Sequence: recovered.Sequence}
|
||||||
|
}
|
||||||
|
return recovered
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDurableSequence returns the highest sequence durably fsynced to WAL.
|
||||||
|
func (db *DB) GetDurableSequence() uint64 {
|
||||||
|
return db.writer.GetDurableSequence()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWriteStopped reports whether the DB rejects new writes.
|
||||||
|
func (db *DB) IsWriteStopped() bool {
|
||||||
|
return db.closed.Load() || db.writer.IsWriteStopped()
|
||||||
|
}
|
||||||
|
|
||||||
|
type memTableReplayer struct {
|
||||||
|
mem *memtable.MemTable
|
||||||
|
mu sync.Mutex
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *memTableReplayer) ReplayPut(key, value []byte, sequence uint64) {
|
||||||
|
if err := r.mem.PutPending(cloneBytes(key), cloneBytes(value), sequence); err != nil {
|
||||||
|
r.setErr(fmt.Errorf("put pending: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *memTableReplayer) ReplayDelete(key []byte, sequence uint64) {
|
||||||
|
if err := r.mem.DeletePending(cloneBytes(key), sequence); err != nil {
|
||||||
|
r.setErr(fmt.Errorf("delete pending: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *memTableReplayer) Err() error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *memTableReplayer) setErr(err error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.err == nil {
|
||||||
|
r.err = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneBytes(src []byte) []byte {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dst := make([]byte, len(src))
|
||||||
|
copy(dst, src)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
+303
@@ -0,0 +1,303 @@
|
|||||||
|
package go_kv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dailz/go-kv/config"
|
||||||
|
"github.com/dailz/go-kv/manifest"
|
||||||
|
"github.com/dailz/go-kv/wal"
|
||||||
|
testifyassert "github.com/stretchr/testify/assert"
|
||||||
|
testifyrequire "github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestE2EWALRoundtrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
db := openTestDB(t, dir)
|
||||||
|
|
||||||
|
const entryCount = 1000
|
||||||
|
for i := range entryCount {
|
||||||
|
key := []byte(e2eKey("roundtrip", i))
|
||||||
|
value := []byte(e2eValue("roundtrip", i))
|
||||||
|
testifyrequire.NoError(t, db.Put(key, value), "put %q", key)
|
||||||
|
}
|
||||||
|
closeTestDB(t, db)
|
||||||
|
|
||||||
|
reopened := openTestDB(t, dir)
|
||||||
|
for i := range entryCount {
|
||||||
|
key := []byte(e2eKey("roundtrip", i))
|
||||||
|
got := reopened.Get(key)
|
||||||
|
testifyrequire.True(t, got.Found, "get %q after reopen", key)
|
||||||
|
testifyassert.Equal(t, []byte(e2eValue("roundtrip", i)), got.Value, "value for %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2ERecoveryPartialWrite(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
db := openTestDB(t, dir)
|
||||||
|
|
||||||
|
testifyrequire.NoError(t, db.Put([]byte("preserved-1"), []byte("value-1")))
|
||||||
|
testifyrequire.NoError(t, db.Put([]byte("preserved-2"), []byte("value-2")))
|
||||||
|
testifyrequire.NoError(t, db.Put([]byte("truncated-tail"), bytes.Repeat([]byte("x"), 256)))
|
||||||
|
closeTestDB(t, db)
|
||||||
|
|
||||||
|
appendWalTail(t, dir, []byte{0xde, 0xad, 0xbe, 0xef})
|
||||||
|
|
||||||
|
reopened := openTestDB(t, dir)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
value []byte
|
||||||
|
}{
|
||||||
|
{name: "first complete batch survives", key: "preserved-1", value: []byte("value-1")},
|
||||||
|
{name: "second complete batch survives", key: "preserved-2", value: []byte("value-2")},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := reopened.Get([]byte(tc.key))
|
||||||
|
testifyrequire.True(t, got.Found, "get %q after tail truncation", tc.key)
|
||||||
|
testifyassert.Equal(t, tc.value, got.Value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2ERecoveryMultiSegment(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := e2eSmallSegmentConfig()
|
||||||
|
db := openTestDBWithConfig(t, dir, cfg)
|
||||||
|
|
||||||
|
const entryCount = 80
|
||||||
|
value := bytes.Repeat([]byte("m"), 512)
|
||||||
|
for i := range entryCount {
|
||||||
|
key := []byte(e2eKey("multi-segment", i))
|
||||||
|
testifyrequire.NoError(t, db.Put(key, e2eValueWithSuffix(value, i)), "put %q", key)
|
||||||
|
}
|
||||||
|
closeTestDB(t, db)
|
||||||
|
|
||||||
|
testifyassert.Greater(t, len(walSegmentFiles(t, dir)), 1, "test setup should rotate WAL segments")
|
||||||
|
prepareAllSegmentsForRecovery(t, dir, cfg)
|
||||||
|
|
||||||
|
reopened := openTestDBWithConfig(t, dir, cfg)
|
||||||
|
for i := range entryCount {
|
||||||
|
key := []byte(e2eKey("multi-segment", i))
|
||||||
|
got := reopened.Get(key)
|
||||||
|
testifyrequire.True(t, got.Found, "get %q after multi-segment recovery", key)
|
||||||
|
testifyassert.Equal(t, e2eValueWithSuffix(value, i), got.Value, "value for %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2EWriteStoppedAfterFailure(t *testing.T) {
|
||||||
|
t.Skip("write-stopped-after-I/O-failure requires an injectable WAL writer or filesystem fault; current DB architecture has neither")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2EGetSemantics(t *testing.T) {
|
||||||
|
db := openTestDB(t, t.TempDir())
|
||||||
|
|
||||||
|
testifyrequire.NoError(t, db.Put([]byte("empty"), []byte{}))
|
||||||
|
gotEmpty := db.Get([]byte("empty"))
|
||||||
|
testifyrequire.True(t, gotEmpty.Found, "empty value should be found")
|
||||||
|
testifyassert.Empty(t, gotEmpty.Value, "empty value should round-trip as empty bytes")
|
||||||
|
|
||||||
|
gotMissing := db.Get([]byte("missing"))
|
||||||
|
testifyassert.False(t, gotMissing.Found, "nonexistent key should not be found")
|
||||||
|
testifyassert.Empty(t, gotMissing.Value, "nonexistent key should not return a value")
|
||||||
|
|
||||||
|
testifyrequire.NoError(t, db.Delete([]byte("empty")))
|
||||||
|
gotDeleted := db.Get([]byte("empty"))
|
||||||
|
testifyassert.False(t, gotDeleted.Found, "deleted key should not be found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2EConcurrentReadWrite(t *testing.T) {
|
||||||
|
db := openTestDB(t, t.TempDir())
|
||||||
|
|
||||||
|
const (
|
||||||
|
writerCount = 10
|
||||||
|
readerCount = 10
|
||||||
|
entriesPerWriter = 100
|
||||||
|
readsPerReader = 1000
|
||||||
|
totalPossibleKeys = writerCount * entriesPerWriter
|
||||||
|
)
|
||||||
|
|
||||||
|
var writersDone atomic.Bool
|
||||||
|
errCh := make(chan error, writerCount+readerCount)
|
||||||
|
var writerWG sync.WaitGroup
|
||||||
|
writerWG.Add(writerCount)
|
||||||
|
for writerID := range writerCount {
|
||||||
|
go func() {
|
||||||
|
defer writerWG.Done()
|
||||||
|
for entryID := range entriesPerWriter {
|
||||||
|
key := []byte(concurrentKey(writerID, entryID))
|
||||||
|
value := []byte(concurrentValue(writerID, entryID))
|
||||||
|
if err := db.Put(key, value); err != nil {
|
||||||
|
errCh <- fmt.Errorf("writer %d put %q: %w", writerID, key, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
var readerWG sync.WaitGroup
|
||||||
|
readerWG.Add(readerCount)
|
||||||
|
for readerID := range readerCount {
|
||||||
|
go func() {
|
||||||
|
defer readerWG.Done()
|
||||||
|
for i := range readsPerReader {
|
||||||
|
keyIndex := (readerID*readsPerReader + i) % totalPossibleKeys
|
||||||
|
writerID := keyIndex / entriesPerWriter
|
||||||
|
entryID := keyIndex % entriesPerWriter
|
||||||
|
got := db.Get([]byte(concurrentKey(writerID, entryID)))
|
||||||
|
if !got.Found {
|
||||||
|
if writersDone.Load() {
|
||||||
|
errCh <- fmt.Errorf("reader %d missing key after writers done: writer=%d entry=%d", readerID, writerID, entryID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
want := []byte(concurrentValue(writerID, entryID))
|
||||||
|
if !bytes.Equal(want, got.Value) {
|
||||||
|
errCh <- fmt.Errorf("reader %d got value %q for writer=%d entry=%d, want %q", readerID, got.Value, writerID, entryID, want)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
writerWG.Wait()
|
||||||
|
writersDone.Store(true)
|
||||||
|
readerWG.Wait()
|
||||||
|
close(errCh)
|
||||||
|
|
||||||
|
for err := range errCh {
|
||||||
|
testifyrequire.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestE2ELargeBatchSegmentRotation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := e2eSmallSegmentConfig()
|
||||||
|
db := openTestDBWithConfig(t, dir, cfg)
|
||||||
|
|
||||||
|
const entryCount = 120
|
||||||
|
value := bytes.Repeat([]byte("b"), 512)
|
||||||
|
for i := range entryCount {
|
||||||
|
key := []byte(e2eKey("large-rotation", i))
|
||||||
|
testifyrequire.NoError(t, db.Put(key, e2eValueWithSuffix(value, i)), "put %q", key)
|
||||||
|
}
|
||||||
|
closeTestDB(t, db)
|
||||||
|
|
||||||
|
testifyassert.Greater(t, len(walSegmentFiles(t, dir)), 1, "test setup should rotate WAL segments")
|
||||||
|
prepareAllSegmentsForRecovery(t, dir, cfg)
|
||||||
|
|
||||||
|
reopened := openTestDBWithConfig(t, dir, cfg)
|
||||||
|
for i := range entryCount {
|
||||||
|
key := []byte(e2eKey("large-rotation", i))
|
||||||
|
got := reopened.Get(key)
|
||||||
|
testifyrequire.True(t, got.Found, "get %q after large rotation recovery", key)
|
||||||
|
testifyassert.Equal(t, e2eValueWithSuffix(value, i), got.Value, "value for %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openTestDBWithConfig(t *testing.T, dir string, cfg config.WalConfig) *DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := Open(dir, &cfg)
|
||||||
|
testifyrequire.NoError(t, err, "Open")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if !db.closed.Load() {
|
||||||
|
testifyrequire.NoError(t, db.Close(), "Close cleanup")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func e2eSmallSegmentConfig() config.WalConfig {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
cfg.MaxSegmentSize = 3 * 1024
|
||||||
|
cfg.MaxBatchSize = 2 * 1024
|
||||||
|
cfg.MaxInlineValue = 1024
|
||||||
|
cfg.MemTableSize = 1024 * 1024
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func e2eKey(prefix string, i int) string {
|
||||||
|
return fmt.Sprintf("%s-key-%04d", prefix, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
func e2eValue(prefix string, i int) string {
|
||||||
|
return fmt.Sprintf("%s-value-%04d", prefix, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
func e2eValueWithSuffix(prefix []byte, i int) []byte {
|
||||||
|
return fmt.Appendf(bytes.Clone(prefix), "-%03d", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
func concurrentKey(writerID int, entryID int) string {
|
||||||
|
return fmt.Sprintf("writer-%02d-key-%03d", writerID, entryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func concurrentValue(writerID int, entryID int) string {
|
||||||
|
return fmt.Sprintf("writer-%02d-value-%03d", writerID, entryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func walSegmentFiles(t *testing.T, dir string) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
testifyrequire.NoError(t, err, "ReadDir")
|
||||||
|
segments := make([]string, 0)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "segment-") || !strings.HasSuffix(entry.Name(), ".wal") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
segments = append(segments, filepath.Join(dir, entry.Name()))
|
||||||
|
}
|
||||||
|
sort.Strings(segments)
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareAllSegmentsForRecovery(t *testing.T, dir string, cfg config.WalConfig) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
testifyrequire.NoError(t, manifest.WriteCurrent(dir, 0), "reset CURRENT to recover from first WAL segment")
|
||||||
|
for _, segment := range walSegmentFiles(t, dir) {
|
||||||
|
segmentID, ok := wal.ParseSegmentFilename(filepath.Base(segment))
|
||||||
|
testifyrequire.True(t, ok, "parse WAL segment filename %q", segment)
|
||||||
|
startSequence := firstBatchSequence(t, segment)
|
||||||
|
header := wal.EncodeWalHeader(&wal.WalFileHeader{
|
||||||
|
BlockSize: cfg.BlockSize,
|
||||||
|
SegmentID: segmentID,
|
||||||
|
StartSequence: startSequence,
|
||||||
|
})
|
||||||
|
file, err := os.OpenFile(segment, os.O_WRONLY, 0)
|
||||||
|
testifyrequire.NoError(t, err, "open WAL segment header for rewrite")
|
||||||
|
_, err = file.WriteAt(header[:], 0)
|
||||||
|
testifyassert.NoError(t, err, "rewrite WAL segment header")
|
||||||
|
testifyassert.NoError(t, file.Sync(), "sync WAL segment header rewrite")
|
||||||
|
testifyassert.NoError(t, file.Close(), "close WAL segment header rewrite")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstBatchSequence(t *testing.T, segment string) uint64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
records, err := wal.ParseRecordsFromFile(segment)
|
||||||
|
testifyrequire.NoError(t, err, "parse WAL records from %s", segment)
|
||||||
|
collector := wal.NewFragmentCollector()
|
||||||
|
for _, record := range records {
|
||||||
|
testifyrequire.NoError(t, collector.Append(record.Type, record.Payload), "collect WAL record fragments")
|
||||||
|
if !collector.IsComplete() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
batch, err := wal.DecodeWalBatch(collector.BatchData())
|
||||||
|
testifyrequire.NoError(t, err, "decode first WAL batch")
|
||||||
|
return batch.BaseSequence
|
||||||
|
}
|
||||||
|
t.Fatalf("segment %s contains no complete WAL batch", segment)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
+228
@@ -0,0 +1,228 @@
|
|||||||
|
package go_kv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDBOpenClose(t *testing.T) {
|
||||||
|
db, err := Open(t.TempDir(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Put([]byte("k"), []byte("v")); err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDBPutGet(t *testing.T) {
|
||||||
|
db := openTestDB(t, t.TempDir())
|
||||||
|
|
||||||
|
if err := db.Put([]byte("k1"), []byte("v1")); err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := db.Get([]byte("k1"))
|
||||||
|
if !got.Found {
|
||||||
|
t.Fatal("Get(k1) not found")
|
||||||
|
}
|
||||||
|
if string(got.Value) != "v1" {
|
||||||
|
t.Fatalf("Get(k1) value = %q, want %q", got.Value, "v1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDBDelete(t *testing.T) {
|
||||||
|
db := openTestDB(t, t.TempDir())
|
||||||
|
|
||||||
|
if err := db.Put([]byte("k"), []byte("v")); err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Delete([]byte("k")); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := db.Get([]byte("k"))
|
||||||
|
if got.Found {
|
||||||
|
t.Fatalf("Get(k) found deleted key with value %q", got.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDBRecovery(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
db := openTestDB(t, dir)
|
||||||
|
|
||||||
|
entries := map[string]string{
|
||||||
|
"k1": "v1",
|
||||||
|
"k2": "v2",
|
||||||
|
"k3": "v3",
|
||||||
|
}
|
||||||
|
for key, value := range entries {
|
||||||
|
if err := db.Put([]byte(key), []byte(value)); err != nil {
|
||||||
|
t.Fatalf("Put(%q): %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeTestDB(t, db)
|
||||||
|
|
||||||
|
reopened := openTestDB(t, dir)
|
||||||
|
for key, want := range entries {
|
||||||
|
got := reopened.Get([]byte(key))
|
||||||
|
if !got.Found {
|
||||||
|
t.Fatalf("Get(%q) not found after recovery", key)
|
||||||
|
}
|
||||||
|
if string(got.Value) != want {
|
||||||
|
t.Fatalf("Get(%q) value = %q, want %q", key, got.Value, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDBRecoveryAfterCorruption(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
db := openTestDB(t, dir)
|
||||||
|
|
||||||
|
if err := db.Put([]byte("good1"), []byte("v1")); err != nil {
|
||||||
|
t.Fatalf("Put good1: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Put([]byte("good2"), []byte("v2")); err != nil {
|
||||||
|
t.Fatalf("Put good2: %v", err)
|
||||||
|
}
|
||||||
|
closeTestDB(t, db)
|
||||||
|
|
||||||
|
appendWalTail(t, dir, []byte{0xde, 0xad, 0xbe, 0xef})
|
||||||
|
|
||||||
|
reopened := openTestDB(t, dir)
|
||||||
|
for key, want := range map[string]string{"good1": "v1", "good2": "v2"} {
|
||||||
|
got := reopened.Get([]byte(key))
|
||||||
|
if !got.Found {
|
||||||
|
t.Fatalf("Get(%q) not found after corrupted-tail recovery", key)
|
||||||
|
}
|
||||||
|
if string(got.Value) != want {
|
||||||
|
t.Fatalf("Get(%q) value = %q, want %q", key, got.Value, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDBMultiplePuts(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
db := openTestDB(t, dir)
|
||||||
|
|
||||||
|
for i := range 50 {
|
||||||
|
key := fmt.Appendf(nil, "k%d", i)
|
||||||
|
value := fmt.Appendf(nil, "v%d", i)
|
||||||
|
if err := db.Put(key, value); err != nil {
|
||||||
|
t.Fatalf("Put(%q): %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeTestDB(t, db)
|
||||||
|
|
||||||
|
reopened := openTestDB(t, dir)
|
||||||
|
for i := range 50 {
|
||||||
|
key := fmt.Appendf(nil, "k%d", i)
|
||||||
|
want := fmt.Sprintf("v%d", i)
|
||||||
|
got := reopened.Get(key)
|
||||||
|
if !got.Found {
|
||||||
|
t.Fatalf("Get(%q) not found after recovery", key)
|
||||||
|
}
|
||||||
|
if string(got.Value) != want {
|
||||||
|
t.Fatalf("Get(%q) value = %q, want %q", key, got.Value, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C2+C3: data must survive three Open/Close cycles.
|
||||||
|
// C3's data-loss bug manifests on the SECOND restart after writes — two
|
||||||
|
// opens cannot catch it.
|
||||||
|
//
|
||||||
|
// Single-segment only: do NOT force rotation, because segment_manager has
|
||||||
|
// an unrelated C8 bug (passes byte offset as startSequence) that breaks
|
||||||
|
// multi-segment recovery. That bug is tracked separately.
|
||||||
|
func TestOpenThreeTimesKeepsData(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
keys := []string{"k1", "k2", "k3", "k4", "k5"}
|
||||||
|
|
||||||
|
db1, err := Open(dir, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open 1: %v", err)
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
if err := db1.Put([]byte(k), []byte("v-"+k)); err != nil {
|
||||||
|
t.Fatalf("Put %s: %v", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db1.Close(); err != nil {
|
||||||
|
t.Fatalf("Close 1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verify := func(label string, db *DB) {
|
||||||
|
t.Helper()
|
||||||
|
for _, k := range keys {
|
||||||
|
r := db.Get([]byte(k))
|
||||||
|
if !r.Found {
|
||||||
|
t.Errorf("%s: key %s not found", label, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db2, err := Open(dir, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open 2: %v", err)
|
||||||
|
}
|
||||||
|
verify("Open 2", db2)
|
||||||
|
if err := db2.Close(); err != nil {
|
||||||
|
t.Fatalf("Close 2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db3, err := Open(dir, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open 3: %v", err)
|
||||||
|
}
|
||||||
|
defer db3.Close()
|
||||||
|
verify("Open 3", db3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openTestDB(t *testing.T, dir string) *DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := Open(dir, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if !db.closed.Load() {
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Fatalf("Close cleanup: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeTestDB(t *testing.T, db *DB) {
|
||||||
|
t.Helper()
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendWalTail(t *testing.T, dir string, tail []byte) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(dir, "segment-0.wal")
|
||||||
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenFile(%s): %v", path, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
t.Fatalf("Close corrupted WAL file: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if _, err := f.Write(tail); err != nil {
|
||||||
|
t.Fatalf("Write corrupted WAL tail: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"MD013": {
|
||||||
|
"line_length": 120,
|
||||||
|
"code_blocks": false,
|
||||||
|
"tables": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
# WAL 代码审核报告(对照 `docs/design.md` § 3.2)
|
||||||
|
|
||||||
|
审核范围:`/wal/...`、`/memtable/...`、`/manifest/...`、`/config/...`、`db.go`。
|
||||||
|
审核时间:基于 commit `e34de4a`(fix: implement group commit collection window)。
|
||||||
|
|
||||||
|
下面按严重程度排列,每条给出 **设计要求 → 代码现状 → 后果 → 修复建议**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 Critical(破坏持久化 / 恢复正确性,必须修)
|
||||||
|
|
||||||
|
### C1. CRC 多项式错误:用 IEEE 而非 crc32c
|
||||||
|
|
||||||
|
- **设计要求**(§3.2 Physical Record、WAL File Header):crc32c(Castagnoli,0x82F63B78),用于识别 torn write / partial write。
|
||||||
|
- **代码现状**:
|
||||||
|
- `wal/record.go:30,55` → `crc32.ChecksumIEEE`
|
||||||
|
- `wal/header.go:44,83` → `crc32.ChecksumIEEE`
|
||||||
|
- **后果**:CRC 校验值与设计文档不一致;如果未来要做跨实现兼容(其他客户端 / 工具按 crc32c 校验),所有 segment 都会被判损坏。当前自洽但与规范脱钩。
|
||||||
|
- **修复**:改用 `hash/crc32.Castagnoli`(即 `crc32.MakeTable(crc32.Castagnoli)`),所有 `ChecksumIEEE` 替换为 `Checksum(data, castagnoliTable)`。常数需要加测试固定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C2. Recovery 用 CURRENT 作为兜底,违反"MANIFEST 是唯一权威"
|
||||||
|
|
||||||
|
- **设计要求**(§3.2 CURRENT / MANIFEST 权威性,line 600-66):
|
||||||
|
> MANIFEST 是 recovery 起点和 checkpoint 状态的权威源……CURRENT 只表示写入侧上次尝试记录的 active WAL segment hint……**不能作为 recovery 起点、终点或排除 segment 的依据**。
|
||||||
|
- **代码现状**:`wal/recover.go:109-124`
|
||||||
|
```go
|
||||||
|
if mf.RecoverySegmentID > 0 { return mf.RecoverySegmentID, nil }
|
||||||
|
if segID, ok := manifest.ReadCurrent(dir); ok { return segID, nil } // ← 违规
|
||||||
|
return 0, nil
|
||||||
|
```
|
||||||
|
- **后果**:CURRENT 是 best-effort 写入、可能落后 / 指向已被截断或未 durable-ready 的 segment。用它做 recovery 起点,要么漏恢复(如果它指向比 MANIFEST 更新的 segment,而那个 segment 实际上没 durable-ready),要么恢复出空集(如果它指向已被 MANIFEST 覆盖删除的旧 segment)。这条路径在 MANIFEST.RecoverySegmentID==0 时(首次创建 DB 或刚 flush 后尚未写过 MANIFEST)会被触发。
|
||||||
|
- **修复**:删掉 CURRENT 兜底,MANIFEST.RecoverySegmentID==0 时直接返回 0;CURRENT 仅在写入侧作为 hint 顺手写。
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_ef425776)**:C2 在 Phase 1 比 C3 更严重。Phase 1 没有 flush,MANIFEST 一直保持 0;`segment_manager.go:47,90` 每次创建/轮转 segment 都会写 CURRENT 指向 active segment。**默认状态下首次 Recover 就会触发 fallback**,不需要"CURRENT 缺失/落后"这种特殊场景。修 C3 之前必须先修 C2,否则数据丢失窗口依然存在。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C3. Recovery 主动写 MANIFEST,违反"recovery repair 不更新 MANIFEST"
|
||||||
|
|
||||||
|
- **设计要求**(§3.2 line 280):
|
||||||
|
> MANIFEST 只作为 checkpoint / recovery 起点元数据;**recovery repair 不更新 MANIFEST**,也不通过 MANIFEST 记录恢复终点。
|
||||||
|
- **代码现状**:`wal/recover.go:77,100`
|
||||||
|
```go
|
||||||
|
if saveErr := manifest.Save(dir, result.NextSegmentID); saveErr != nil { ... }
|
||||||
|
```
|
||||||
|
无论恢复成功还是尾部截断,都会把 MANIFEST.RecoverySegmentID 改写为"最后一个 segment + 1"。
|
||||||
|
- **后果**:**直接数据丢失**。设想 segment-5 是 active,写了若干 batch 后崩溃,恢复时只重放了部分 batch 并把尾部截断。代码把 MANIFEST 推进到 segment-6。**下次启动时 recovery 直接从 segment-6 开始,跳过了 segment-5 中已经 durable 的 batch**。设计明确要求 MANIFEST 只能由 checkpoint(MemTable flush 完成 + SSTable 元数据落盘)推进。
|
||||||
|
- **修复**:删掉 recovery 中的 `manifest.Save`。MANIFEST 推进只能发生在 flush 完成后(未来 §3.3 实现)。
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_ef425776)**:单修 C3 不足以解决 Phase 1 数据丢失,必须和 C2 一起修。测试方向需要区分干净 WAL 和尾部损坏 WAL 两种幂等性。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C4. 非尾段 fragment 状态被当成尾段损坏
|
||||||
|
|
||||||
|
- **设计要求**(§3.2 Fragment 状态机 line 731):
|
||||||
|
> Segment 边界不是合法的 fragment 边界……如果扫描到 WAL 尾部时仍处于 CollectingFragments 状态……丢弃该 incomplete batch。设计同时明确:**CollectingFragments + 不是最后恢复 segment → 视为 WAL 中间损坏,报错**。
|
||||||
|
- **代码现状**:`wal/recovery.go:133-138` 在 `ReplaySegmentFile` 末尾,只要处于 `FragmentCollecting` 就返回 `TailCorruptionError`,**不区分当前 segment 是否是最后一个**;`RecoverFromSegments` 也直接透传。
|
||||||
|
- **后果**:如果 segment-5 中间出现 `First + Middle*` 但没有 `Last`(中间损坏),但 segment-6 还存在,代码会把 segment-5 的中间损坏当尾部截断处理,截掉 segment-6 的有效数据。设计要求此时硬报错。
|
||||||
|
- **修复**:`ReplaySegmentFile` 需要接收 `isLast bool` 参数,或者在 `RecoverFromSegments` 的 segment 循环里检查:非最后段返回 `CollectingFragments` 必须返回普通 corruption 错误,不是 `TailCorruptionError`。
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_ef425776)**:C4 还有一个更严重的失败模式。`wal/recover.go:61` 总是对 `segments[len(segments)-1]` 调用 `truncateSegment`,但 `RecoverFromSegments` 的 `TailCorruptionError` 可能来自非尾段。结果:真正损坏的 segment 不动,最后一段的有效数据被错误截掉。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C5. 尾部截断不 fsync、不删空 segment、不 fsync 目录,且失败被吞
|
||||||
|
|
||||||
|
- **设计要求**(§3.2 尾部截断持久化 line 786-800):
|
||||||
|
> 1. ftruncate 当前 active segment 到 lastCompleteBatchEnd
|
||||||
|
> 2. **fsync 被截断的 segment**
|
||||||
|
> 3. 删除 startSequence == expectedSequence 且不含任何 complete batch 的后续空 segment
|
||||||
|
> 4. **fsync WAL directory**
|
||||||
|
> 全部成功后 recovery 才能进入恢复完成状态。**若任一步失败,recovery 必须报错,DB 不得进入可写状态**。
|
||||||
|
- **代码现状**:`wal/recover.go:60-69`
|
||||||
|
```go
|
||||||
|
validOffset, truncErr := findValidOffset(lastSeg.FilePath)
|
||||||
|
if truncErr != nil {
|
||||||
|
result.TruncateError = fmt.Errorf("%w (find valid offset: %v)", err, truncErr) // 吞掉
|
||||||
|
} else if truncErr := truncateSegment(lastSeg.FilePath, validOffset); truncErr != nil {
|
||||||
|
result.TruncateError = fmt.Errorf("%w (truncate: %v)", err, truncErr) // 吞掉
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`truncateSegment` 只是 `os.Truncate`,没有 fsync 文件、没有删后续空 segment、没有 fsync 目录,失败也只是写进 `result.TruncateError` 然后**正常返回成功**。
|
||||||
|
- **后果**:崩溃恢复后 WAL 尾部可能再次暴露已被"截断"的脏字节,下次启动会重复 repair 或 repair 出不同边界;DB 已经接受新写入,破坏了"恢复后的 WAL 状态即权威"这一不变量。
|
||||||
|
- **修复**:把 truncation 拆成独立函数,按设计 4 步串行执行,任何一步失败 `return err`,DB.Open 必须失败。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C6. SegmentWriter 创建时目录 fsync 失败被静默忽略
|
||||||
|
|
||||||
|
- **设计要求**(§3.2 WAL 元数据持久化协议 line 248-273):durable-ready 协议第 5 步 `fsync WAL directory` 是 segment 进入 durable-ready 的硬条件。目录 fsync 失败时 segment **不得**承载可确认写入;若已有 Batch 依赖该 segment,进入 write-stopped。
|
||||||
|
- **代码现状**:`wal/segment_writer.go:86-89`
|
||||||
|
```go
|
||||||
|
if dirFD, derr := os.Open(dir); derr == nil {
|
||||||
|
dirFD.Sync() // 错误被忽略
|
||||||
|
dirFD.Close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **后果**:rename 已发生但目录元数据未落盘。掉电后恢复可能看不到这个 segment 文件,但 WAL writer 已经向调用方确认了该 Batch 成功("不丢已确认写入"被破坏)。这是 §3.2 Always 策略下最严重的 durability 漏洞之一。
|
||||||
|
- **修复**:目录 fsync 错误必须返回,触发 write-stopped;同时显式管理 durable-ready 状态机(新字段 `durableReady bool`),未就绪时禁止 AppendBatch。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C7. Put/Delete vs Close 存在 send-on-closed-channel 竞态
|
||||||
|
|
||||||
|
- **代码现状**:`wal/writer.go:314-326`(Put)先检查 `writeStopped`,再 `queue.Submit`;而 `Close` 先 `writeStopped.Store(true)` 再 `queue.Close()`。两者之间没有同步。
|
||||||
|
- **后果**:并发调用 Put 和 Close 时,Put 通过 writeStopped 检查后、Submit 之前,Close 把 channel 关掉,Put 的 `cq.ch <- req` 触发 panic。这不是 §3.2 设计直接约束,但破坏写入路径稳定性。
|
||||||
|
- **修复**:Close 用 RWMutex 保护,Submit 用 RLock 检查 closed 标志;或者把 close 时机延后到所有 in-flight submit 完成。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C8. Segment 轮转时把字节偏移当 sequence number 传给新 segment(Oracle bg_2e86d33b 发现)
|
||||||
|
|
||||||
|
- **设计要求**(§3.2 WAL File Header、Segment 连续性校验 line 639-663):每个 segment 的 `startSequence` 必须严格衔接前一 segment 最后一个 batch 后的 sequence;recovery 时 `segment.StartSequence != expectedSequence` 必须报错。
|
||||||
|
- **代码现状**:`wal/segment_manager.go:64-66`
|
||||||
|
```go
|
||||||
|
if sm.active.RemainingPayload() < worstCaseSize {
|
||||||
|
if err := sm.rotate(sm.active.CurrentOffset()); err != nil { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`rotate(newStartSequence uint64)` 的参数名是 `newStartSequence`,但传入的 `sm.active.CurrentOffset()` 返回的是**字节偏移**(从 `WalFileHeaderSize=32` 累加),不是 sequence number。
|
||||||
|
- **后果**:发生 segment 轮转后,新 segment 的 header 里 `startSequence = 上一 segment 的字节偏移`。Recovery 时 `wal/recovery.go:155-157`:
|
||||||
|
```go
|
||||||
|
if segment.StartSequence != nextSequence { error }
|
||||||
|
```
|
||||||
|
立刻失败。**Phase 1 多 segment 场景的 recovery 实际上是坏的**。
|
||||||
|
- **修复**:`SegmentManager` 需要跟踪当前 `nextSequence`(或从 WalWriter 拿),rotate 时把它传下去,而不是 `CurrentOffset()`。
|
||||||
|
- **对 C2+C3 patch 的影响**:相关 e2e 测试(`TestOpenThreeTimesKeepsData`)必须**单 segment**,不能强制轮转,否则会撞上 C8 而不是 C2+C3。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟠 High(破坏格式约束或重要不变量)
|
||||||
|
|
||||||
|
### H1. Physical Record 不校验 `length > 0`
|
||||||
|
|
||||||
|
- **设计要求**(§3.2 Block 边界处理、Physical Record 解析规则 line 389, 681):`length` 必须 `> 0`。
|
||||||
|
- **代码现状**:`wal/record.go:38-67` `DecodePhysicalRecord` 只校验 `length <= len(data)-headerSize`,没校验下界。`wal/record_parser.go:69` 也没校验。
|
||||||
|
- **后果**:攻击者 / 损坏数据可注入 `length=0` 的 record,绕过 CRC(payload 为空时 CRC 只覆盖 length+type),恢复出空 batch 进而触发 `entry count is zero` 之类的错误,被误判为"尾部损坏可截断",实际上属于中间损坏。
|
||||||
|
- **修复**:`DecodePhysicalRecord` 加 `if length == 0 { return ErrZeroLength }`,并在 parser 把它当中间损坏(非尾部不截断)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H2. Physical Record 不校验 `type != Invalid (0)`
|
||||||
|
|
||||||
|
- **设计要求**(§3.2 fragment 类型表 line 366-372):type=0 是 invalid,用于损坏检测。
|
||||||
|
- **代码现状**:`wal/record.go` 解析时不校验 type;`FragmentCollector.Append` 的 default 分支会拒绝,但 `ParseBlock` 收集 records 时不会。
|
||||||
|
- **后果**:损坏的 type=0 record 会被加入 records 列表,传给 fragment collector 后才报错;这把"物理层损坏"推迟到"batch 层错误",错误分类可能错(按设计应该硬错,但实际可能被当 CRC 失败归为尾部损坏)。
|
||||||
|
- **修复**:`DecodePhysicalRecord` 校验 `recType ∈ {1,2,3,4}`,否则返回错误。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H3. `worstCaseSize` 估算不足,segment 可能写超 `MaxSegmentSize`
|
||||||
|
|
||||||
|
- **设计要求**(§3.2 Segment Rotation 约束、config 不变量 line 392-451):判断是否需要 rotate 时必须基于"真实最坏情况"开销:`numRecords * prHeaderSize + worstCaseBlockPadding`,对默认配置最大 batch 是 903 + 7 = 910 bytes 开销。
|
||||||
|
- **代码现状**:`wal/segment_manager.go:62`
|
||||||
|
```go
|
||||||
|
worstCaseSize := uint64(len(encodedBatch)) + uint64(PhysicalRecordHeaderSize) + uint64(PhysicalRecordHeaderSize)
|
||||||
|
```
|
||||||
|
只加 14 bytes(2 × 7)。对接近 4MB 的大 batch,少估了约 896 bytes。
|
||||||
|
- **后果**:当 active segment 剩余 payload 在 `[encodedBatchSize + 14, encodedBatchSize + 910]` 区间时,代码认为放得下,实际写出后超过 `MaxSegmentSize`。ValidateBatchLimits 只校验"空 segment 能放下",没校验"当前剩余能放下"。下一批次才会触发 rotate,期间 segment 文件实际大小超出配置上限。
|
||||||
|
- **修复**:把 `ValidateBatchLimits` 中已经算过的 `numRecords`/`overhead`/`padding` 计算抽成公共函数,segment_manager 用同样的公式判断 remaining。或者更稳:rotate 阈值改为 `if active.RemainingPayload() < minFreshSegmentPayload { rotate }`,即只要剩余不足以容纳最大合法 batch 就 rotate。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H4. `MaxImmutableCount` 配置存在但从不强制
|
||||||
|
|
||||||
|
- **设计要求**(§3.2 写入流程 step ⑤ line 119):
|
||||||
|
> 若 Immutable MemTable 队列已达上限,该 Batch 必须在 WAL write 之前等待后台 flush 释放容量。
|
||||||
|
- **代码现状**:`wal/writer.go:244-265` `reserveMemTable` 只在 active 满时 rotate,**完全不检查 immutable 队列长度**。`MemTableList.rotateActive` 无脑 append。
|
||||||
|
- **后果**:写入速度快于 flush 时,immutable 队列无限增长,OOM;同时也违反了"WAL write 前等待 flush"的设计约束。
|
||||||
|
- **修复**:`reserveMemTable` 在 rotate 前检查 `len(immutable) >= MaxImmutableCount`,是则阻塞等待条件变量(需要 flush goroutine 来唤醒)。Phase 1 没有 flush,至少应该在超限时返回 `ErrWriteStopped` 或阻塞,而不是无脑 append。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H5. Per-batch `(segmentID, endOffset, endSequence)` 跟踪缺失,durableSequence 推进模型错误
|
||||||
|
|
||||||
|
- **设计要求**(§3.2 line 205-245):每个完整 Batch 必须记录 `(segmentID, endOffset, endSequence)`;后台 fsync worker snapshot `(segmentID, endOffset, endSequence)`;fsync 成功后只能推进到满足"segment 已 durable-ready + offset 覆盖 + endSequence 连续"的最大 batch。跨 segment 推进还需要目标 segment 的 header + rename + 目录都已 fsync。
|
||||||
|
- **代码现状**:`wal/sequence.go:68-78` `MarkDurable` 只是 CAS 把 durableSequence 推到 seq;`wal/writer.go:228` 每次 `processBatch` fsync 后立刻调用 `MarkDurable(lastSequence)`。
|
||||||
|
- **后果**:当前 Phase 1 只有 Always 模式 + 单 writer 串行 fsync,**功能上恰好正确**(每次 fsync 覆盖且只覆盖当前 batch,durableSequence == publishedSequence)。但一旦未来引入 Periodic / 异步 fsync / 多 batch 合并 fsync,模型立刻崩溃。设计明确要求"durableSequence 不能只根据'最近一次 fsync 成功'模糊推进"。
|
||||||
|
- **修复**:哪怕 Phase 1,也要按设计记录每 batch 的 `(segmentID, endOffset, endSequence)`,并实现 fsyncSnapshot 比较逻辑。否则是对未来扩展的债务性违约。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H6. MemTable publish 机制不符合 release/acquire 模型
|
||||||
|
|
||||||
|
- **设计要求**(§3.2 step ⑧-⑩ line 121):
|
||||||
|
> MemTable skiplist / arena 节点必须先通过**原子发布机制**写入读路径可见结构(例如 `atomic.Pointer` store-release,或等价的 release publish),并且 Batch 内所有 entry 的节点都完成发布后,才能用 `atomic.Uint64.Store` 推进 `publishedSequence`。普通读者必须先 `Load` 当前 `publishedSequence`,再遍历 MemTable;读到 entry 后仍以 `entry.sequence <= loadedPublishedSequence` 判断可见性。
|
||||||
|
- **代码现状**:`memtable/memtable.go:87-127` `Publish`:
|
||||||
|
1. 在 `skiplist.mu` 下遍历收集 pending 节点;
|
||||||
|
2. **释放锁后**,对每个 entry 调用 `skiplist.Put(..., pending=false)` 创建**新节点**替换旧节点;
|
||||||
|
3. `mt.published.Store(upToSequence)`。
|
||||||
|
|
||||||
|
读路径 `skiplist.Get` 用 `node.pending` 判断可见性,**完全不读 `publishedSequence`**。
|
||||||
|
- **后果**:
|
||||||
|
1. 读路径看到的可见性边界是"pending 标志位",不是 `publishedSequence`。Phase 1 单 key autocommit 下语义等价,但设计要求的是后者。
|
||||||
|
2. Publish 创建新节点替换旧节点,旧 pending 节点成为 GC 垃圾;多个 batch 同时 publish(理论上)会竞争同一 key 的替换路径。
|
||||||
|
3. 弱内存序架构上,新节点写入(通过 `atomic.Pointer.Store`)发生在 `published.Store` 之前,**顺序对的**;但设计要求的 publish-then-load-sequence 模式没有被代码使用,未来引入 MVCC 的 `commitSequence` / `visibleCommitSequence` 时会失配。
|
||||||
|
4. MemTable.published 字段写完后**没人读**,是死代码。
|
||||||
|
- **修复**:要么按设计实现:skiplist 节点带 sequence,Put 时直接 published(不 pending),读路径 Load `publishedSequence` 后过滤;要么保留 pending 模型但在设计文档里明确改写,并删除/利用 MemTable.published。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H7. `arena.Reserve` 只是检查,不是真正预留
|
||||||
|
|
||||||
|
- **设计要求**(§3.2 step ⑤ line 119):按 Batch 内所有 entry 的最大内存占用(key、value 或 ValueLogPointer、**skiplist 节点**、arena 对齐与**层高开销**)计算需要预留的 Arena 字节数。
|
||||||
|
- **代码现状**:`memtable/arena.go:75-84` `Reserve` 只读 offset 不修改;`memtable/memtable.go:55-70` 每条 entry 估算 `metadataOverhead = 32`,没考虑 `maxLevel * sizeof(pointer) = 20 * 8 = 160` bytes 的层高开销。skiplist 节点实际存在堆上(`newSkipNode` 不用 arena)。
|
||||||
|
- **后果**:Reserve 是个粗略的 budget gate,实际 heap 用量可能超过 MemTableSize;多线程并发 reserve 还可能 double-count(两个 batch 都看到 remaining 够,都写入,实际超限)。Phase 1 不用 arena 存节点,所以不会触发 ErrArenaFull,但容量预算失效。
|
||||||
|
- **修复**:要么把 Reserve 改成真 atomic advance offset(commit 预留),要么显式承认 Phase 1 是软限制并在设计里标注;估算公式按最坏层高(maxLevel=20)算。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 Medium(设计偏离但不立即致错)
|
||||||
|
|
||||||
|
### H8. `findValidOffset` 不跟踪 batch 边界,截断点错位(Oracle bg_ef425776 升级 M1)
|
||||||
|
|
||||||
|
- **设计要求**(§3.2 尾部截断持久化 line 786):截断目标必须是"最后一个完整 WAL Batch 的结束位置 `lastCompleteBatchEnd`"。
|
||||||
|
- **代码现状**:`wal/recover.go:137-203` 的 `findValidOffset` 只用 `DecodePhysicalRecord` 校验物理记录,**不跟踪完整 batch 边界 / fragment 状态机**。对尾部 `First + Middle*` 没 `Last` 的情况:
|
||||||
|
- `recovery.go:133` 在最后一个完整 batch 结尾报尾部损坏
|
||||||
|
- `findValidOffset` 可能返回不完整 fragment 之后的 EOF,**比真正应截断的位置更靠后**
|
||||||
|
- **后果**:残留半截 fragment 在文件尾部。下次启动 recovery 还会在同位置触发尾部损坏,**反复 repair,每次结果可能不同**。
|
||||||
|
- **修复**:让 `ParseRecordsFromFile` 直接返回 `lastValidOffset`(基于完整 batch 边界),作为 truncation 的权威依据;删掉独立的 `findValidOffset` 重复解析。
|
||||||
|
|
||||||
|
> 原 M1("两次解析可能不一致")已升级为 H8,因为 Oracle 给出具体证据:不跟踪 batch 边界会让截断点错位,导致反复 repair。
|
||||||
|
|
||||||
|
### M2. `EncodeWalBatch(0, entries)` 预校验后立刻丢弃,再编一次
|
||||||
|
|
||||||
|
- `wal/writer.go:192-204` 先用 baseSequence=0 编码一次只为校验,然后再用真实 baseSequence 编码。功能对(baseSequence 不影响 size),但白编一次 4MB buffer。
|
||||||
|
- **修复**:抽 `validateEncodedSize(entries)` 函数,只算 size 不分配 buffer;或者直接信任 `ValidateBatchLimits` 已经覆盖的检查(其实已经覆盖了),删掉这次预编码。
|
||||||
|
|
||||||
|
### M3. `SyncMode` 是字符串 `"always"`,不是设计里的策略枚举
|
||||||
|
|
||||||
|
- 设计明确三种策略 `Always` / `Periodic` / `Never`,Phase 1 只支持 Always。代码用裸字符串,类型不安全,未来加 Periodic 时容易漏改地方。
|
||||||
|
- **修复**:定义 `type SyncMode int` + 常量,配置序列化层做字符串映射。
|
||||||
|
|
||||||
|
### M4. `CommitQueue` 容量 = `MaxBatchEntries`,可能过大
|
||||||
|
|
||||||
|
- `wal/writer.go:82` `queueCapacity := max(1, int(cfg.MaxBatchEntries))` 默认 10000,意味着 channel 缓冲 10000 个 `*CommitRequest`。每个 request 至少带 1 个 entry 的 key/value clone。高并发下内存占用被低估。
|
||||||
|
- **修复**:独立配置项 `CommitQueueCapacity`,默认更小(例如 1024)。
|
||||||
|
|
||||||
|
### M5. `SegmentWriter` 没有 `durableReady` 状态字段
|
||||||
|
|
||||||
|
- 设计要求 segment 显式进入 durable-ready 状态才能承载可确认写入。代码隐式假设"构造函数返回即可写",没有状态机字段。
|
||||||
|
- **修复**:加 `durableReady bool` 字段,`AppendBatch` 前断言;构造函数中所有 fsync 步聚通过后才置 true。
|
||||||
|
|
||||||
|
### M6. `GroupCommitDelay` 计时器语义偏离设计
|
||||||
|
|
||||||
|
- 设计:"500µs 或 32KB,先到者触发"。代码 `runLoop` 每次从 channel 读到第一个 req 后启 timer,500µs 内继续收集。这相当于"从第一个 req 起等待 500µs",与设计一致。但 timer 在每个 batch 循环重建,如果 channel 持续有 req,低负载时其实仍能 500µs 触发,OK。**不算 bug,但建议注释清楚**。
|
||||||
|
|
||||||
|
### M7. `MemTable.aborted` 是 `sync.Map` 且从不清理
|
||||||
|
|
||||||
|
- `memtable/memtable.go:36`。每次 `Abort(seq)` 写入,从不删除。长时间运行 + 频繁 abort 会内存泄漏。
|
||||||
|
- **修复**:Publish 时把已 publish 的 sequence 从 aborted 里删掉;或者用 bitmap。
|
||||||
|
|
||||||
|
### M8. `processRemaining` 在 write-stopped 后仍会处理队列
|
||||||
|
|
||||||
|
- `wal/writer.go:165-173` 在 close 时 drain 队列。但 `processBatch` 内部会先检查 `writeStopped`,如果已停止会直接 sendError。所以 drain 不会真正写 WAL,OK。**但需要测试覆盖这个路径**。
|
||||||
|
|
||||||
|
### M9. 错误消息中"segment 损坏"和"尾部损坏"混用,难审计
|
||||||
|
|
||||||
|
- 多处 `fmt.Errorf` 没用 `%w` 包装 `ErrWALCorrupted`,调用者难以 `errors.Is`。
|
||||||
|
- **修复**:定义 `ErrWALCorrupted` / `ErrWALTailCorrupted` 哨兵错误,所有 corruption 路径用 `%w` 包装。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 Low / 建议
|
||||||
|
|
||||||
|
- **L1**:`wal/header.go` 把 `walFileHeaderSize` 等小写常量和 `wal/header.go` 与 `wal/constants.go` 中重复的大写常量合并,避免两套维护。
|
||||||
|
- **L2**:`wal/segment_writer.go:42` `os.O_EXCL` 在并发 Open 同一 tmp 名时直接失败,错误信息可加上"可能是上次崩溃残留"。
|
||||||
|
- **L3**:`memtable/skiplist.go:209` `rand.Float64()` 非并发安全(Go 1.20+ 默认 auto-seed 后是安全的,但显式用 `math/rand/v2` 更清楚)。
|
||||||
|
- **L4**:`config/config.go:124` `SyncMode != "always"` 错误消息建议列出合法值。
|
||||||
|
- **L5**:测试覆盖建议补:① crc32c 固定向量;② 非尾段 CollectingFragments 必须 hard error;③ MANIFEST 不被 recovery 修改;④ Put(k, []) 与 Delete(k) 的 Get 结果在恢复后仍能区分;⑤ dir fsync 失败时 segment 不可写。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 总结(必须立刻修的)
|
||||||
|
|
||||||
|
| 编号 | 一句话 | 风险等级 |
|
||||||
|
|------|--------|----------|
|
||||||
|
| **C1** | CRC 多项式错(IEEE → crc32c) | 格式不符规范 |
|
||||||
|
| **C2** | Recovery 用 CURRENT 兜底 | 恢复起点错误 |
|
||||||
|
| **C3** | Recovery 写 MANIFEST | **下次启动跳过有效 segment,丢数据** |
|
||||||
|
| **C4** | 非尾段 CollectingFragments 当尾段损坏 | 中间损坏被静默截断 |
|
||||||
|
| **C5** | 截断无 fsync / 不删空 segment / 失败被吞 | **重复 repair,DB 状态不一致** |
|
||||||
|
| **C6** | SegmentWriter dir fsync 失败被吞 | **掉电后已确认写入消失** |
|
||||||
|
| **C7** | Put vs Close 的 channel 竞态 | 偶发 panic |
|
||||||
|
| **C8** | Segment 轮转把字节偏移当 startSequence | **多 segment recovery 直接失败** |
|
||||||
|
|
||||||
|
C3 和 C6 是最严重的:C3 直接导致数据丢失,C6 直接破坏 Always 模式的"不丢已确认写入"承诺。C8 是隐藏炸弹:单 segment 时一切正常,第一次轮转后就坏。建议优先修复 C1-C8 这 8 条,再处理 H1-H7。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修复优先级建议
|
||||||
|
|
||||||
|
按数据丢失风险排:**C3 → C6 → C5 → C2 → C4 → C1 → C7**,再处理 H1-H7。
|
||||||
|
|
||||||
|
> **Oracle 修订(bg_2e86d33b)**:实际上 C2 和 C3 必须**捆绑**修复(C2 的触发条件在 Phase 1 默认状态下就成立,比 C3 更宽松),单独修 C3 不止血。修订后优先级:**C2 + C3(一起)→ C6 → C5 + H8(一起,同一文件)→ C4 → C8 → C1 → C7**。
|
||||||
+237
-22
@@ -116,7 +116,7 @@
|
|||||||
|
|
||||||
默认模式下,写入成功发生在 WAL fsync 之后;但 fsync 的粒度是 WAL Batch,不是单条 `Put`。因此多条写入共享一次 fsync 成本,同时每条写入仍然只有在自身所在 WAL Batch 持久化后才算成功。
|
默认模式下,写入成功发生在 WAL fsync 之后;但 fsync 的粒度是 WAL Batch,不是单条 `Put`。因此多条写入共享一次 fsync 成本,同时每条写入仍然只有在自身所在 WAL Batch 持久化后才算成功。
|
||||||
|
|
||||||
步骤 ⑤ 是 WAL 副作用之前的强制容量关口:WAL writer 必须按 Batch 内所有 entry 的最大内存占用(key、value 或 ValueLogPointer、skiplist 节点、arena 对齐与层高开销)计算需要预留的 Arena 字节数。若当前可写 MemTable 剩余容量不足,必须先把它冻结为 Immutable MemTable,并创建新的可写 MemTable 后再继续。若 Immutable MemTable 队列已达上限,该 Batch 必须在 WAL write 之前等待后台 flush 释放容量;若单个 Batch 的最大可能内存占用超过 MemTable 上限,则该 Batch 在进入 WAL 前按普通错误拒绝。实现不得在 WAL write / append 已尝试之后,再因为 Arena 满而让 MemTable pending 写入失败。
|
步骤 ④ 和 ⑤ 是 WAL 副作用之前的强制资源关口:WAL writer 必须先执行 WAL Batch 资源上限校验,确认 encoded batch 在当前配置下可以写入一个空 WAL segment,再按 Batch 内所有 entry 的最大内存占用(key、value 或 ValueLogPointer、skiplist 节点、arena 对齐与层高开销)计算需要预留的 Arena 字节数。若 Batch 超过 WAL 资源上限、超过空 WAL segment 可承载上限,或单个 Batch 的最大可能内存占用超过 MemTable 上限,该 Batch 必须在 sequence 分配和 WAL write 之前按普通错误拒绝。若当前可写 MemTable 剩余容量不足,必须先把它冻结为 Immutable MemTable,并创建新的可写 MemTable 后再继续。若 Immutable MemTable 队列已达上限,该 Batch 必须在 WAL write 之前等待后台 flush 释放容量。实现不得在 WAL write / append 已尝试之后,再因为 Batch 超限、segment 容量不可能容纳或 Arena 满而让 MemTable pending 写入失败。
|
||||||
|
|
||||||
步骤 ⑧ 与 ⑩ 之间存在明确的内存序约束:MemTable skiplist / arena 节点必须先通过原子发布机制写入读路径可见结构(例如 `atomic.Pointer` store-release,或等价的 release publish),并且 Batch 内所有 entry 的节点都完成发布后,才能用 `atomic.Uint64.Store` 推进 `publishedSequence`。普通读者必须先 `Load` 当前 `publishedSequence`,再遍历 MemTable;读到 entry 后仍以 `entry.sequence <= loadedPublishedSequence` 判断可见性。该顺序保证弱内存序架构上读者不会先观察到已推进的 `publishedSequence`,却看不到对应已发布的 skiplist 节点。
|
步骤 ⑧ 与 ⑩ 之间存在明确的内存序约束:MemTable skiplist / arena 节点必须先通过原子发布机制写入读路径可见结构(例如 `atomic.Pointer` store-release,或等价的 release publish),并且 Batch 内所有 entry 的节点都完成发布后,才能用 `atomic.Uint64.Store` 推进 `publishedSequence`。普通读者必须先 `Load` 当前 `publishedSequence`,再遍历 MemTable;读到 entry 后仍以 `entry.sequence <= loadedPublishedSequence` 判断可见性。该顺序保证弱内存序架构上读者不会先观察到已推进的 `publishedSequence`,却看不到对应已发布的 skiplist 节点。
|
||||||
|
|
||||||
@@ -136,6 +136,22 @@ WAL 写入路径的失败分类如下:
|
|||||||
|
|
||||||
`ErrCommitUnknown` 的 Batch 在当前运行期仍不发布、不确认成功;如果已经写入 MemTable,其 entries 保持 pending / unpublished 或转为 aborted,仅作为内部状态存在,对普通读不可见。`publishedSequence` 始终保持连续 high-water mark,普通读仍可使用 `sequence <= publishedSequence` 判断可见性。重启后,如果 recovery 在 WAL 中发现该 Batch 完整、CRC 合法且 sequence 连续,可以按正常 WAL 规则重放;如果只留下尾部 partial write,则按 WAL 尾部截断规则处理。
|
`ErrCommitUnknown` 的 Batch 在当前运行期仍不发布、不确认成功;如果已经写入 MemTable,其 entries 保持 pending / unpublished 或转为 aborted,仅作为内部状态存在,对普通读不可见。`publishedSequence` 始终保持连续 high-water mark,普通读仍可使用 `sequence <= publishedSequence` 判断可见性。重启后,如果 recovery 在 WAL 中发现该 Batch 完整、CRC 合法且 sequence 连续,可以按正常 WAL 规则重放;如果只留下尾部 partial write,则按 WAL 尾部截断规则处理。
|
||||||
|
|
||||||
|
WAL 编码与写入缓冲必须采用 **per-batch private encode buffer + direct write** 模型:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Batch entries → private encoded []byte → split Physical Records → write(fd, records)
|
||||||
|
```
|
||||||
|
|
||||||
|
每个 WAL Batch 在私有内存缓冲区中完成完整编码和 Physical Record 布局;该缓冲区在调用 `write()` 之前不得被
|
||||||
|
recovery 看到,也不得进入任何跨 Batch 共享的 WAL 写入状态。实现可以用对象池复用底层 `[]byte` 容量,但复用后的
|
||||||
|
缓冲区在同一时刻只能归属一个 Batch,并且必须在该 Batch 完成写入或失败处理后才能归还池中。
|
||||||
|
|
||||||
|
第一版 WAL writer 不使用共享 `bufio.Writer` 作为 WAL append 路径的一部分,也不允许多个 Batch 先写入同一个共享
|
||||||
|
用户态缓冲区后再统一 flush。共享 buffered writer 会模糊“zero bytes reached WAL state”的边界:一旦 Batch bytes
|
||||||
|
进入共享缓冲,后续 flush 失败无法可靠判断具体哪个 Batch 已经对 WAL 状态产生副作用。因此失败分类以是否已经尝试
|
||||||
|
把当前 Batch 的 private buffer 写入 WAL 文件为边界:未调用 `write()` 前失败是普通错误;调用 `write()` 后失败,
|
||||||
|
除非实现能证明当前 Batch 零字节到达 WAL 状态,否则必须返回 `ErrCommitUnknown` 并进入 write-stopped。
|
||||||
|
|
||||||
`ErrCommitUnknown` 表示提交结果不确定:调用方不能把它当作“写入一定失败”并盲目重试。恢复完成后,调用方必须通过读取 key 或后续事务层提供的事务 ID / commit record 查询提交状态,再决定是否重试。后续 MVCC / SSI 事务层必须为事务提交提供幂等标识,避免 fsync 不确定结果导致非幂等事务重复提交。
|
`ErrCommitUnknown` 表示提交结果不确定:调用方不能把它当作“写入一定失败”并盲目重试。恢复完成后,调用方必须通过读取 key 或后续事务层提供的事务 ID / commit record 查询提交状态,再决定是否重试。后续 MVCC / SSI 事务层必须为事务提交提供幂等标识,避免 fsync 不确定结果导致非幂等事务重复提交。
|
||||||
|
|
||||||
如果进程在 WAL write / append 已尝试之后、调用方收到成功或错误之前崩溃,调用方观察到的是“无返回结果”。该状态在 API 语义上等价于 `ErrCommitUnknown`:不是成功确认,也不是 definitely failed,而是 maybe committed。调用方重启后必须按同一套状态确认约束处理,不得因为没有收到成功返回就假设该写入一定不存在。
|
如果进程在 WAL write / append 已尝试之后、调用方收到成功或错误之前崩溃,调用方观察到的是“无返回结果”。该状态在 API 语义上等价于 `ErrCommitUnknown`:不是成功确认,也不是 definitely failed,而是 maybe committed。调用方重启后必须按同一套状态确认约束处理,不得因为没有收到成功返回就假设该写入一定不存在。
|
||||||
@@ -167,13 +183,13 @@ WAL 写入路径的失败分类如下:
|
|||||||
|
|
||||||
#### 可见性语义
|
#### 可见性语义
|
||||||
|
|
||||||
- `publishedSequence` 表示普通读的逻辑可见 high-water mark;普通读可以读取 MemTable,但只返回 `sequence <= publishedSequence` 的 entry
|
- `publishedSequence` 表示 WAL 物理 mutation sequence 的连续发布边界:普通读可以读取 MemTable,但第一阶段只返回 `sequence <= publishedSequence` 的 entry
|
||||||
- fsync 前,写入可以已经存在于 MemTable 中,但处于 pending / unpublished 状态,仅供内部提交流程使用,对普通读不可见
|
- fsync 前,写入可以已经存在于 MemTable 中,但处于 pending / unpublished 状态,仅供内部提交流程使用,对普通读不可见
|
||||||
- pending / unpublished / aborted entry 不仅对普通读不可见,也不得进入 SSTable;MemTable flush 必须遵守 3.3 的 Flush 过滤规则,只刷 `sequence <= publishedSequence` 且非 aborted 的 entry
|
- pending / unpublished / aborted entry 不仅对普通读不可见,也不得进入 SSTable;MemTable flush 必须遵守 3.3 的 Flush 过滤规则,只刷 `sequence <= publishedSequence` 且非 aborted 的 entry
|
||||||
- `Always` 默认策略下,`publishedSequence` 也是 durable high-water mark;普通读能读到的数据,必须是崩溃恢复后仍可恢复的数据
|
- `Always` 默认策略下,`publishedSequence` 也是 durable high-water mark;普通读能读到的数据,必须是崩溃恢复后仍可恢复的数据
|
||||||
- `Periodic` / `Never` 策略下,`publishedSequence` 可以领先于 durable high-water mark;普通读能读到当前进程内已发布的数据,但这些数据不保证机器掉电后仍可恢复
|
- `Periodic` / `Never` 策略下,`publishedSequence` 可以领先于 durable high-water mark;普通读能读到当前进程内已发布的数据,但这些数据不保证机器掉电后仍可恢复
|
||||||
- 如果 WAL Entry 引用外部持久化对象(例如 Value Log record),发布 WAL Batch 前,被引用对象也必须满足当前落盘策略对应的持久化要求;在 `Always` 下这意味着该 Batch 引用的 Value Log ranges 必须先通过 3.9 的 durable barrier,避免恢复后出现悬空指针
|
- 如果 WAL Entry 引用外部持久化对象(例如 Value Log record),发布 WAL Batch 前,被引用对象也必须满足当前落盘策略对应的持久化要求;在 `Always` 下这意味着该 Batch 引用的 Value Log ranges 必须先通过 3.9 的 durable barrier,避免恢复后出现悬空指针
|
||||||
- 该模型为后续 MVCC / 事务层提供统一的 read timestamp / commit sequence 基础
|
- 第一阶段单 key autocommit 中,`entry.sequence == commitSequence`,因此 `publishedSequence` 可同时作为普通读可见边界;后续 MVCC / SSI 引入多 key 事务后,`publishedSequence` 不改义为事务提交时间,而是继续表示物理发布 / durable / recovery 边界,事务可见性由独立的 `commitSequence` / `visibleCommitSequence` 控制
|
||||||
- `Always` 下该选择牺牲的是写入进入 MemTable 后到 fsync 发布前的短暂全局可见性延迟,不是已发布数据的读路径性能
|
- `Always` 下该选择牺牲的是写入进入 MemTable 后到 fsync 发布前的短暂全局可见性延迟,不是已发布数据的读路径性能
|
||||||
|
|
||||||
#### WAL 落盘策略
|
#### WAL 落盘策略
|
||||||
@@ -186,6 +202,48 @@ WAL 写入路径的失败分类如下:
|
|||||||
|
|
||||||
非默认落盘策略必须由用户显式开启。内部仍使用同一套 sequence / publish 机制,区别只在于 `publishedSequence` 是在 fsync 后推进,还是在 WAL write 成功后提前推进。`Periodic` / `Never` 下,已经发布并返回成功的写入仍可能在机器掉电后丢失。
|
非默认落盘策略必须由用户显式开启。内部仍使用同一套 sequence / publish 机制,区别只在于 `publishedSequence` 是在 fsync 后推进,还是在 WAL write 成功后提前推进。`Periodic` / `Never` 下,已经发布并返回成功的写入仍可能在机器掉电后丢失。
|
||||||
|
|
||||||
|
系统必须同时维护 `durableSequence`,表示最后一次已经满足当前恢复发现条件且 fsync 成功的连续 WAL 物理
|
||||||
|
mutation high-water mark:
|
||||||
|
|
||||||
|
```text
|
||||||
|
durableSequence <= publishedSequence
|
||||||
|
```
|
||||||
|
|
||||||
|
`Always` 策略下,Batch 只有 fsync 成功后才发布,因此 `durableSequence == publishedSequence`。`Periodic` 策略下,
|
||||||
|
WAL write 成功后可以先推进 `publishedSequence` 并返回成功,后台 fsync 成功后再推进 `durableSequence`。`Never`
|
||||||
|
策略下,引擎不主动推进 `durableSequence`,它只能反映最近一次由启动恢复或显式同步操作确认的 durable 边界。
|
||||||
|
|
||||||
|
`Periodic` 后台 fsync 失败后,引擎必须进入 write-stopped:后续写入被拒绝,错误被记录并暴露给调用方的健康检查 /
|
||||||
|
状态查询接口。已经返回成功但 `sequence > durableSequence` 的 Batch 不回滚、不从 MemTable 删除,也不向原调用方
|
||||||
|
补发错误;它们在当前进程内仍按 `publishedSequence` 可见,但机器掉电后可能丢失。调用方如果选择 `Periodic` /
|
||||||
|
`Never`,必须通过 `GetDurableSequence()` 或等价状态接口自行判断哪些已确认写入已经跨越 durable 边界。
|
||||||
|
|
||||||
|
`Periodic` 的 fsync 间隔必须是显式配置项;文档中的默认策略仍是 `Always`,第一版可以不提供 `Periodic` 默认值。
|
||||||
|
若后续提供默认 `Periodic` 间隔,必须同时在用户文档中说明最大时间窗口和最大 pending bytes 窗口,使调用方能估算
|
||||||
|
`publishedSequence - durableSequence` 覆盖的风险范围。
|
||||||
|
|
||||||
|
`durableSequence` 不能只根据“最近一次 fsync 成功”模糊推进。WAL writer 必须为每个完整 Batch 记录
|
||||||
|
`(segmentID, endOffset, endSequence)`,其中 `endOffset` 是该 Batch 最后一个 Physical Record 结束后的文件偏移,
|
||||||
|
`endSequence = baseSequence + entryCount - 1`。后台 fsync worker 触发时必须先快照当前 append high-water mark:
|
||||||
|
|
||||||
|
```text
|
||||||
|
fsyncSnapshot = (segmentID, endOffset, endSequence)
|
||||||
|
```
|
||||||
|
|
||||||
|
fsync 成功后,只能把 `durableSequence` 推进到满足以下条件的最大连续 Batch:
|
||||||
|
|
||||||
|
```text
|
||||||
|
batch.segmentID < fsyncSnapshot.segmentID
|
||||||
|
∨ (batch.segmentID == fsyncSnapshot.segmentID ∧ batch.endOffset <= fsyncSnapshot.endOffset)
|
||||||
|
|
||||||
|
∧ batch.segment 已进入 durable-ready 状态
|
||||||
|
∧ batch.endSequence 连续衔接当前 durableSequence
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 fsync 期间又追加了新的 Batch,或者发生 segment rotation,这些晚于快照的 Batch 不得因为本次 fsync 成功而被
|
||||||
|
标记为 durable;它们必须等待覆盖自身 `(segmentID, endOffset)` 的下一次 fsync 成功。跨 segment 推进时,目标 segment
|
||||||
|
还必须满足 WAL 元数据持久化协议:segment header 已 fsync,rename 后目录项已 fsync,并已进入 durable-ready。
|
||||||
|
|
||||||
#### WAL 元数据持久化协议
|
#### WAL 元数据持久化协议
|
||||||
|
|
||||||
`Always` 策略下,WAL Batch 可以返回成功的前提不仅是 WAL bytes 已 fsync,还包括恢复路径能够在掉电后找到这些 bytes。任何承载已确认写入的 WAL segment 都必须先进入 durable-ready 状态。
|
`Always` 策略下,WAL Batch 可以返回成功的前提不仅是 WAL bytes 已 fsync,还包括恢复路径能够在掉电后找到这些 bytes。任何承载已确认写入的 WAL segment 都必须先进入 durable-ready 状态。
|
||||||
@@ -198,13 +256,8 @@ WAL 写入路径的失败分类如下:
|
|||||||
3. fsync segment-N.wal.tmp
|
3. fsync segment-N.wal.tmp
|
||||||
4. rename segment-N.wal.tmp → segment-N.wal
|
4. rename segment-N.wal.tmp → segment-N.wal
|
||||||
5. fsync WAL directory
|
5. fsync WAL directory
|
||||||
6. update CURRENT via temp + rename:
|
6. segment-N enters durable-ready state
|
||||||
a. write CURRENT.tmp = segment-N.wal
|
7. WAL writer may append batches whose recovery depends on segment-N
|
||||||
b. fsync CURRENT.tmp
|
|
||||||
c. rename CURRENT.tmp → CURRENT
|
|
||||||
d. fsync metadata directory
|
|
||||||
7. segment-N enters durable-ready state
|
|
||||||
8. WAL writer may append batches whose recovery depends on segment-N
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`Always` 下,一个 WAL Batch 只有同时满足以下条件才能确认成功并发布 sequence:
|
`Always` 下,一个 WAL Batch 只有同时满足以下条件才能确认成功并发布 sequence:
|
||||||
@@ -213,22 +266,27 @@ WAL 写入路径的失败分类如下:
|
|||||||
batch 所在 segment 已进入 durable-ready 状态
|
batch 所在 segment 已进入 durable-ready 状态
|
||||||
∧ WAL bytes 已写到 batch end offset
|
∧ WAL bytes 已写到 batch end offset
|
||||||
∧ WAL 文件已 fsync 到 batch end offset
|
∧ WAL 文件已 fsync 到 batch end offset
|
||||||
∧ 发现该 segment 所需的元数据已经 fsync
|
∧ 发现该 segment 所需的目录项已经 fsync
|
||||||
```
|
```
|
||||||
|
|
||||||
如果新 segment 进入 durable-ready 之前任一步失败,该 segment 不得成为 active segment,也不得承载可确认写入。如果此时尚未分配 sequence,可以重试创建或切换到其他 segment;如果 sequence 已分配或已有 WAL Batch 依赖该 segment,则按 WAL write failure 处理,引擎进入 write-stopped 状态。
|
如果新 segment 进入 durable-ready 之前任一步失败,该 segment 不得成为 active segment,也不得承载可确认写入。如果此时尚未分配 sequence,可以重试创建或切换到其他 segment;如果 sequence 已分配或已有 WAL Batch 依赖该 segment,则按 WAL write failure 处理,引擎进入 write-stopped 状态。
|
||||||
|
|
||||||
新 segment 的 durable-ready 协议只能在当前 active segment 已结束于完整 WAL Batch 边界后启动。实现不得预创建未来 segment,也不得让 `segment-N+1.wal` 在 `segment-N.wal` 仍可能存在未完成 Batch 尾部时对 recovery 可见。该约束保证:如果 recovery 看到后续 segment 存在,前一个 segment 必须已经 sealed 在完整 Batch 边界;否则前一个 segment 的尾部异常应被视为 WAL 中间损坏。
|
新 segment 的 durable-ready 协议只能在当前 active segment 已结束于完整 WAL Batch 边界后启动。实现不得预创建未来 segment,也不得让 `segment-N+1.wal` 在 `segment-N.wal` 仍可能存在未完成 Batch 尾部时对 recovery 可见。该约束保证:如果 recovery 看到后续 segment 存在,前一个 segment 必须已经 sealed 在完整 Batch 边界;否则前一个 segment 的尾部异常应被视为 WAL 中间损坏。
|
||||||
|
|
||||||
|
CURRENT 更新不属于 durable-ready 协议,也不是 WAL Batch 确认成功的前提。写入侧可以在 segment 进入 durable-ready 后,通过 temp + rename 的方式 best-effort 更新 CURRENT,用于下次启动或运行期快速定位 active segment;CURRENT 更新失败不得影响已经通过目录 fsync 变为可发现的 segment,也不得让 recovery 改以 CURRENT 作为权威来源。
|
||||||
|
|
||||||
MANIFEST 不在每次 WAL segment 轮转时更新。MANIFEST 表示 recovery 起点 / checkpoint 状态,只在 MemTable flush、SSTable 与 checkpoint 元数据都持久化后推进;旧 WAL segment 何时可删除由后续文件生命周期规则定义。
|
MANIFEST 不在每次 WAL segment 轮转时更新。MANIFEST 表示 recovery 起点 / checkpoint 状态,只在 MemTable flush、SSTable 与 checkpoint 元数据都持久化后推进;旧 WAL segment 何时可删除由后续文件生命周期规则定义。
|
||||||
|
|
||||||
|
MANIFEST 只作为 checkpoint / recovery 起点元数据;recovery repair 不更新 MANIFEST,也不通过 MANIFEST 记录恢复终点。
|
||||||
|
|
||||||
#### 设计决策
|
#### 设计决策
|
||||||
|
|
||||||
| 决策 | 选择 | 理由 |
|
| 决策 | 选择 | 理由 |
|
||||||
| -------- | ---------------------------------- | --------------------------------------------------------------------------- |
|
| -------- | ---------------------------------- | --------------------------------------------------------------------------- |
|
||||||
| 批量窗口 | 时间 + 大小双触发(500µs 或 32KB) | 高负载靠大小触发效率最大化,低负载靠时间触发不会卡住 |
|
| 批量窗口 | 时间 + 大小双触发(500µs 或 32KB) | 高负载靠大小触发效率最大化,低负载靠时间触发不会卡住 |
|
||||||
| 日志格式 | 固定 Block(32KB) | 恢复时按 block 读取校验,比逐条快;和 SSTable block 设计一致 |
|
| 日志格式 | 固定 Block(32KB) | 恢复时按 block 读取校验,比逐条快;和 SSTable block 设计一致 |
|
||||||
| 文件管理 | 分段日志 | 精确删除已刷盘的旧 WAL 段;恢复可并行读多文件;CURRENT 文件指向当前活跃 WAL |
|
| 文件管理 | 分段日志 | 精确删除已刷盘的旧 WAL 段;恢复通过 MANIFEST + WAL 目录扫描发现 segment;CURRENT 仅作为 last-known active segment 的 best-effort hint |
|
||||||
|
| WAL segment 大小 | 默认 64MB,可配置但有最小值约束 | 保证最大合法 WAL Batch 可以写入空 segment,同时避免过小 segment 导致频繁 rotation |
|
||||||
|
|
||||||
#### WAL Block 格式
|
#### WAL Block 格式
|
||||||
|
|
||||||
@@ -339,6 +397,61 @@ Physical Record 的顺序由 WAL 文件的顺序追加和顺序扫描保证,
|
|||||||
- 不允许先写入 Batch 的部分 fragment,再因 segment 空间不足切换 segment
|
- 不允许先写入 Batch 的部分 fragment,再因 segment 空间不足切换 segment
|
||||||
- Recovery 的 fragment 收集状态不跨 segment 携带;每个非最后恢复 segment 扫描结束时必须处于 `Idle`
|
- Recovery 的 fragment 收集状态不跨 segment 携带;每个非最后恢复 segment 扫描结束时必须处于 `Idle`
|
||||||
|
|
||||||
|
`maxWalSegmentSize` 是单个 WAL segment 文件的最大字节数,包含 WAL File Header;`maxWalSegmentPayload` 是 header 之后可用于 Block / Physical Record / WAL Batch 的 payload 区大小。Segment rotation 的容量判断必须基于 payload 区剩余空间,而不是整个文件大小。
|
||||||
|
|
||||||
|
配置必须满足以下不变量,确保最大合法 WAL Batch 可以写入一个空 segment:
|
||||||
|
|
||||||
|
```text
|
||||||
|
maxWalSegmentPayload = maxWalSegmentSize - walFileHeaderSize
|
||||||
|
|
||||||
|
maxWalSegmentPayload >=
|
||||||
|
maxEncodedWalBatchSize
|
||||||
|
+ worstCasePhysicalRecordOverhead
|
||||||
|
+ worstCaseBlockPadding
|
||||||
|
```
|
||||||
|
|
||||||
|
当前格式下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
walFileHeaderSize = 32 bytes
|
||||||
|
walBlockSize = 32KB
|
||||||
|
physicalRecordHeaderSize = 7 bytes
|
||||||
|
walBatchHeaderSize = 18 bytes
|
||||||
|
maxWalBatchEntriesSize = 4MB
|
||||||
|
|
||||||
|
maxEncodedWalBatchSize =
|
||||||
|
walBatchHeaderSize + maxWalBatchEntriesSize
|
||||||
|
= 18 + 4,194,304
|
||||||
|
= 4,194,322 bytes
|
||||||
|
|
||||||
|
maxPhysicalRecordPayload =
|
||||||
|
walBlockSize - physicalRecordHeaderSize
|
||||||
|
= 32,768 - 7
|
||||||
|
= 32,761 bytes
|
||||||
|
|
||||||
|
maxPhysicalRecordCount =
|
||||||
|
ceil(maxEncodedWalBatchSize / maxPhysicalRecordPayload)
|
||||||
|
= 129
|
||||||
|
|
||||||
|
worstCasePhysicalRecordOverhead =
|
||||||
|
maxPhysicalRecordCount * physicalRecordHeaderSize
|
||||||
|
= 129 * 7
|
||||||
|
= 903 bytes
|
||||||
|
|
||||||
|
worstCaseBlockPadding = 7 bytes
|
||||||
|
|
||||||
|
minWalSegmentPayload =
|
||||||
|
4,194,322 + 903 + 7
|
||||||
|
= 4,195,232 bytes
|
||||||
|
|
||||||
|
minWalSegmentSize =
|
||||||
|
walFileHeaderSize + minWalSegmentPayload
|
||||||
|
= 32 + 4,195,232
|
||||||
|
= 4,195,264 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
因此,在当前默认资源上限下,`maxWalSegmentSize` 必须 `>= 4,195,264 bytes`。默认 `maxWalSegmentSize = 64MB`,满足该约束。`maxWalSegmentSize`、`maxWalBatchEntriesSize`、`walBlockSize` 必须在 DB 创建 / 打开时用 checked arithmetic 联合校验;如果配置无法满足上述不变量,DB 必须拒绝启动或拒绝创建,而不是运行到写入阶段才发现某个合法 Batch 无法写入空 segment。
|
||||||
|
|
||||||
##### WAL Batch
|
##### WAL Batch
|
||||||
|
|
||||||
WAL Batch 是物理持久化单元,对应一次 group commit batch。恢复时必须拼出完整 WAL Batch 后才能重放,不能重放半个 batch。WAL Batch 不是事务边界;一个 WAL Batch 可以包含多个独立 autocommit 写入,后续也可以包含一个或多个事务提交记录。
|
WAL Batch 是物理持久化单元,对应一次 group commit batch。恢复时必须拼出完整 WAL Batch 后才能重放,不能重放半个 batch。WAL Batch 不是事务边界;一个 WAL Batch 可以包含多个独立 autocommit 写入,后续也可以包含一个或多个事务提交记录。
|
||||||
@@ -374,10 +487,47 @@ Entry 的 sequence 由 batch 内位置推导:
|
|||||||
entry[i].sequence = baseSequence + i
|
entry[i].sequence = baseSequence + i
|
||||||
```
|
```
|
||||||
|
|
||||||
|
所有 sequence 运算必须使用 checked arithmetic。写入侧在分配 Batch sequence 前必须确认:
|
||||||
|
|
||||||
|
```text
|
||||||
|
baseSequence + entryCount - 1 不溢出 u64
|
||||||
|
```
|
||||||
|
|
||||||
|
如果剩余 sequence 空间不足以容纳整个 Batch,WAL writer 不得写入部分 Entry,也不得让 sequence wrap;引擎必须进入
|
||||||
|
terminal `sequence exhausted` 状态,拒绝后续写入并要求用户迁移 / 重建数据库。Recovery 侧解析 WAL Batch 时也必须做
|
||||||
|
同样的溢出检查;如果 `baseSequence + entryCount - 1` 或 `expectedSequence += entryCount` 发生溢出,视为 WAL 损坏
|
||||||
|
并报错,而不是按取模后的 sequence 继续恢复。
|
||||||
|
|
||||||
|
###### WAL Batch 资源上限
|
||||||
|
|
||||||
|
WAL Batch 解析和写入必须使用同一套可配置资源上限。默认上限如下:
|
||||||
|
|
||||||
|
| 资源 | 默认上限 | 约束目的 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| `entryCount` | 10,000 | 限制单个 batch 的解析循环次数和重放工作量 |
|
||||||
|
| `entriesSize` | 4MB | 限制 Entries 区域和完整 batch payload 的内存占用 |
|
||||||
|
| 单个 `keyLen` | 4KB | 防止单 key 分配过大 |
|
||||||
|
| 单个 Inline `valLen` | 4KB | 超过该大小的 value 必须走 ValueLogPointer |
|
||||||
|
| fragment buffer | 不超过 Batch Header 长度 + `entriesSize` 上限 | 防止 fragment 拼接阶段无限增长 |
|
||||||
|
| varint 编码长度 | 最大 5 bytes | 防止畸形 varint 长时间解析或解出超大长度 |
|
||||||
|
|
||||||
|
写入侧必须在 sequence 分配、MemTable 预留和 WAL append 之前完成这些校验;超出任一上限时按普通错误拒绝写入,且不得产生 WAL 副作用。Recovery 侧超过任一上限时视为 WAL 损坏,并按“WAL 尾部可截断、中间损坏报错”的规则处理。
|
||||||
|
|
||||||
###### 事务边界与 commit sequence
|
###### 事务边界与 commit sequence
|
||||||
|
|
||||||
WAL 的 `baseSequence + i` 是物理 mutation sequence,用于保持 WAL 重放顺序和 `publishedSequence` 连续推进;它不能直接等同于多 key 事务的逻辑提交时间。
|
WAL 的 `baseSequence + i` 是物理 mutation sequence,用于保持 WAL 重放顺序和 `publishedSequence` 连续推进;它不能直接等同于多 key 事务的逻辑提交时间。
|
||||||
|
|
||||||
|
相关 sequence 的职责边界如下:
|
||||||
|
|
||||||
|
| 名称 | 范围 | 职责 |
|
||||||
|
|------|------|------|
|
||||||
|
| `walSequence` / `entry.sequence` | 单条 WAL 物理 mutation | 保证 WAL append、recovery replay、MemTable flush 与 Value Log 可达性判断的物理顺序 |
|
||||||
|
| `publishedSequence` | 连续的 WAL 物理 mutation high-water mark | 表示已发布到读路径且满足当前落盘策略的物理边界;也是 recovery / durable high-water mark;MVCC 引入后仍不表示事务可见时间 |
|
||||||
|
| `commitSequence` | 事务逻辑提交记录 | 表示 MVCC reader 与 SSI 冲突检测使用的逻辑提交 timestamp;同一事务内所有 mutation 共享一个 `commitSequence` |
|
||||||
|
| `visibleCommitSequence` / reader snapshot | 读事务或普通读的逻辑可见边界 | 事务时代的读可见性 high-water mark;读路径必须同时满足 MVCC 可见性规则和底层物理 mutation 已不超过 `publishedSequence` |
|
||||||
|
|
||||||
|
因此系统在 MVCC / SSI 阶段会同时维护两个边界:`publishedSequence` 负责物理持久化与恢复连续性,`visibleCommitSequence` 负责事务逻辑可见性。二者在第一阶段单 key autocommit 中等价,但这是阶段性简化,不是长期语义。
|
||||||
|
|
||||||
第一阶段单 key autocommit 规则:
|
第一阶段单 key autocommit 规则:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -449,7 +599,10 @@ Entry
|
|||||||
|
|
||||||
###### CURRENT / MANIFEST 权威性
|
###### CURRENT / MANIFEST 权威性
|
||||||
|
|
||||||
MANIFEST 是 recovery 起点和 checkpoint 状态的权威源,记录最老仍需恢复的 `recoverySegmentID`。CURRENT 只表示当前活跃 WAL segment,是写入侧快速定位 active segment 的辅助文件,不作为 recovery 起点的权威来源。
|
MANIFEST 是 recovery 起点和 checkpoint 状态的权威源,记录最老仍需恢复的 `recoverySegmentID`。
|
||||||
|
MANIFEST 不记录 recovery repair 终点;尾部截断 repair 的持久化结果由 WAL 文件截断后的 EOF 隐式表达。
|
||||||
|
CURRENT 只表示写入侧上次尝试记录的 active WAL segment hint,是写入侧快速定位 active segment 的辅助文件;
|
||||||
|
它可能缺失、落后或与目录扫描结果不一致,不能作为 recovery 起点、终点或排除 segment 的依据。
|
||||||
|
|
||||||
恢复时:
|
恢复时:
|
||||||
|
|
||||||
@@ -461,6 +614,8 @@ MANIFEST 是 recovery 起点和 checkpoint 状态的权威源,记录最老仍
|
|||||||
5. 如果 MANIFEST 指定的 recovery segment 缺失,或后续需要恢复的 segment 不连续,报错
|
5. 如果 MANIFEST 指定的 recovery segment 缺失,或后续需要恢复的 segment 不连续,报错
|
||||||
```
|
```
|
||||||
|
|
||||||
|
目录扫描得到的 WAL segment 集合必须先按 `MANIFEST.recoverySegmentID` 过滤:`segmentID < recoverySegmentID` 的旧 segment 已被 MANIFEST 覆盖,不属于 recovery candidate set。Recovery 不校验这些旧 segment 的连续性、header 或 `startSequence`;它们即使仍残留在目录中,也只交由文件生命周期清理流程处理。
|
||||||
|
|
||||||
该规则保证即使 CURRENT 更新在崩溃前未持久化,恢复仍不会依赖不可靠的 active segment 指针;只要已确认写入所在 segment 已按 WAL 元数据持久化协议进入 durable-ready 状态,恢复就能通过目录扫描发现它。
|
该规则保证即使 CURRENT 更新在崩溃前未持久化,恢复仍不会依赖不可靠的 active segment 指针;只要已确认写入所在 segment 已按 WAL 元数据持久化协议进入 durable-ready 状态,恢复就能通过目录扫描发现它。
|
||||||
|
|
||||||
###### Recovery 扫描流程
|
###### Recovery 扫描流程
|
||||||
@@ -486,10 +641,14 @@ MANIFEST 是 recovery 起点和 checkpoint 状态的权威源,记录最老仍
|
|||||||
从 MANIFEST 指定的 recovery 起点开始,多 segment 恢复必须同时校验 `segmentID` 和 `startSequence` 的连续性:
|
从 MANIFEST 指定的 recovery 起点开始,多 segment 恢复必须同时校验 `segmentID` 和 `startSequence` 的连续性:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
segments = scan WAL directory
|
||||||
|
recoverySegments = segments where segmentID >= manifest.recoverySegmentID
|
||||||
|
sort recoverySegments by segmentID ascending
|
||||||
|
|
||||||
expectedSegmentID = manifest.recoverySegmentID
|
expectedSegmentID = manifest.recoverySegmentID
|
||||||
expectedSequence = recoveryStartSegment.startSequence
|
expectedSequence = recoveryStartSegment.startSequence
|
||||||
|
|
||||||
for segment in segmentID ascending order:
|
for segment in recoverySegments:
|
||||||
require segment.segmentID == expectedSegmentID
|
require segment.segmentID == expectedSegmentID
|
||||||
require segment.startSequence == expectedSequence
|
require segment.startSequence == expectedSequence
|
||||||
|
|
||||||
@@ -503,6 +662,10 @@ for segment in segmentID ascending order:
|
|||||||
|
|
||||||
已被 MANIFEST 证明不再需要的旧 WAL segment 可以删除,不参与连续性校验;连续性要求只适用于 recovery 起点之后仍需恢复的 WAL segment。
|
已被 MANIFEST 证明不再需要的旧 WAL segment 可以删除,不参与连续性校验;连续性要求只适用于 recovery 起点之后仍需恢复的 WAL segment。
|
||||||
|
|
||||||
|
如果目录中仍存在 `segmentID < recoverySegmentID` 的旧 segment,recovery 必须忽略它们,不得因旧 segment 的缺失、不连续、header 损坏或 `startSequence` 不匹配而报错。是否删除这些旧文件属于 checkpoint 后的文件生命周期管理,不是 recovery 正确性的前置条件。
|
||||||
|
|
||||||
|
首次创建数据库时,`MANIFEST.recoverySegmentID` 通常初始化为 0;但该值不是全局固定起点。随着 checkpoint 推进,`recoverySegmentID` 可以推进到任意后续 `segmentID`,后续 recovery 只从该起点开始要求连续。
|
||||||
|
|
||||||
###### Physical Record 解析规则
|
###### Physical Record 解析规则
|
||||||
|
|
||||||
解析器在 Block 内顺序读取 Physical Record:
|
解析器在 Block 内顺序读取 Physical Record:
|
||||||
@@ -522,6 +685,11 @@ while blockRemaining >= 7:
|
|||||||
|
|
||||||
当 `blockRemaining < 7` 时,剩余 bytes 必须全为 0,然后进入下一个 Block。
|
当 `blockRemaining < 7` 时,剩余 bytes 必须全为 0,然后进入下一个 Block。
|
||||||
|
|
||||||
|
最后一个需要恢复的 segment 的最后一个 Block 可以是 short Block:如果解析器刚好在一个完整 Physical Record 结束后
|
||||||
|
遇到 EOF,该 EOF 是正常 WAL 结束条件,不要求文件中不存在的 bytes 补齐到 32KB,也不要求对不存在的 padding 做 0 校验。
|
||||||
|
Padding 规则只适用于文件中实际存在的 bytes:只要当前 Block 内已经物理存在的剩余 bytes 非 0,就按 padding 损坏处理;
|
||||||
|
如果剩余 bytes 尚未写入文件而直接 EOF,则按正常尾部结束处理。
|
||||||
|
|
||||||
错误分类:
|
错误分类:
|
||||||
|
|
||||||
这里的 “WAL 尾部” 有严格定义:只指最后一个需要恢复的 WAL segment 的物理 EOF 附近。非最后恢复 segment 中的 header 半写、`length` 越界、CRC 错误、incomplete batch 或非法 padding,即使发生在该 segment 文件尾,也视为 WAL 中间损坏。
|
这里的 “WAL 尾部” 有严格定义:只指最后一个需要恢复的 WAL segment 的物理 EOF 附近。非最后恢复 segment 中的 header 半写、`length` 越界、CRC 错误、incomplete batch 或非法 padding,即使发生在该 segment 文件尾,也视为 WAL 中间损坏。
|
||||||
@@ -552,8 +720,8 @@ CollectingFragments
|
|||||||
| Idle | First | 开始收集 fragment,进入 CollectingFragments |
|
| Idle | First | 开始收集 fragment,进入 CollectingFragments |
|
||||||
| Idle | Middle | 非法 fragment 顺序 |
|
| Idle | Middle | 非法 fragment 顺序 |
|
||||||
| Idle | Last | 非法 fragment 顺序 |
|
| Idle | Last | 非法 fragment 顺序 |
|
||||||
| CollectingFragments | Middle | 追加 payload 到当前 fragment buffer |
|
| CollectingFragments | Middle | 追加 payload 到当前 fragment buffer;追加后 buffer 不得超过 Batch Header 长度 + WAL Batch `entriesSize` 上限 |
|
||||||
| CollectingFragments | Last | 追加 payload,拼出完整 WAL Batch,解析重放后回到 Idle |
|
| CollectingFragments | Last | 追加 payload;追加后 buffer 不得超过 Batch Header 长度 + WAL Batch `entriesSize` 上限,随后拼出完整 WAL Batch,解析重放后回到 Idle |
|
||||||
| CollectingFragments | Full | 非法 fragment 顺序 |
|
| CollectingFragments | Full | 非法 fragment 顺序 |
|
||||||
| CollectingFragments | First | 非法 fragment 顺序 |
|
| CollectingFragments | First | 非法 fragment 顺序 |
|
||||||
| Idle | segment 结束 | 正常结束当前 segment |
|
| Idle | segment 结束 | 正常结束当前 segment |
|
||||||
@@ -568,26 +736,26 @@ Segment 边界不是合法的 fragment 边界。`First + Middle* + Last` 必须
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
require flags 合法
|
require flags 合法
|
||||||
require entryCount > 0
|
require 0 < entryCount <= maxWalBatchEntryCount
|
||||||
require entriesSize > 0
|
require 0 < entriesSize <= maxWalBatchEntriesSize
|
||||||
require entriesSize == 实际 Entries bytes 长度
|
require entriesSize == 实际 Entries bytes 长度
|
||||||
require batch.baseSequence == expectedSequence
|
require batch.baseSequence == expectedSequence
|
||||||
```
|
```
|
||||||
|
|
||||||
然后顺序解析 Entries:
|
然后顺序解析 Entries。解析 `keyLen` / `valLen` varint 时,编码长度不得超过 `maxWalVarintBytes`;在分配 key/value buffer 或切片前,必须先完成长度上限和 Entry 边界校验:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
for i in 0..entryCount:
|
for i in 0..entryCount:
|
||||||
sequence = batch.baseSequence + i
|
sequence = batch.baseSequence + i
|
||||||
parse opType, valueKind, keyLen, valLen, key, value
|
parse opType, valueKind, keyLen, valLen, key, value
|
||||||
require opType ∈ {Put, Delete}
|
require opType ∈ {Put, Delete}
|
||||||
require keyLen > 0
|
require 0 < keyLen <= maxWalKeyBytes
|
||||||
require Entry 不越界
|
require Entry 不越界
|
||||||
|
|
||||||
if opType == Put:
|
if opType == Put:
|
||||||
require valueKind ∈ {Inline, ValueLogPointer}
|
require valueKind ∈ {Inline, ValueLogPointer}
|
||||||
if valueKind == Inline:
|
if valueKind == Inline:
|
||||||
valLen 可以为 0
|
require valLen <= maxWalInlineValueBytes
|
||||||
replay PutInline(key, value, sequence)
|
replay PutInline(key, value, sequence)
|
||||||
if valueKind == ValueLogPointer:
|
if valueKind == ValueLogPointer:
|
||||||
require valLen > 0
|
require valLen > 0
|
||||||
@@ -607,7 +775,29 @@ expectedSequence += entryCount
|
|||||||
lastCompleteBatchEnd = 当前 WAL offset
|
lastCompleteBatchEnd = 当前 WAL offset
|
||||||
```
|
```
|
||||||
|
|
||||||
如果 Batch Header 或 Entry 解析错误发生在 WAL 尾部,可以丢弃该 incomplete batch;如果发生在 WAL 中间,默认报错。
|
如果 Batch Header 或 Entry 解析错误发生在 CRC-valid 的完整 WAL Batch 中,即使该 Batch 位于最后一个需要恢复的
|
||||||
|
segment 的物理尾部,也必须视为 WAL 损坏并报错;它不再是典型 torn write 证据,不能通过尾部截断静默丢弃。
|
||||||
|
尾部可截断 repair 只适用于物理不完整或物理校验失败的情况:半个 Physical Record header、`length` 越过 EOF、
|
||||||
|
CRC 校验失败、非法 tail padding、或 `First + Middle*` fragment 链未出现 `Last`。
|
||||||
|
|
||||||
|
###### 尾部截断持久化
|
||||||
|
|
||||||
|
当 recovery 在最后一个需要恢复的 segment 发现可截断的 WAL 尾部损坏时,截断本身是 recovery repair 的一部分,必须在恢复完成并接受新写入前持久化。截断目标始终是最后一个完整 WAL Batch 的结束位置 `lastCompleteBatchEnd`;非最后恢复 segment 中的异常仍按 WAL 中间损坏处理,不得通过截断 repair 静默跳过。
|
||||||
|
|
||||||
|
尾部截断持久化步骤:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. ftruncate 当前 active segment 到 lastCompleteBatchEnd
|
||||||
|
2. fsync 被截断的 segment
|
||||||
|
3. 删除 startSequence == expectedSequence 且不含任何 complete batch 的后续空 segment
|
||||||
|
4. fsync WAL directory
|
||||||
|
```
|
||||||
|
|
||||||
|
以上步骤全部成功后,截断后的 WAL 文件状态本身就是 recovery repair 的持久化结果,recovery 才能进入恢复完成状态并允许 WAL writer 接受新写入。
|
||||||
|
后续再次启动时,recovery 仍从 `MANIFEST.recoverySegmentID` 开始扫描 WAL,并自然在截断后的 EOF 停止;不需要额外记录 `recoveryEndSequence`。
|
||||||
|
|
||||||
|
若 ftruncate、segment fsync、空 segment 删除或 WAL directory fsync 任一步失败,recovery 必须报错,DB 不得进入可写状态;
|
||||||
|
否则再次崩溃后可能重新暴露未持久化的截断尾部,导致重复 repair 或 recovery 边界不一致。
|
||||||
|
|
||||||
###### 恢复完成状态
|
###### 恢复完成状态
|
||||||
|
|
||||||
@@ -620,6 +810,7 @@ publishedSequence = recoveredSequence
|
|||||||
```
|
```
|
||||||
|
|
||||||
因为 WAL 中恢复出来的数据都来自已经持久化的完整 batch,所以恢复后可以全部视为 published。
|
因为 WAL 中恢复出来的数据都来自已经持久化的完整 batch,所以恢复后可以全部视为 published。
|
||||||
|
`nextSequence` 与 `publishedSequence` 是本次 recovery 扫描结果导出的内存状态,不写入 MANIFEST;下一次启动会重新从 MANIFEST recovery 起点扫描 WAL 并重新计算。
|
||||||
|
|
||||||
这里的 published 表示“恢复后对普通读可见”,不表示崩溃前调用方一定已经收到成功确认。进程崩溃但机器未掉电时,已经通过 `write()` 进入 OS page cache、但尚未完成 fsync 或尚未唤醒调用方的完整 WAL Batch,可能在进程退出后被内核刷盘。重启后如果 recovery 发现该 Batch CRC 合法、sequence 连续且完整,就会按正常 WAL 规则重放并标记为 published。这不是数据丢失或 WAL 损坏,而是未确认写入的可见性前移。
|
这里的 published 表示“恢复后对普通读可见”,不表示崩溃前调用方一定已经收到成功确认。进程崩溃但机器未掉电时,已经通过 `write()` 进入 OS page cache、但尚未完成 fsync 或尚未唤醒调用方的完整 WAL Batch,可能在进程退出后被内核刷盘。重启后如果 recovery 发现该 Batch CRC 合法、sequence 连续且完整,就会按正常 WAL 规则重放并标记为 published。这不是数据丢失或 WAL 损坏,而是未确认写入的可见性前移。
|
||||||
|
|
||||||
@@ -709,6 +900,30 @@ MemTable 可能包含已写入内存但尚未发布的 pending entry,也可能
|
|||||||
|
|
||||||
### 3.4 读路径
|
### 3.4 读路径
|
||||||
|
|
||||||
|
#### 嵌入式 Get API
|
||||||
|
|
||||||
|
第一版嵌入式 API 的 `Get` 必须显式返回 key 是否存在,不能只返回 `[]byte`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// GetResult 表示单 key 读取结果。
|
||||||
|
type GetResult struct {
|
||||||
|
// Value 是 key 当前可见版本的 value bytes。
|
||||||
|
// 当 Found 为 true 时,Value 可以是长度为 0 的合法空 value。
|
||||||
|
Value []byte
|
||||||
|
|
||||||
|
// Found 表示 key 是否存在。
|
||||||
|
// Found=false 表示 key 不存在;Found=true 且 len(Value)==0 表示 key 存在但 value 为空 bytes。
|
||||||
|
Found bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 读取 key 的当前可见值。
|
||||||
|
func (db *DB) Get(key []byte) (GetResult, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
该签名服务于两个约束:第一,`Put(key, emptyValue)` 是合法写入,读路径必须能把“存在但 value 为空”与“不存在”
|
||||||
|
区分开;第二,`ErrCommitUnknown` 后的弱状态确认需要读取当前 key 状态,如果 API 丢失 `Found` 信息,调用方无法
|
||||||
|
判断空 value 写入是否可能已经提交。
|
||||||
|
|
||||||
读取时按优先级查,从新到旧,找到第一个就返回:
|
读取时按优先级查,从新到旧,找到第一个就返回:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
# WAL Section 3.2 Oracle 审核报告 — Issues 清单
|
||||||
|
|
||||||
|
> 来源:Oracle 对 `docs/design.md` Section 3.2 WAL 的架构审核
|
||||||
|
> 日期:2026-06-09
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Issues
|
||||||
|
|
||||||
|
### C1. WAL write failure 语义过于简化:`write()` 失败后 bytes 可能已落盘
|
||||||
|
|
||||||
|
**严重程度**: Critical
|
||||||
|
**位置**: Section 3.2 "写入流程" 步骤 ⑤ 及后续错误处理段落(~line 117)
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
|
||||||
|
当前设计将 WAL encode/write 失败统一当作 "definitely failed" 返回普通错误。但实际上存在两种不同情况:
|
||||||
|
|
||||||
|
1. **Encode 失败**(未触及 syscall):确实是 definitely failed,可以安全返回普通错误
|
||||||
|
2. **`write()` 失败**(bytes 可能已进入 OS page cache 或部分写入文件):不是 definitely failed。Recovery 后可能发现该 batch CRC 合法并被重放,导致语义矛盾——调用方收到错误认为写入失败,但数据实际被恢复
|
||||||
|
|
||||||
|
**影响**: 调用方可能基于"写入失败"做非幂等业务决策(如放弃、走替代路径),但数据实际持久化了。
|
||||||
|
|
||||||
|
**建议修复**:
|
||||||
|
|
||||||
|
拆分 WAL write failure 处理:
|
||||||
|
|
||||||
|
```text
|
||||||
|
- encode 失败(未调用 write()): 普通错误,write-stopped
|
||||||
|
- write() 失败(bytes 可能已交给 OS): ErrCommitUnknown + write-stopped
|
||||||
|
除非实现能证明零 bytes 到达文件(例如 write 返回 0 且无副作用)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C2. Segment 边界跨 Batch 行为未定义
|
||||||
|
|
||||||
|
**严重程度**: Critical
|
||||||
|
**位置**: Section 3.2 "Block 边界处理"(~line 303)与 "Recovery 扫描流程"(~line 436)
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
|
||||||
|
WAL Batch 可拆成多个 Physical Record 跨多个 Block,但未定义 WAL Batch 是否可以跨 segment 文件。Recovery 中 incomplete fragment 的处理取决于是否位于"最后一个需要恢复的 segment"(~line 493):
|
||||||
|
|
||||||
|
- 如果 Batch 可以跨 segment:recovery 必须在 segment 间携带 fragment 收集状态(CollectingFragments),这增加了恢复复杂度
|
||||||
|
- 如果 Batch 不可跨 segment:需要显式约束 segment rotation 时机
|
||||||
|
|
||||||
|
当前 recovery 流程按 segment 顺序独立扫描,未定义跨 segment fragment 收集。
|
||||||
|
|
||||||
|
**影响**: 可能导致合法的跨 segment batch 被误判为中间损坏,或需要引入复杂的跨 segment 状态管理。
|
||||||
|
|
||||||
|
**建议修复**:
|
||||||
|
|
||||||
|
在 Section 3.2 明确添加约束:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WAL Batch 不得跨 segment 文件。Segment rotation 只在 WAL Batch 边界发生。
|
||||||
|
当前 segment 写入完一个完整 WAL Batch 后,如果需要轮转,在下个 Batch 写入前切换到新 segment。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C3. 预创建 segment 可破坏尾部截断逻辑
|
||||||
|
|
||||||
|
**严重程度**: Critical
|
||||||
|
**位置**: Section 3.2 "WAL 元数据持久化协议"(~line 173)与 "Physical Record 解析规则"(~line 491)
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
|
||||||
|
新 segment 创建协议(步骤 1-8)允许 `segment-N+1.wal` 在文件系统上可见(已完成 rename + directory fsync),即使它尚未承载任何 batch。如果此时进程崩溃,`segment-N.wal` 可能有 crash-torn tail。
|
||||||
|
|
||||||
|
Recovery 扫描时,因为 `segment-N+1.wal` 存在且 header 合法,`segment-N` 不再被视为"最后一个需要恢复的 segment"。按照当前的尾部/中间损坏分类规则,`segment-N` 的尾部损坏会被升级为"WAL 中间损坏"→ 报错而非截断。
|
||||||
|
|
||||||
|
**影响**: 一个本应可截断恢复的尾部 partial write 场景被错误升级为不可恢复的中间损坏,导致整个 DB 无法启动。
|
||||||
|
|
||||||
|
**建议修复**:
|
||||||
|
|
||||||
|
方案 A(推荐):禁止预创建未来 segment,直到当前 segment 在完整 batch 边界 sealed:
|
||||||
|
|
||||||
|
```text
|
||||||
|
新 segment 只在当前 active segment 写完一个完整 WAL Batch 后才创建。
|
||||||
|
确保 segment-N 永远在完整 batch 边界结束,segment-N+1 的创建不先于该 sealing。
|
||||||
|
```
|
||||||
|
|
||||||
|
方案 B:Recovery 能识别并忽略空 segment(startSequence == expectedSequence 但无任何 batch):
|
||||||
|
|
||||||
|
```text
|
||||||
|
如果 segment header 合法但不含任何 complete batch,且 startSequence == expectedSequence,
|
||||||
|
视为空 segment,跳过或删除,继续扫描下一个 segment。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C4. 恢复后未 fsync 确认的 Batch 可能变成已发布
|
||||||
|
|
||||||
|
**严重程度**: Critical
|
||||||
|
**位置**: Section 3.2 "恢复完成状态"(~line 575)与 "持久化策略"(~line 96)
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
|
||||||
|
Recovery 重放所有 CRC 合法、sequence 连续的 WAL batch,并在恢复完成后将其全部标记为 `published`。但其中可能包含崩溃前从未 fsync 确认(调用方未收到成功)的 batch。
|
||||||
|
|
||||||
|
场景:
|
||||||
|
1. WAL bytes 已通过 `write()` 写入 OS page cache
|
||||||
|
2. 进程崩溃(非掉电),OS 将 page cache 刷盘
|
||||||
|
3. 重启后 recovery 发现该 batch 完整、CRC 合法、sequence 连续
|
||||||
|
4. 该 batch 被重放并标记为 published
|
||||||
|
5. 但调用方从未收到成功确认
|
||||||
|
|
||||||
|
**影响**: `Always` 策略下,调用方收到的语义是 "Put 返回成功 = 已持久化"。但如果进程 crash(非掉电),未确认的写入可能变成已发布。这是一个 API 语义问题而非数据安全问题。
|
||||||
|
|
||||||
|
**建议修复**:
|
||||||
|
|
||||||
|
在 Section 3.2 "持久化策略" 或 "恢复完成状态" 中显式声明:
|
||||||
|
|
||||||
|
```text
|
||||||
|
进程崩溃(非掉电)后恢复时,OS page cache 中已写入但尚未 fsync 的完整 WAL Batch
|
||||||
|
可能被恢复并视为已发布。这不是数据丢失,而是数据可见性前移。
|
||||||
|
调用方必须理解:进程崩溃重启后,比掉电场景可能多恢复一些写入。
|
||||||
|
|
||||||
|
如果需要严格区分"调用方已确认"与"未确认但存在于 WAL",需要后续引入
|
||||||
|
durable commit marker 或 confirmed-sequence 元数据。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C5. Go 内存模型:lock-free read 需要原子发布机制
|
||||||
|
|
||||||
|
**严重程度**: Critical
|
||||||
|
**位置**: Section 3.3 MemTable "并发策略"(~line 600)与 Section 3.2 步骤 ⑥⑧(~line 109-112)
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
|
||||||
|
Section 3.3 声明 MemTable 使用 "Mutex 写 + 无锁读",Section 3.2 步骤 ⑥ 在 fsync 前将 entry 写入 MemTable(pending 状态),步骤 ⑧ 通过更新 `publishedSequence` 使 entry 对无锁读可见。
|
||||||
|
|
||||||
|
在 Go 内存模型中:
|
||||||
|
1. **Skiplist 节点发布**:Mutex 保护下的写入对未持锁的并发读者不一定可见。需要 `atomic.Pointer` 或等效发布机制确保节点对读者可见。
|
||||||
|
2. **`publishedSequence` 更新**:作为普通变量写入,无锁读者可能看到过时值或部分写入。必须是 atomic 操作。
|
||||||
|
|
||||||
|
**影响**: 在 ARM 架构(弱内存序)上可能出现读者看到 `publishedSequence` 已更新但对应 skiplist 节点尚未可见的情况,导致读到不一致数据。
|
||||||
|
|
||||||
|
**建议修复**:
|
||||||
|
|
||||||
|
在 Section 3.2 或 3.3 中明确内存序要求:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. MemTable skiplist 节点必须通过 atomic store(atomic.Pointer 或自定义 release 操作)发布,
|
||||||
|
确保无锁读者看到完整的节点内容。
|
||||||
|
2. publishedSequence 必须是 atomic 变量(atomic.Uint64),
|
||||||
|
且其 Store 必须在所有 batch entries 的 skiplist 节点都已原子发布之后执行。
|
||||||
|
这保证读者先看到节点,再通过 publishedSequence 筛选可见 entry。
|
||||||
|
3. 读者必须先 atomic Load publishedSequence,再遍历 skiplist。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Important Issues
|
||||||
|
|
||||||
|
### I1. MemTable 写入失败(Arena 满)在 WAL 写入后未覆盖
|
||||||
|
|
||||||
|
**严重程度**: Important
|
||||||
|
**位置**: Section 3.2 "写入流程" 步骤 ⑥(~line 109)
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
|
||||||
|
写入流程步骤 ⑤(WAL encode/write)成功后,步骤 ⑥ 写入 MemTable 可能因为 Arena 满而失败。此时 WAL bytes 已持久化(或已在 page cache),但 MemTable 中没有对应 entry。设计文档未覆盖此场景。
|
||||||
|
|
||||||
|
**建议修复**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
方案 A(推荐):在 WAL write 之前保证 MemTable 有足够容量。
|
||||||
|
写入前检查 Arena 剩余空间,不足时先冻结 MemTable 并创建新 MemTable。
|
||||||
|
Arena 预留必须考虑最大可能的 batch size。
|
||||||
|
|
||||||
|
方案 B:MemTable 写入失败后按 ErrCommitUnknown + write-stopped 处理。
|
||||||
|
因为 WAL bytes 可能已持久化,不能按普通错误处理。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I2. `CURRENT` 文件权威性与实际恢复模型不一致
|
||||||
|
|
||||||
|
**严重程度**: Important
|
||||||
|
**位置**: Section 3.2 "CURRENT / MANIFEST 权威性"(~line 420)与 "WAL 元数据持久化协议"(~line 181)
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
|
||||||
|
设计明确声明 `CURRENT` 只是写入侧辅助文件,recovery 权威源是 `MANIFEST + 目录扫描`。但 durable-ready 协议要求在 segment 可承载写入前更新 `CURRENT` 并 fsync(步骤 6-7)。这意味着 `CURRENT` 更新是 batch 确认成功的前提之一,但 recovery 又不依赖它。
|
||||||
|
|
||||||
|
**建议修复**:
|
||||||
|
|
||||||
|
选择一种并保持一致:
|
||||||
|
|
||||||
|
```text
|
||||||
|
方案 A(推荐):简化 durable-ready 协议,移除 CURRENT 更新作为 batch 确认前提。
|
||||||
|
Recovery 通过 MANIFEST + 目录扫描发现 segment,CURRENT 仅作为写入侧快速定位优化。
|
||||||
|
新 segment 只需 rename + WAL directory fsync 即可进入 durable-ready。
|
||||||
|
|
||||||
|
方案 B:让 CURRENT 成为 recovery 的必要组件。
|
||||||
|
这样需要处理 CURRENT 损坏/缺失的 fallback,增加恢复复杂度。不推荐。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I3. 尾部截断后缺少持久化步骤
|
||||||
|
|
||||||
|
**严重程度**: Important
|
||||||
|
**位置**: Section 3.2 "Physical Record 解析规则" 尾部损坏处理(~line 495)
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
|
||||||
|
Recovery 允许截断最后一个 segment 的尾部损坏。但截断操作本身(`ftruncate` + 删除后续空 segment)需要 fsync 才能在再次崩溃时保持一致性。设计文档未说明截断后的持久化步骤。
|
||||||
|
|
||||||
|
**建议修复**:
|
||||||
|
|
||||||
|
在 "恢复完成状态" 之后或 "Recovery 扫描流程" 末尾添加:
|
||||||
|
|
||||||
|
```text
|
||||||
|
截断持久化步骤:
|
||||||
|
1. ftruncate active segment 到 lastCompleteBatchEnd
|
||||||
|
2. fsync truncated segment
|
||||||
|
3. 删除 startSequence == expectedSequence 但无 complete batch 的后续空 segment
|
||||||
|
4. fsync WAL directory
|
||||||
|
5. 更新 MANIFEST 记录恢复终点
|
||||||
|
6. fsync metadata directory
|
||||||
|
以上完成后,引擎才能开始接受新写入。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I4. Batch 校验缺少资源上限
|
||||||
|
|
||||||
|
**严重程度**: Important
|
||||||
|
**位置**: Section 3.2 "WAL Batch 校验与重放"(~line 530)
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
|
||||||
|
Recovery 校验 `entryCount`、`entriesSize` 和 entry 边界,但未定义任何资源上限。恶意或损坏的 WAL 可能包含极大的 `entryCount` 或 `entriesSize`,导致 recovery OOM 或无限循环。
|
||||||
|
|
||||||
|
**建议修复**:
|
||||||
|
|
||||||
|
在 Section 3.2 添加硬性限制:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WAL Batch 资源上限(可配置,建议默认值):
|
||||||
|
- entryCount: 最大 10,000
|
||||||
|
- entriesSize: 最大 4MB
|
||||||
|
- 单个 keyLen: 最大 4KB(不含 value)
|
||||||
|
- 单个 valLen (Inline): 最大 4KB(超过走 ValueLogPointer)
|
||||||
|
- fragment buffer: 最大 entriesSize 上限
|
||||||
|
- varint: 最大 5 bytes(u64 varint 上限)
|
||||||
|
|
||||||
|
Recovery 解析时,超过任何上限即视为 WAL 损坏。
|
||||||
|
写入侧也必须遵守这些限制,超出拒绝写入。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I5. `publishedSequence` 需要明确的内存序约束
|
||||||
|
|
||||||
|
**严重程度**: Important
|
||||||
|
**位置**: Section 3.2 "可见性语义"(~line 148)
|
||||||
|
|
||||||
|
**问题描述**:
|
||||||
|
|
||||||
|
`publishedSequence` 作为普通变量描述其语义,但未说明其在 Go 内存模型中的操作类型。多 goroutine 并发读写需要明确的 happens-before 关系。
|
||||||
|
|
||||||
|
**建议修复**:
|
||||||
|
|
||||||
|
在 "可见性语义" 小节补充:
|
||||||
|
|
||||||
|
```text
|
||||||
|
publishedSequence 的内存序约束:
|
||||||
|
1. 类型:atomic.Uint64(或等效原子变量)
|
||||||
|
2. 写入侧:Store 只在 WAL durability 和所有 MemTable 节点原子发布都完成后执行
|
||||||
|
3. 读取侧:Load 在遍历 MemTable 前执行,获得可见性 high-water mark
|
||||||
|
4. Happens-before 关系:
|
||||||
|
WAL fsync 完成 → MemTable 节点原子发布 → publishedSequence.Store
|
||||||
|
→ 读者 publishedSequence.Load → 遍历 MemTable 筛选可见 entry
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 变更追踪
|
||||||
|
|
||||||
|
| Issue | 类型 | 优先级 | 状态 |
|
||||||
|
|-------|------|--------|------|
|
||||||
|
| C1 | 语义正确性 | Critical | Open |
|
||||||
|
| C2 | 格式完整性 | Critical | Open |
|
||||||
|
| C3 | 恢复正确性 | Critical | Open |
|
||||||
|
| C4 | API 语义 | Critical | Open |
|
||||||
|
| C5 | 内存安全 | Critical | Open |
|
||||||
|
| I1 | 错误处理完整性 | Important | Open |
|
||||||
|
| I2 | 设计一致性 | Important | Open |
|
||||||
|
| I3 | 持久化完整性 | Important | Open |
|
||||||
|
| I4 | 安全性/鲁棒性 | Important | Open |
|
||||||
|
| I5 | 内存序正确性 | Important | Open |
|
||||||
@@ -0,0 +1,640 @@
|
|||||||
|
# Phase 1: WAL 子系统开发方案
|
||||||
|
|
||||||
|
基于 `docs/design.md` §3.2 设计文档。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
实现完整的 WAL(预写日志)子系统,使其能够支撑单 key autocommit 的写入、崩溃恢复和读可见性语义。
|
||||||
|
|
||||||
|
## 开发阶段总览
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1A: 项目骨架 + WAL 编码格式层
|
||||||
|
Phase 1B: WAL 文件写入 + Segment 管理
|
||||||
|
Phase 1C: WAL Writer(Group Commit)
|
||||||
|
Phase 1D: WAL Recovery
|
||||||
|
Phase 1E: MemTable(SkipList + Arena)
|
||||||
|
Phase 1F: 写入路径集成(WAL → MemTable 完整流水线)
|
||||||
|
Phase 1G: 读路径 + 嵌入式 API
|
||||||
|
Phase 1H: MANIFEST + 文件管理
|
||||||
|
Phase 1I: 集成测试 + Benchmark
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1A: 项目骨架 + WAL 编码格式层
|
||||||
|
|
||||||
|
**目标**: 建立 Go 项目结构,实现 WAL 物理格式(Block / Physical Record / WAL Batch / Entry)的编码与解码。
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
#### 1A-1: 项目初始化
|
||||||
|
- `go.mod` 初始化(模块名 `github.com/dailz/go-kv`)
|
||||||
|
- 目录结构:
|
||||||
|
```
|
||||||
|
go-kv/
|
||||||
|
├── go.mod
|
||||||
|
├── wal/ # WAL 子系统
|
||||||
|
│ ├── wal.go # 公共类型、常量、配置
|
||||||
|
│ ├── record.go # Physical Record 编解码
|
||||||
|
│ ├── batch.go # WAL Batch 编解码
|
||||||
|
│ ├── entry.go # Entry 编解码
|
||||||
|
│ ├── header.go # WAL File Header 编解码
|
||||||
|
│ └── wal_test.go
|
||||||
|
├── memtable/ # MemTable(Phase 1E)
|
||||||
|
├── config/ # 全局配置
|
||||||
|
├── errors.go # 公共错误类型
|
||||||
|
└── db.go # DB 入口
|
||||||
|
```
|
||||||
|
- `.golangci.yml` 配置(参考 golang-lint skill)
|
||||||
|
|
||||||
|
#### 1A-2: 公共错误类型 (`errors.go`)
|
||||||
|
- `ErrCommitUnknown` — maybe committed 语义
|
||||||
|
- `ErrWriteStopped` — 引擎 write-stopped
|
||||||
|
- `ErrSequenceExhausted` — sequence 耗尽
|
||||||
|
- `ErrWALCorrupted` — WAL 损坏
|
||||||
|
- `ErrInvalidConfig` — 配置不合法
|
||||||
|
|
||||||
|
#### 1A-3: WAL 常量与配置 (`wal/wal.go`)
|
||||||
|
```go
|
||||||
|
const (
|
||||||
|
WalMagic uint32 = 0x... // 待定
|
||||||
|
WalFormatVersion uint16 = 1
|
||||||
|
WalFileHeaderSize = 32
|
||||||
|
WalBlockSize = 32 * 1024 // 32KB
|
||||||
|
PhysicalRecordHeaderSize = 7
|
||||||
|
WalBatchHeaderSize = 18
|
||||||
|
MaxWalBatchEntryCount = 10_000
|
||||||
|
MaxWalBatchEntriesSize = 4 * 1024 * 1024 // 4MB
|
||||||
|
MaxWalKeyBytes = 4 * 1024 // 4KB
|
||||||
|
MaxWalInlineValueBytes = 4 * 1024 // 4KB
|
||||||
|
MaxWalVarintBytes = 5
|
||||||
|
DefaultMaxWalSegmentSize = 64 * 1024 * 1024 // 64MB
|
||||||
|
DefaultImmutableCount = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fragment types
|
||||||
|
const (
|
||||||
|
RecInvalid uint8 = 0
|
||||||
|
RecFull uint8 = 1
|
||||||
|
RecFirst uint8 = 2
|
||||||
|
RecMiddle uint8 = 3
|
||||||
|
RecLast uint8 = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpType
|
||||||
|
const (
|
||||||
|
OpInvalid uint8 = 0
|
||||||
|
OpPut uint8 = 1
|
||||||
|
OpDelete uint8 = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValueKind
|
||||||
|
const (
|
||||||
|
VKNone uint8 = 0
|
||||||
|
VKInline uint8 = 1
|
||||||
|
VKValueLogPointer uint8 = 2
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
WAL 配置结构体:
|
||||||
|
```go
|
||||||
|
type WalConfig struct {
|
||||||
|
MaxSegmentSize uint64 // default 64MB
|
||||||
|
BlockSize uint32 // default 32KB
|
||||||
|
SyncMode SyncMode // Always/Periodic/Never
|
||||||
|
PeriodicSyncMs uint32 // Periodic 模式的 fsync 间隔
|
||||||
|
MaxBatchEntries uint32 // default 10000
|
||||||
|
MaxBatchSize uint32 // default 4MB
|
||||||
|
MaxKeyBytes uint32 // default 4KB
|
||||||
|
MaxInlineValue uint32 // default 4KB
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
配置校验函数 — 必须在 DB 打开时验证不变量:
|
||||||
|
```text
|
||||||
|
maxWalSegmentPayload >= maxEncodedWalBatchSize + worstCasePhysicalRecordOverhead + worstCaseBlockPadding
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1A-4: WAL File Header 编解码 (`wal/header.go`)
|
||||||
|
- `WalFileHeader` 结构体:magic, formatVersion, headerSize, blockSize, segmentID, startSequence, headerCRC
|
||||||
|
- `EncodeWalHeader(h *WalFileHeader) [WalFileHeaderSize]byte`
|
||||||
|
- `DecodeWalHeader(data []byte) (*WalFileHeader, error)` — 校验 magic、formatVersion、headerSize、headerCRC
|
||||||
|
- CRC 覆盖范围:magic 到 startSequence,不包含 headerCRC 自身
|
||||||
|
- 字节序:little-endian
|
||||||
|
|
||||||
|
#### 1A-5: Physical Record 编解码 (`wal/record.go`)
|
||||||
|
- `PhysicalRecord` 结构体:CRC, Length, Type, Payload
|
||||||
|
- `EncodePhysicalRecord(recType uint8, payload []byte) []byte` — 返回编码后的 bytes
|
||||||
|
- `DecodePhysicalRecord(data []byte) (*PhysicalRecord, error)` — CRC 校验
|
||||||
|
- Block 边界处理辅助函数:
|
||||||
|
- `PaddingNeeded(blockOffset, blockSize uint32) int` — 剩余空间 <= 7 时返回需要 padding 的字节数
|
||||||
|
- `CanFitRecord(blockOffset, blockSize, payloadLen uint32) bool`
|
||||||
|
|
||||||
|
#### 1A-6: WAL Batch 编解码 (`wal/batch.go`)
|
||||||
|
- `WalBatch` 结构体:Flags, BaseSequence, EntryCount, EntriesSize, Entries
|
||||||
|
- `EncodeWalBatch(batch *WalBatch) ([]byte, error)` — 编码 Batch Header + Entries
|
||||||
|
- `DecodeWalBatch(data []byte) (*WalBatch, error)` — 校验 flags、entryCount、entriesSize
|
||||||
|
- Batch 分片:`SplitIntoRecords(encodedBatch []byte, blockSize uint32) [][]byte` — 将编码后的 Batch 拆分为 Physical Record payloads
|
||||||
|
- Batch 重组:`FragmentCollector` — 收集 fragments 并重组成完整 Batch
|
||||||
|
|
||||||
|
FragmentCollector 状态机:
|
||||||
|
```
|
||||||
|
Idle → 收到 Full → 重放 batch → Idle
|
||||||
|
Idle → 收到 First → CollectingFragments
|
||||||
|
CollectingFragments → 收到 Middle → 追加
|
||||||
|
CollectingFragments → 收到 Last → 重组 → 重放 → Idle
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1A-7: Entry 编解码 (`wal/entry.go`)
|
||||||
|
- `WalEntry` 结构体:OpType, ValueKind, Key, Value
|
||||||
|
- `EncodeEntry(e *WalEntry) ([]byte, error)` — 编码为 varint 长度 + bytes
|
||||||
|
- `DecodeEntry(data []byte) (*WalEntry, int, error)` — 解码,返回 entry 和 consumed bytes
|
||||||
|
- 校验规则:
|
||||||
|
- keyLen > 0 && keyLen <= maxKeyBytes
|
||||||
|
- Put 要求 valueKind ∈ {Inline, ValueLogPointer}
|
||||||
|
- Put + Inline: valLen <= maxInlineValueBytes (允许 valLen = 0)
|
||||||
|
- Put + ValueLogPointer: valLen > 0
|
||||||
|
- Delete: valueKind == None, valLen == 0
|
||||||
|
|
||||||
|
#### 1A-8: WAL Batch 资源校验
|
||||||
|
- `ValidateBatchLimits(entries []*WalEntry) error` — 在 sequence 分配之前检查:
|
||||||
|
- entryCount <= maxBatchEntries
|
||||||
|
- 每个 keyLen <= maxKeyBytes
|
||||||
|
- 每个 inline valLen <= maxInlineValueBytes
|
||||||
|
- entries 编码后总大小 <= maxBatchSize
|
||||||
|
- 单个 Batch 的最坏 Physical Record overhead 不超过 segment capacity
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- [ ] 所有编解码函数有 table-driven test
|
||||||
|
- [ ] CRC 校验正确
|
||||||
|
- [ ] Fragment 分片/重组 round-trip 正确
|
||||||
|
- [ ] 资源限制校验覆盖所有边界条件
|
||||||
|
- [ ] `go vet` / `golangci-lint` 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1B: WAL 文件写入 + Segment 管理
|
||||||
|
|
||||||
|
**目标**: 实现 WAL segment 文件的写入、轮转和持久化协议。
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
#### 1B-1: Segment 文件格式写入器 (`wal/segment_writer.go`)
|
||||||
|
- `SegmentWriter` — 封装 WAL segment 文件的追加写入
|
||||||
|
- 状态:当前 segment fd、当前 block offset、当前 segmentID、payload written bytes
|
||||||
|
- `NewSegmentWriter(dir string, segmentID uint64, startSequence uint64, cfg *WalConfig) (*SegmentWriter, error)`
|
||||||
|
- 创建 segment-N.wal.tmp
|
||||||
|
- 写入 WAL File Header
|
||||||
|
- fsync
|
||||||
|
- rename → segment-N.wal
|
||||||
|
- fsync directory
|
||||||
|
- 进入 durable-ready 状态
|
||||||
|
- `AppendBatch(batch *WalBatch) error` — 编码 batch → split into records → 按 block 边界写入
|
||||||
|
- `Sync() error` — fsync 当前 segment 文件
|
||||||
|
- `Close() error`
|
||||||
|
- `RemainingPayload() uint64` — 当前 segment 剩余可用 payload 空间
|
||||||
|
- `CurrentOffset() uint64` — 当前写入偏移
|
||||||
|
|
||||||
|
#### 1B-2: Block 写入缓冲 (`wal/block_writer.go`)
|
||||||
|
- 管理 32KB block 的填充和 padding
|
||||||
|
- `BlockWriter` — 封装 block 内的 Physical Record 写入
|
||||||
|
- 自动处理 block 边界:剩余 <= 7 bytes 时 padding
|
||||||
|
- 跨 block 的 batch fragment 自动拆分
|
||||||
|
|
||||||
|
#### 1B-3: Segment 轮转逻辑
|
||||||
|
- 写入 batch 前检查 `RemainingPayload()` 是否足够容纳整个 batch
|
||||||
|
- 不足时:当前 segment 完成(在 batch 边界)、创建新 segment
|
||||||
|
- 新 segment 的 durable-ready 协议:
|
||||||
|
1. create segment-N+1.wal.tmp
|
||||||
|
2. write WAL File Header(含 startSequence = nextExpectedSequence)
|
||||||
|
3. fsync segment-N+1.wal.tmp
|
||||||
|
4. rename → segment-N+1.wal
|
||||||
|
5. fsync WAL directory
|
||||||
|
6. segment-N+1 进入 durable-ready
|
||||||
|
- 旧的 active segment 密封
|
||||||
|
|
||||||
|
#### 1B-4: CURRENT 文件管理
|
||||||
|
- best-effort 更新 CURRENT 文件
|
||||||
|
- temp + rename 模式
|
||||||
|
- 更新失败不影响已 durable-ready 的 segment
|
||||||
|
|
||||||
|
#### 1B-5: WAL 目录管理工具
|
||||||
|
- 扫描 WAL 目录中的 segment 文件
|
||||||
|
- 按 segmentID 排序
|
||||||
|
- 解析文件名中的 segmentID
|
||||||
|
- 文件名格式:`segment-{id}.wal`
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- [ ] Segment 创建遵循 durable-ready 协议
|
||||||
|
- [ ] Batch 不跨 segment
|
||||||
|
- [ ] Block padding 正确
|
||||||
|
- [ ] Segment 轮转在 batch 边界发生
|
||||||
|
- [ ] 多 segment 写入后,每个 segment 的 header 可以正确解析
|
||||||
|
- [ ] 测试覆盖:正常写入、跨 block batch、segment 轮转触发
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1C: WAL Writer(Group Commit)
|
||||||
|
|
||||||
|
**目标**: 实现完整的 WAL 写入路径,包括 group commit、sequence 管理、fsync 策略和错误分类。
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
#### 1C-1: Sequence 管理器 (`wal/sequence.go`)
|
||||||
|
- `SequenceManager` — 管理 WAL 物理 mutation sequence
|
||||||
|
- `atomic.Uint64` 存储 nextSequence、publishedSequence、durableSequence
|
||||||
|
- `AllocateBatch(count uint32) (baseSequence uint64, err error)` — checked arithmetic 检查溢出
|
||||||
|
- `Publish(sequence uint64)` — release 语义 store publishedSequence
|
||||||
|
- `MarkDurable(snapshot SegmentEndState)` — 推进 durableSequence
|
||||||
|
- `Published() uint64` — load publishedSequence
|
||||||
|
- `Durable() uint64` — load durableSequence
|
||||||
|
|
||||||
|
#### 1C-2: Commit Queue (`wal/commit_queue.go`)
|
||||||
|
- 写请求进入的队列
|
||||||
|
- 每个写请求关联一个 `*sync.Cond` 或 channel 用于等待/唤醒
|
||||||
|
- `CommitBatch` 结构体:entries、完成 channel、错误结果、baseSequence
|
||||||
|
|
||||||
|
#### 1C-3: WAL Writer 主循环 (`wal/writer.go`)
|
||||||
|
核心写入循环:
|
||||||
|
```
|
||||||
|
loop:
|
||||||
|
1. 从 commit queue 收集一批写入
|
||||||
|
2. 等待触发条件(500µs 或 32KB)或 queue 非空
|
||||||
|
3. 组装 WAL Batch
|
||||||
|
4. 校验 batch 资源限制
|
||||||
|
5. 预留 MemTable Arena 容量
|
||||||
|
6. 分配 sequence(baseSequence)
|
||||||
|
7. 在私有缓冲区编码 WAL Batch
|
||||||
|
8. 检查/触发 segment 轮转
|
||||||
|
9. Append WAL Batch 到 segment 文件
|
||||||
|
10. 写入 MemTable(pending/unpublished)
|
||||||
|
11. fsync(Always 模式)
|
||||||
|
12. 发布 publishedSequence
|
||||||
|
13. 唤醒所有等待的调用方
|
||||||
|
```
|
||||||
|
|
||||||
|
错误分类逻辑:
|
||||||
|
- 步骤 4-7 失败(未分配 sequence)→ 普通错误,可继续
|
||||||
|
- 步骤 6 后失败(sequence 已分配)→ write-stopped
|
||||||
|
- 步骤 9 后失败(WAL write 已尝试)→ ErrCommitUnknown + write-stopped
|
||||||
|
- 步骤 11 失败(fsync)→ ErrCommitUnknown + write-stopped
|
||||||
|
|
||||||
|
#### 1C-4: Fsync 策略实现 (`wal/fsync.go`)
|
||||||
|
- `SyncMode` 类型:Always / Periodic / Never
|
||||||
|
- `Always`: 每次 batch fsync 后再 publish
|
||||||
|
- `Periodic`: 后台 goroutine 定期 fsync,write 成功即可 publish
|
||||||
|
- `Never`: 不主动 fsync
|
||||||
|
- `Periodic` 的 fsync worker:
|
||||||
|
- 快照当前 append high-water mark: (segmentID, endOffset, endSequence)
|
||||||
|
- fsync 成功后按连续 batch 推进 durableSequence
|
||||||
|
- fsync 失败 → write-stopped
|
||||||
|
|
||||||
|
#### 1C-5: durableSequence 推进逻辑
|
||||||
|
- 每个 batch 记录 `(segmentID, endOffset, endSequence)`
|
||||||
|
- fsync snapshot 后只推进满足条件的最大连续 batch
|
||||||
|
- 跨 segment 推进需要 segment 已 durable-ready
|
||||||
|
|
||||||
|
#### 1C-6: Write-Stopped 状态管理
|
||||||
|
- `atomic.Bool` 存储 writeStopped
|
||||||
|
- 进入 write-stopped 后拒绝新写入
|
||||||
|
- 已存在的 MemTable / Immutable MemTable 可继续后台处理
|
||||||
|
- 提供 `IsWriteStopped() bool` 查询接口
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- [ ] Group commit 正确合并多个写请求
|
||||||
|
- [ ] 双触发(时间/大小)工作正常
|
||||||
|
- [ ] Sequence 分配无溢出
|
||||||
|
- [ ] Always 模式下 publish 在 fsync 之后
|
||||||
|
- [ ] 错误分类准确(普通错误 / write-stopped / ErrCommitUnknown)
|
||||||
|
- [ ] 并发写入正确(多 goroutine 同时 Put)
|
||||||
|
- [ ] Write-stopped 后新写入被拒绝
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1D: WAL Recovery
|
||||||
|
|
||||||
|
**目标**: 实现 WAL 崩溃恢复,包括 segment 扫描、fragment 重组、batch 校验和尾部截断。
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
#### 1D-1: Segment 扫描器 (`wal/scanner.go`)
|
||||||
|
- 从 WAL 目录扫描 segment 文件
|
||||||
|
- 按 segmentID 排序
|
||||||
|
- 从 MANIFEST 指定的 recoverySegmentID 开始
|
||||||
|
- 过滤掉 segmentID < recoverySegmentID 的旧 segment
|
||||||
|
- 校验连续性:segmentID 和 startSequence 都必须连续
|
||||||
|
|
||||||
|
#### 1D-2: Physical Record 解析器 (`wal/record_parser.go`)
|
||||||
|
- Block 级别的顺序解析
|
||||||
|
- 处理 padding(全 0 校验)
|
||||||
|
- Physical Record header 解析和 CRC 校验
|
||||||
|
- 错误分类:尾部 vs 中间损坏
|
||||||
|
|
||||||
|
#### 1D-3: Fragment 重组器 (`wal/fragment_collector.go`)
|
||||||
|
- 实现 Idle / CollectingFragments 状态机
|
||||||
|
- 收集 First / Middle / Last fragments
|
||||||
|
- Buffer 大小限制(Batch Header 长度 + entriesSize 上限)
|
||||||
|
- Fragment 顺序合法性检查
|
||||||
|
|
||||||
|
#### 1D-4: Batch 校验与重放 (`wal/recovery.go`)
|
||||||
|
- Batch Header 校验:flags、entryCount、entriesSize
|
||||||
|
- Batch sequence 连续性:batch.baseSequence == expectedSequence
|
||||||
|
- Entry 逐条校验:opType、valueKind、keyLen、valLen
|
||||||
|
- 重放回调:对每个合法 entry 调用 replay 函数
|
||||||
|
- Sequence 推进:expectedSequence += entryCount
|
||||||
|
|
||||||
|
#### 1D-5: 尾部截断持久化 (`wal/truncation.go`)
|
||||||
|
- 识别最后一个完整 batch 的结束位置
|
||||||
|
- ftruncate segment 文件
|
||||||
|
- fsync 被截断的 segment
|
||||||
|
- 删除不含任何 complete batch 的后续空 segment
|
||||||
|
- fsync WAL directory
|
||||||
|
- 任一步失败 → recovery 报错
|
||||||
|
|
||||||
|
#### 1D-6: Recovery 主流程 (`wal/recovery.go`)
|
||||||
|
```
|
||||||
|
1. 读取 MANIFEST → recoverySegmentID
|
||||||
|
2. 扫描 WAL 目录 → 过滤出 recovery segments
|
||||||
|
3. 排序并校验连续性
|
||||||
|
4. 逐 segment 扫描:
|
||||||
|
a. 校验 File Header
|
||||||
|
b. 逐 Block 解析 Physical Records
|
||||||
|
c. Fragment 重组 → 完整 Batch
|
||||||
|
d. Batch 校验 → 重放
|
||||||
|
e. 更新 expectedSequence
|
||||||
|
5. 处理尾部异常
|
||||||
|
6. 持久化截断(如需要)
|
||||||
|
7. 返回恢复结果:recoveredSequence, nextSequence, publishedSequence
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- [ ] 正常 WAL 完整恢复
|
||||||
|
- [ ] 尾部 partial write 正确截断
|
||||||
|
- [ ] 中间损坏正确报错
|
||||||
|
- [ ] 跨 segment 恢复正确
|
||||||
|
- [ ] Fragment 重组 round-trip 正确
|
||||||
|
- [ ] Segment 连续性校验
|
||||||
|
- [ ] Sequence 溢出检测
|
||||||
|
- [ ] 资源限制校验(recovery 侧)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1E: MemTable(SkipList + Arena)
|
||||||
|
|
||||||
|
**目标**: 实现基于 Arena 的 SkipList,支持 pending/unpublished/aborted 状态,容量预留,和原子发布。
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
#### 1E-1: Arena 分配器 (`memtable/arena.go`)
|
||||||
|
- 固定大小 Arena(默认 64MB)
|
||||||
|
- 线程安全的内存分配
|
||||||
|
- 对齐分配
|
||||||
|
- 剩余容量查询
|
||||||
|
- 支持预留(reserve)操作
|
||||||
|
|
||||||
|
#### 1E-2: SkipList (`memtable/skiplist.go`)
|
||||||
|
- 最大 20 层
|
||||||
|
- Mutex 写 + 无锁读
|
||||||
|
- `atomic.Pointer` 发布 next 指针(release 语义)
|
||||||
|
- 有序遍历(Iterator)
|
||||||
|
- key 比较(bytes comparison)
|
||||||
|
|
||||||
|
#### 1E-3: Entry 状态管理 (`memtable/entry.go`)
|
||||||
|
- Entry 结构:key、value、sequence、pending/aborted 标记
|
||||||
|
- 原子发布:`atomic.Pointer` store-release
|
||||||
|
- 可见性判断:`entry.sequence <= loadedPublishedSequence && !aborted`
|
||||||
|
|
||||||
|
#### 1E-4: MemTable (`memtable/memtable.go`)
|
||||||
|
- 封装 SkipList + Arena
|
||||||
|
- `Put(key, value, sequence) error` — 写入 pending entry
|
||||||
|
- `PublishEntries(upToSequence)` — 批量发布 pending entries
|
||||||
|
- `AbortEntries(fromSequence)` — 标记 aborted
|
||||||
|
- `Get(key, publishedSequence) (GetResult, error)` — 无锁读,只返回 sequence <= publishedSequence 且非 aborted 的 entry
|
||||||
|
- `NewIterator(publishedSequence) Iterator` — 无锁有序遍历
|
||||||
|
- `ApproximateSize() uint64` — 近似内存使用量
|
||||||
|
- `IsFull() bool`
|
||||||
|
- `Reserve(entries []ReserveEntry) (uint64, error)` — 容量预留(最坏情况计算)
|
||||||
|
|
||||||
|
#### 1E-5: 容量预留计算
|
||||||
|
- 每个 entry 的预留大小 = key bytes + value bytes + skiplist node overhead + next 指针数组(最大层高)+ arena 对齐 padding
|
||||||
|
- 批量预留必须覆盖整个 batch
|
||||||
|
- checked arithmetic 检查单个 batch 是否超过空 MemTable 容量
|
||||||
|
|
||||||
|
#### 1E-6: Immutable MemTable 管理
|
||||||
|
- Freeze 流程:当前 MemTable → Immutable
|
||||||
|
- Immutable 队列(上限 2)
|
||||||
|
- 队列满时阻塞
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- [ ] SkipList 正确性:插入、查找、有序遍历
|
||||||
|
- [ ] Arena 分配无泄漏
|
||||||
|
- [ ] 并发读写正确(racetest)
|
||||||
|
- [ ] pending/unpublished entry 对读不可见
|
||||||
|
- [ ] 发布后 entry 可见
|
||||||
|
- [ ] aborted entry 对读不可见
|
||||||
|
- [ ] 容量预留准确
|
||||||
|
- [ ] 内存序正确(go test -race 通过)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1F: 写入路径集成
|
||||||
|
|
||||||
|
**目标**: 将 WAL Writer 和 MemTable 连通,实现完整的写入流水线。
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
#### 1F-1: DB 写入 API (`db.go`)
|
||||||
|
```go
|
||||||
|
type DB struct { ... }
|
||||||
|
|
||||||
|
func Open(dir string, opts ...Option) (*DB, error)
|
||||||
|
func (db *DB) Close() error
|
||||||
|
func (db *DB) Put(key, value []byte) error
|
||||||
|
func (db *DB) Delete(key []byte) error
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1F-2: 写入路径集成
|
||||||
|
完整写入流程:
|
||||||
|
```
|
||||||
|
Put(key, value)
|
||||||
|
→ commit queue
|
||||||
|
→ group commit 组装 batch
|
||||||
|
→ 校验 batch limits
|
||||||
|
→ 预留 MemTable Arena
|
||||||
|
→ 分配 sequence
|
||||||
|
→ 私有缓冲编码
|
||||||
|
→ 检查 segment 轮转
|
||||||
|
→ WAL append
|
||||||
|
→ MemTable pending write
|
||||||
|
→ fsync(Always 模式)
|
||||||
|
→ 原子发布 MemTable entries
|
||||||
|
→ 推进 publishedSequence
|
||||||
|
→ 唤醒调用方
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1F-3: MemTable Freeze + Switch
|
||||||
|
- 写入前检查容量,不足时 freeze + switch
|
||||||
|
- Immutable 队列满时阻塞等待
|
||||||
|
- Freeze 时确保当前 MemTable 已完成所有 pending 发布
|
||||||
|
|
||||||
|
#### 1F-4: 恢复启动集成
|
||||||
|
- Open 时执行 recovery
|
||||||
|
- 恢复的 entries 写入 MemTable 并标记为 published
|
||||||
|
- 设置 nextSequence、publishedSequence
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- [ ] 单条 Put 写入成功
|
||||||
|
- [ ] 并发 Put 正确
|
||||||
|
- [ ] 写入后读取可见(Always 模式)
|
||||||
|
- [ ] WAL crash recovery 后数据完整
|
||||||
|
- [ ] MemTable freeze/switch 正确
|
||||||
|
- [ ] Sequence 连续无间隙
|
||||||
|
- [ ] `go test -race` 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1G: 读路径 + 嵌入式 API
|
||||||
|
|
||||||
|
**目标**: 实现完整的读路径和嵌入式 API。
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
#### 1G-1: Get API
|
||||||
|
```go
|
||||||
|
type GetResult struct {
|
||||||
|
Value []byte
|
||||||
|
Found bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) Get(key []byte) (GetResult, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1G-2: 读路径实现
|
||||||
|
- 读取 publishedSequence(atomic load)
|
||||||
|
- 遍历 MemTable → Immutable MemTables
|
||||||
|
- 只返回 `sequence <= publishedSequence` 且非 aborted 的 entry
|
||||||
|
- Delete (tombstone) 返回 `Found=false`
|
||||||
|
|
||||||
|
#### 1G-3: 辅助 API
|
||||||
|
```go
|
||||||
|
func (db *DB) GetDurableSequence() uint64
|
||||||
|
func (db *DB) IsWriteStopped() bool
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- [ ] Put 后 Get 返回正确值
|
||||||
|
- [ ] Delete 后 Get 返回 Found=false
|
||||||
|
- [ ] 空 value 正确区分(Found=true, Value=[]byte{})
|
||||||
|
- [ ] 并发读写正确
|
||||||
|
- [ ] 未发布 entry 对 Get 不可见
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1H: MANIFEST + 文件管理
|
||||||
|
|
||||||
|
**目标**: 实现 MANIFEST 持久化和 WAL segment 生命周期管理。
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
#### 1H-1: MANIFEST 格式
|
||||||
|
- 记录 recovery 起点的 recoverySegmentID
|
||||||
|
- temp + rename 原子更新
|
||||||
|
- MANIFEST 只在 checkpoint(MemTable flush)后推进
|
||||||
|
|
||||||
|
#### 1H-2: 首次创建 DB 流程
|
||||||
|
- 创建目录结构
|
||||||
|
- 创建初始 MANIFEST(recoverySegmentID=0)
|
||||||
|
- 创建初始 WAL segment
|
||||||
|
|
||||||
|
#### 1H-3: WAL Segment 生命周期
|
||||||
|
- 旧 segment 删除条件:已被 MANIFEST checkpoint 覆盖
|
||||||
|
- 删除顺序:先删除文件,再 fsync directory
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- [ ] 首次创建 DB 成功
|
||||||
|
- [ ] 重复打开 DB 正确恢复
|
||||||
|
- [ ] MANIFEST 原子更新
|
||||||
|
- [ ] 旧 WAL segment 正确清理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1I: 集成测试 + Benchmark
|
||||||
|
|
||||||
|
**目标**: 端到端测试和性能基准。
|
||||||
|
|
||||||
|
### 任务
|
||||||
|
|
||||||
|
#### 1I-1: 集成测试
|
||||||
|
- 正常写入 + 读取 round-trip
|
||||||
|
- 并发写入 + 读取一致性
|
||||||
|
- 崩溃恢复(kill -9 模拟)
|
||||||
|
- WAL 尾部损坏恢复
|
||||||
|
- Write-stopped 后的行为
|
||||||
|
- Sequence 耗尽处理
|
||||||
|
- 配置校验拒绝非法配置
|
||||||
|
- 空 value 写入/读取
|
||||||
|
- 大量数据写入(触发 segment 轮转)
|
||||||
|
|
||||||
|
#### 1I-2: Benchmark
|
||||||
|
- 单线程 Put 吞吐
|
||||||
|
- 多线程 Put 吞吐
|
||||||
|
- 单线程 Get 延迟
|
||||||
|
- 多线程 Get 延迟
|
||||||
|
- WAL Recovery 时间
|
||||||
|
- 写入放大测量
|
||||||
|
|
||||||
|
#### 1I-3: Race Condition 测试
|
||||||
|
- `go test -race -count=100`
|
||||||
|
- 并发 Put + Get
|
||||||
|
- 并发 Put + Close
|
||||||
|
|
||||||
|
### 验收标准
|
||||||
|
- [ ] 所有集成测试通过
|
||||||
|
- [ ] Benchmark 数字可作为后续优化基线
|
||||||
|
- [ ] Race test 无 data race
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖关系与并行度
|
||||||
|
|
||||||
|
```
|
||||||
|
1A ─────┐
|
||||||
|
├── 1B ─────┐
|
||||||
|
│ ├── 1C ─────┐
|
||||||
|
│ │ ├── 1F ── 1G ── 1I
|
||||||
|
│ │ │
|
||||||
|
1A ── 1E ──────────┘ │
|
||||||
|
│ │
|
||||||
|
├── 1D ─────────────────┘
|
||||||
|
│
|
||||||
|
└── 1H ────────────────────────────── 1I
|
||||||
|
```
|
||||||
|
|
||||||
|
可并行开发的模块:
|
||||||
|
- 1A 完成后,1B/1D/1E/1H 可以并行开发
|
||||||
|
- 1B 完成后,1C 可以开始
|
||||||
|
- 1C + 1D + 1E 完成后,1F 可以集成
|
||||||
|
- 1F + 1H 完成后,1G 可以集成
|
||||||
|
- 所有完成后,1I 集成测试
|
||||||
|
|
||||||
|
## 技术要点备忘
|
||||||
|
|
||||||
|
### 内存序(最关键)
|
||||||
|
- skiplist next 指针:`atomic.Pointer` store-release
|
||||||
|
- `publishedSequence`:`atomic.Uint64` store(在所有 entry 节点发布后)
|
||||||
|
- 读者先 load publishedSequence(acquire),再遍历 skiplist
|
||||||
|
|
||||||
|
### WAL 副作用边界
|
||||||
|
- 未调用 `write()` → 普通错误
|
||||||
|
- 已调用 `write()` → ErrCommitUnknown + write-stopped
|
||||||
|
- 私有缓冲区编码,不共享 bufio.Writer
|
||||||
|
|
||||||
|
### ErrCommitUnknown 语义
|
||||||
|
- maybe committed,不是 definitely failed
|
||||||
|
- 不盲目重试
|
||||||
|
- 第一阶段为弱确认
|
||||||
|
|
||||||
|
### WAL Batch 资源前置校验
|
||||||
|
- sequence 分配之前完成所有可失败校验
|
||||||
|
- 减少 write-stopped 触发机会
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Package errkit defines sentinel errors shared across packages in the go-kv module.
|
||||||
|
//
|
||||||
|
// These errors live in a leaf package to avoid import cycles: both the root
|
||||||
|
// package and sub-packages (wal, memtable, etc.) can import errkit without
|
||||||
|
// creating circular dependencies.
|
||||||
|
package errkit
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// ErrCommitUnknown indicates the commit result is indeterminate: the WAL write
|
||||||
|
// was attempted but the caller cannot assume the write definitely failed or
|
||||||
|
// succeeded. The caller must query the commit state before retrying.
|
||||||
|
var ErrCommitUnknown = errors.New("go-kv: commit result unknown")
|
||||||
|
|
||||||
|
// ErrWriteStopped indicates the engine has entered a write-stopped state and
|
||||||
|
// rejects all subsequent writes.
|
||||||
|
var ErrWriteStopped = errors.New("go-kv: write stopped")
|
||||||
|
|
||||||
|
// ErrSequenceExhausted indicates the sequence number space is exhausted. The
|
||||||
|
// engine enters a terminal state requiring database migration or rebuild.
|
||||||
|
var ErrSequenceExhausted = errors.New("go-kv: sequence exhausted")
|
||||||
|
|
||||||
|
// ErrWALCorrupted indicates WAL data corruption was detected.
|
||||||
|
var ErrWALCorrupted = errors.New("go-kv: WAL corrupted")
|
||||||
|
|
||||||
|
// ErrInvalidConfig indicates an invalid configuration was provided.
|
||||||
|
var ErrInvalidConfig = errors.New("go-kv: invalid config")
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package go_kv
|
||||||
|
|
||||||
|
import "github.com/dailz/go-kv/errkit"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrCommitUnknown = errkit.ErrCommitUnknown
|
||||||
|
ErrWriteStopped = errkit.ErrWriteStopped
|
||||||
|
ErrSequenceExhausted = errkit.ErrSequenceExhausted
|
||||||
|
ErrWALCorrupted = errkit.ErrWALCorrupted
|
||||||
|
ErrInvalidConfig = errkit.ErrInvalidConfig
|
||||||
|
)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package go_kv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestErrorTypes(t *testing.T) {
|
||||||
|
allErrors := []error{
|
||||||
|
ErrCommitUnknown,
|
||||||
|
ErrWriteStopped,
|
||||||
|
ErrSequenceExhausted,
|
||||||
|
ErrWALCorrupted,
|
||||||
|
ErrInvalidConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each error must match itself via errors.Is.
|
||||||
|
for _, err := range allErrors {
|
||||||
|
if !errors.Is(err, err) {
|
||||||
|
t.Errorf("errors.Is(%v, %v) = false, want true", err, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each error must NOT match any other error.
|
||||||
|
for i, a := range allErrors {
|
||||||
|
for j, b := range allErrors {
|
||||||
|
if i == j {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if errors.Is(a, b) {
|
||||||
|
t.Errorf("errors.Is(%v, %v) = true, want false (distinct errors)", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapped errors must still be identifiable via errors.Is.
|
||||||
|
wrapped := fmt.Errorf("operation failed: %w", ErrCommitUnknown)
|
||||||
|
if !errors.Is(wrapped, ErrCommitUnknown) {
|
||||||
|
t.Errorf("errors.Is(wrapped ErrCommitUnknown, ErrCommitUnknown) = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
module github.com/dailz/go-kv
|
||||||
|
|
||||||
|
go 1.26.3
|
||||||
|
|
||||||
|
require github.com/stretchr/testify v1.11.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package manifest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteCurrent writes the CURRENT file to dir with a best-effort atomic rename.
|
||||||
|
// The file contains the active WAL segment filename (e.g. "segment-5.wal").
|
||||||
|
// CURRENT is only a write-side hint; it may be missing or stale after a crash.
|
||||||
|
func WriteCurrent(dir string, segmentID uint64) error {
|
||||||
|
content := fmt.Sprintf("segment-%d.wal\n", segmentID)
|
||||||
|
tmpPath := dir + "/CURRENT.tmp"
|
||||||
|
if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write current tmp: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, dir+"/CURRENT"); err != nil {
|
||||||
|
return fmt.Errorf("rename current: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadCurrent reads the CURRENT file from dir and returns the segment ID.
|
||||||
|
// If the file does not exist or cannot be parsed, it returns 0, false with no error.
|
||||||
|
func ReadCurrent(dir string) (segmentID uint64, ok bool) {
|
||||||
|
data, err := os.ReadFile(dir + "/CURRENT")
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
line := strings.TrimSpace(string(data))
|
||||||
|
// Expected format: "segment-N.wal"
|
||||||
|
if !strings.HasPrefix(line, "segment-") || !strings.HasSuffix(line, ".wal") {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
idStr := strings.TrimSuffix(strings.TrimPrefix(line, "segment-"), ".wal")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Package manifest manages database metadata and checkpoint information.
|
||||||
|
package manifest
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package manifest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manifest holds database metadata used for recovery.
|
||||||
|
// The MANIFEST file stores the recovery checkpoint so that
|
||||||
|
// recovery knows which segments are already confirmed durable.
|
||||||
|
type Manifest struct {
|
||||||
|
RecoverySegmentID uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads the MANIFEST file from dir.
|
||||||
|
// If the file does not exist, it returns a zero-value Manifest with no error (fresh DB).
|
||||||
|
func Load(dir string) (*Manifest, error) {
|
||||||
|
path := dir + "/MANIFEST"
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &Manifest{RecoverySegmentID: 0}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := strings.TrimSpace(string(data))
|
||||||
|
if !strings.HasPrefix(line, "recovery_segment_id:") {
|
||||||
|
return nil, fmt.Errorf("manifest: invalid format: %q", line)
|
||||||
|
}
|
||||||
|
idStr := strings.TrimPrefix(line, "recovery_segment_id:")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("manifest: parse recovery_segment_id: %w", err)
|
||||||
|
}
|
||||||
|
return &Manifest{RecoverySegmentID: id}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save atomically writes the MANIFEST file to dir with the given recoverySegmentID.
|
||||||
|
func Save(dir string, recoverySegmentID uint64) error {
|
||||||
|
content := fmt.Sprintf("recovery_segment_id:%d\n", recoverySegmentID)
|
||||||
|
tmpPath := dir + "/MANIFEST.tmp"
|
||||||
|
if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write manifest tmp: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, dir+"/MANIFEST"); err != nil {
|
||||||
|
return fmt.Errorf("rename manifest: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package manifest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManifestRoundtrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
err := Save(dir, 42)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
m, err := Load(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(42), m.RecoverySegmentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestMissing(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
m, err := Load(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, uint64(0), m.RecoverySegmentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentRoundtrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
err := WriteCurrent(dir, 5)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
segmentID, ok := ReadCurrent(dir)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, uint64(5), segmentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCurrentMissing(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
_, ok := ReadCurrent(dir)
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package memtable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrArenaFull is returned when the arena cannot satisfy an allocation request.
|
||||||
|
var ErrArenaFull = errors.New("arena: insufficient capacity")
|
||||||
|
|
||||||
|
// Arena is a bump allocator backed by a fixed-size byte slice.
|
||||||
|
// It provides thread-safe allocation with 8-byte alignment.
|
||||||
|
//
|
||||||
|
// The arena is used by the memtable to store keys, values, and skip-list
|
||||||
|
// node structures. Once allocated, bytes are never freed — the entire
|
||||||
|
// arena is discarded when the memtable is flushed.
|
||||||
|
type Arena struct {
|
||||||
|
buf []byte
|
||||||
|
offset uint32 // next allocation offset (atomic for reads)
|
||||||
|
capacity uint32 // total capacity (immutable)
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewArena creates a new Arena with the given capacity in bytes.
|
||||||
|
// The entire buffer is allocated upfront.
|
||||||
|
func NewArena(capacity uint32) *Arena {
|
||||||
|
return &Arena{
|
||||||
|
buf: make([]byte, capacity),
|
||||||
|
capacity: capacity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate reserves alignedSize bytes in the arena and returns the offset
|
||||||
|
// at which the caller can write. The size is aligned up to 8 bytes.
|
||||||
|
func (a *Arena) Allocate(size uint32) (uint32, error) {
|
||||||
|
alignedSize := (size + 7) &^ uint32(7)
|
||||||
|
|
||||||
|
a.mu.Lock()
|
||||||
|
rem := a.capacity - a.offset
|
||||||
|
if rem < alignedSize {
|
||||||
|
a.mu.Unlock()
|
||||||
|
return 0, ErrArenaFull
|
||||||
|
}
|
||||||
|
off := a.offset
|
||||||
|
a.offset += alignedSize
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
return off, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBytes returns a slice of the arena buffer at [offset, offset+size).
|
||||||
|
// It panics if the range is out of bounds.
|
||||||
|
func (a *Arena) GetBytes(offset, size uint32) []byte {
|
||||||
|
end := offset + size
|
||||||
|
if end > a.capacity {
|
||||||
|
panic("arena: GetBytes out of bounds")
|
||||||
|
}
|
||||||
|
return a.buf[offset:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining returns the number of bytes still available for allocation.
|
||||||
|
func (a *Arena) Remaining() uint32 {
|
||||||
|
return a.capacity - atomic.LoadUint32(&a.offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capacity returns the total capacity of the arena.
|
||||||
|
func (a *Arena) Capacity() uint32 {
|
||||||
|
return a.capacity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve checks whether the arena has at least totalSize bytes remaining
|
||||||
|
// without actually allocating. It is used by the WAL writer to verify
|
||||||
|
// capacity before appending entries.
|
||||||
|
func (a *Arena) Reserve(totalSize uint32) error {
|
||||||
|
a.mu.Lock()
|
||||||
|
rem := a.capacity - a.offset
|
||||||
|
a.mu.Unlock()
|
||||||
|
|
||||||
|
if rem < totalSize {
|
||||||
|
return ErrArenaFull
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package memtable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestArenaAllocate(t *testing.T) {
|
||||||
|
a := NewArena(1024)
|
||||||
|
|
||||||
|
off, err := a.Allocate(9)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, uint32(0), off)
|
||||||
|
|
||||||
|
copy(a.GetBytes(off, 9), "test data")
|
||||||
|
require.Equal(t, []byte("test data"), a.GetBytes(off, 9))
|
||||||
|
|
||||||
|
off2, err := a.Allocate(200)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// 9 bytes aligned to 16, so second allocation starts at 16
|
||||||
|
require.Equal(t, uint32(16), off2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArenaFull(t *testing.T) {
|
||||||
|
a := NewArena(64)
|
||||||
|
|
||||||
|
off, err := a.Allocate(60)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, uint32(0), off) // 60 aligned to 64
|
||||||
|
|
||||||
|
_, err = a.Allocate(10)
|
||||||
|
require.ErrorIs(t, err, ErrArenaFull)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArenaAlignment(t *testing.T) {
|
||||||
|
a := NewArena(1024)
|
||||||
|
|
||||||
|
sizes := []uint32{1, 3, 5, 7, 8, 9, 13, 16, 100}
|
||||||
|
for _, s := range sizes {
|
||||||
|
off, err := a.Allocate(s)
|
||||||
|
require.NoError(t, err, "size=%d", s)
|
||||||
|
require.Equal(t, uint32(0), off%8, "offset %d not 8-byte aligned for size %d", off, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArenaConcurrent(t *testing.T) {
|
||||||
|
a := NewArena(65536)
|
||||||
|
|
||||||
|
const goroutines = 64
|
||||||
|
const perGoroutine = 32
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(goroutines)
|
||||||
|
|
||||||
|
offsets := make([][]uint32, goroutines)
|
||||||
|
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
i := i
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
locals := make([]uint32, 0, perGoroutine)
|
||||||
|
for j := 0; j < perGoroutine; j++ {
|
||||||
|
off, err := a.Allocate(8)
|
||||||
|
if err == nil {
|
||||||
|
// Write unique data to detect corruption
|
||||||
|
copy(a.GetBytes(off, 8), []byte{byte(i), byte(j), 0, 0, 0, 0, 0, 0})
|
||||||
|
locals = append(locals, off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offsets[i] = locals
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Verify data integrity — each byte pair must match its goroutine/j index
|
||||||
|
for i, locals := range offsets {
|
||||||
|
for j, off := range locals {
|
||||||
|
data := a.GetBytes(off, 8)
|
||||||
|
require.Equal(t, byte(i), data[0], "goroutine %d offset %d", i, off)
|
||||||
|
require.Equal(t, byte(j), data[1], "iteration %d offset %d", j, off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArenaReserve(t *testing.T) {
|
||||||
|
a := NewArena(128)
|
||||||
|
|
||||||
|
require.NoError(t, a.Reserve(64))
|
||||||
|
require.NoError(t, a.Reserve(128))
|
||||||
|
require.ErrorIs(t, a.Reserve(129), ErrArenaFull)
|
||||||
|
|
||||||
|
// Allocate some space
|
||||||
|
_, err := a.Allocate(32)
|
||||||
|
require.NoError(t, err) // 32 aligned = 32
|
||||||
|
|
||||||
|
// 128 - 32 = 96 remaining
|
||||||
|
require.NoError(t, a.Reserve(96))
|
||||||
|
require.ErrorIs(t, a.Reserve(97), ErrArenaFull)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArenaRemainingAndCapacity(t *testing.T) {
|
||||||
|
cap := uint32(256)
|
||||||
|
a := NewArena(cap)
|
||||||
|
|
||||||
|
require.Equal(t, cap, a.Capacity())
|
||||||
|
require.Equal(t, cap, a.Remaining())
|
||||||
|
|
||||||
|
_, err := a.Allocate(10)
|
||||||
|
require.NoError(t, err) // 10 aligned to 16
|
||||||
|
|
||||||
|
require.Equal(t, cap-16, a.Remaining())
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Package memtable implements an in-memory sorted key-value store.
|
||||||
|
package memtable
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package memtable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrMemTableFull is returned when the memtable cannot reserve enough space.
|
||||||
|
var ErrMemTableFull = errors.New("memtable: insufficient capacity")
|
||||||
|
|
||||||
|
// ReserveEntry describes a single key-value pair to be reserved.
|
||||||
|
type ReserveEntry struct {
|
||||||
|
Key []byte
|
||||||
|
Value []byte
|
||||||
|
IsDelete bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetResult is returned by MemTable.Get.
|
||||||
|
type GetResult struct {
|
||||||
|
Found bool
|
||||||
|
Value []byte
|
||||||
|
Sequence uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemTable wraps an Arena and SkipList with publish/abort semantics.
|
||||||
|
//
|
||||||
|
// Writers first reserve capacity, then insert pending entries, then either
|
||||||
|
// publish (making them visible) or abort (leaving them invisible forever).
|
||||||
|
// Readers only see published entries that have not been aborted.
|
||||||
|
type MemTable struct {
|
||||||
|
arena *Arena
|
||||||
|
skiplist *SkipList
|
||||||
|
published atomic.Uint64 // highest published sequence
|
||||||
|
aborted sync.Map // map[uint64]struct{} — set of aborted sequences
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemTable creates a MemTable backed by an arena of the given capacity.
|
||||||
|
func NewMemTable(capacity uint32) *MemTable {
|
||||||
|
arena := NewArena(capacity)
|
||||||
|
return &MemTable{
|
||||||
|
arena: arena,
|
||||||
|
skiplist: NewSkipList(arena),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve estimates whether the arena has enough space for all entries.
|
||||||
|
// It returns the total estimated size for caller tracking.
|
||||||
|
//
|
||||||
|
// The estimate is conservative: key + value bytes plus a fixed metadata
|
||||||
|
// overhead per entry. Since Phase 1 stores nodes on the Go heap (not in
|
||||||
|
// the arena), this reservation is primarily a capacity gate for future
|
||||||
|
// arena-backed phases.
|
||||||
|
func (mt *MemTable) Reserve(entries []ReserveEntry) (uint32, error) {
|
||||||
|
const metadataOverhead uint32 = 32 // per-entry overhead estimate
|
||||||
|
|
||||||
|
var totalSize uint32
|
||||||
|
for _, e := range entries {
|
||||||
|
sz := metadataOverhead + uint32(len(e.Key)) + uint32(len(e.Value))
|
||||||
|
// Align to 8 bytes
|
||||||
|
sz = (sz + 7) &^ uint32(7)
|
||||||
|
totalSize += sz
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mt.arena.Reserve(totalSize); err != nil {
|
||||||
|
return 0, ErrMemTableFull
|
||||||
|
}
|
||||||
|
return totalSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutPending inserts a key-value pair as pending (invisible to Get/Iterator).
|
||||||
|
func (mt *MemTable) PutPending(key []byte, value []byte, sequence uint64) error {
|
||||||
|
return mt.skiplist.Put(key, value, sequence, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePending inserts a tombstone entry (nil value) as pending.
|
||||||
|
func (mt *MemTable) DeletePending(key []byte, sequence uint64) error {
|
||||||
|
return mt.skiplist.Put(key, nil, sequence, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish makes all pending entries with sequence <= upToSequence visible.
|
||||||
|
//
|
||||||
|
// It iterates the skip list and re-inserts each pending entry with
|
||||||
|
// pending=false, which the lock-free reader path will then observe.
|
||||||
|
// Aborted entries are skipped and remain invisible forever.
|
||||||
|
func (mt *MemTable) Publish(upToSequence uint64) {
|
||||||
|
// Collect entries to publish under the iterator (which skips pending).
|
||||||
|
// We need a raw walk, so we access the skiplist directly.
|
||||||
|
type entry struct {
|
||||||
|
key []byte
|
||||||
|
value []byte
|
||||||
|
sequence uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the raw skip list level-0 chain to find pending entries.
|
||||||
|
// We cannot use NewIterator because it skips pending entries.
|
||||||
|
var toPublish []entry
|
||||||
|
|
||||||
|
mt.skiplist.mu.Lock()
|
||||||
|
node := mt.skiplist.head.next[0].Load()
|
||||||
|
for node != nil {
|
||||||
|
if node.pending && node.sequence <= upToSequence {
|
||||||
|
if _, aborted := mt.aborted.Load(node.sequence); !aborted {
|
||||||
|
toPublish = append(toPublish, entry{
|
||||||
|
key: node.key,
|
||||||
|
value: node.value,
|
||||||
|
sequence: node.sequence,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = node.next[0].Load()
|
||||||
|
}
|
||||||
|
mt.skiplist.mu.Unlock()
|
||||||
|
|
||||||
|
// Re-put each entry as published. Each call acquires the skiplist mutex.
|
||||||
|
for _, e := range toPublish {
|
||||||
|
if err := mt.skiplist.Put(e.key, e.value, e.sequence, false); err != nil {
|
||||||
|
// Reserve guaranteed space, so this should never happen.
|
||||||
|
// If it does, it's an internal invariant violation.
|
||||||
|
panic(fmt.Sprintf("memtable: Put failed after Reserve: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update high-water mark after all entries are visible.
|
||||||
|
mt.published.Store(upToSequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort marks a sequence as aborted. Aborted entries are never visible to readers.
|
||||||
|
func (mt *MemTable) Abort(sequence uint64) {
|
||||||
|
mt.aborted.Store(sequence, struct{}{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves the latest visible value for key.
|
||||||
|
//
|
||||||
|
// A value is visible if it is published (pending=false in the skip list)
|
||||||
|
// and not in the aborted set.
|
||||||
|
func (mt *MemTable) Get(key []byte) *GetResult {
|
||||||
|
found, value, sequence := mt.skiplist.Get(key)
|
||||||
|
if !found {
|
||||||
|
return &GetResult{Found: false}
|
||||||
|
}
|
||||||
|
return &GetResult{
|
||||||
|
Found: true,
|
||||||
|
Value: value,
|
||||||
|
Sequence: sequence,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIterator returns a forward iterator over all published (visible) entries.
|
||||||
|
// Pending and aborted entries are automatically skipped.
|
||||||
|
func (mt *MemTable) NewIterator() *Iterator {
|
||||||
|
return mt.skiplist.NewIterator()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApproximateSize returns the number of bytes used in the arena.
|
||||||
|
func (mt *MemTable) ApproximateSize() uint64 {
|
||||||
|
return uint64(mt.arena.Capacity() - mt.arena.Remaining())
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsableCapacity returns the remaining bytes available in the arena.
|
||||||
|
func (mt *MemTable) UsableCapacity() uint32 {
|
||||||
|
return mt.arena.Remaining()
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package memtable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMemTablePendingInvisible(t *testing.T) {
|
||||||
|
mt := NewMemTable(4096)
|
||||||
|
|
||||||
|
// Put a pending entry — must not be visible.
|
||||||
|
if err := mt.PutPending([]byte("key1"), []byte("val1"), 1); err != nil {
|
||||||
|
t.Fatalf("PutPending: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res := mt.Get([]byte("key1"))
|
||||||
|
if res.Found {
|
||||||
|
t.Fatal("pending entry should not be visible")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish sequence 1 — entry becomes visible.
|
||||||
|
mt.Publish(1)
|
||||||
|
|
||||||
|
res = mt.Get([]byte("key1"))
|
||||||
|
if !res.Found {
|
||||||
|
t.Fatal("published entry should be visible")
|
||||||
|
}
|
||||||
|
if string(res.Value) != "val1" {
|
||||||
|
t.Fatalf("value mismatch: got %q, want %q", res.Value, "val1")
|
||||||
|
}
|
||||||
|
if res.Sequence != 1 {
|
||||||
|
t.Fatalf("sequence mismatch: got %d, want %d", res.Sequence, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemTableAbortedInvisible(t *testing.T) {
|
||||||
|
mt := NewMemTable(4096)
|
||||||
|
|
||||||
|
if err := mt.PutPending([]byte("key1"), []byte("val1"), 1); err != nil {
|
||||||
|
t.Fatalf("PutPending: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort sequence 1 before publishing.
|
||||||
|
mt.Abort(1)
|
||||||
|
|
||||||
|
// Publish up to sequence 10 — aborted entry should stay invisible.
|
||||||
|
mt.Publish(10)
|
||||||
|
|
||||||
|
res := mt.Get([]byte("key1"))
|
||||||
|
if res.Found {
|
||||||
|
t.Fatal("aborted entry should not be visible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemTableMultiplePublish(t *testing.T) {
|
||||||
|
mt := NewMemTable(8192)
|
||||||
|
|
||||||
|
if err := mt.PutPending([]byte("a"), []byte("va"), 1); err != nil {
|
||||||
|
t.Fatalf("PutPending a: %v", err)
|
||||||
|
}
|
||||||
|
if err := mt.PutPending([]byte("b"), []byte("vb"), 2); err != nil {
|
||||||
|
t.Fatalf("PutPending b: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neither visible before publish.
|
||||||
|
if mt.Get([]byte("a")).Found {
|
||||||
|
t.Fatal("a should not be visible before publish")
|
||||||
|
}
|
||||||
|
if mt.Get([]byte("b")).Found {
|
||||||
|
t.Fatal("b should not be visible before publish")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish both.
|
||||||
|
mt.Publish(2)
|
||||||
|
|
||||||
|
if !mt.Get([]byte("a")).Found {
|
||||||
|
t.Fatal("a should be visible after publish")
|
||||||
|
}
|
||||||
|
if !mt.Get([]byte("b")).Found {
|
||||||
|
t.Fatal("b should be visible after publish")
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(mt.Get([]byte("a")).Value) != "va" {
|
||||||
|
t.Fatal("value mismatch for a")
|
||||||
|
}
|
||||||
|
if string(mt.Get([]byte("b")).Value) != "vb" {
|
||||||
|
t.Fatal("value mismatch for b")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemTableReserve(t *testing.T) {
|
||||||
|
mt := NewMemTable(4096)
|
||||||
|
|
||||||
|
// Reserve 10 small entries — should succeed.
|
||||||
|
entries := make([]ReserveEntry, 10)
|
||||||
|
for i := range entries {
|
||||||
|
entries[i] = ReserveEntry{
|
||||||
|
Key: []byte("k"),
|
||||||
|
Value: []byte("v"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total, err := mt.Reserve(entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Reserve 10 entries: %v", err)
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
t.Fatal("expected non-zero total size")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserve more than remaining — should fail.
|
||||||
|
big := []ReserveEntry{{
|
||||||
|
Key: make([]byte, 2048),
|
||||||
|
Value: make([]byte, 2048),
|
||||||
|
}}
|
||||||
|
_, err = mt.Reserve(big)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected ErrMemTableFull for oversized reserve")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemTableConcurrentReads(t *testing.T) {
|
||||||
|
mt := NewMemTable(8192)
|
||||||
|
|
||||||
|
// Write 10 pending entries.
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
key := []byte{byte('a' + i)}
|
||||||
|
val := []byte{byte(i)}
|
||||||
|
if err := mt.PutPending(key, val, uint64(i+1)); err != nil {
|
||||||
|
t.Fatalf("PutPending %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish all.
|
||||||
|
mt.Publish(10)
|
||||||
|
|
||||||
|
// Concurrent reads should all see published values.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for g := 0; g < 4; g++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
key := []byte{byte('a' + (i % 10))}
|
||||||
|
res := mt.Get(key)
|
||||||
|
if !res.Found {
|
||||||
|
t.Errorf("key %s not found", key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemTableIteratorSkipsPending(t *testing.T) {
|
||||||
|
mt := NewMemTable(4096)
|
||||||
|
|
||||||
|
_ = mt.PutPending([]byte("pending"), []byte("p"), 1)
|
||||||
|
_ = mt.PutPending([]byte("published"), []byte("pub"), 2)
|
||||||
|
mt.Publish(2) // publishes "published" at seq 2
|
||||||
|
|
||||||
|
// Abort seq 1 — stays pending.
|
||||||
|
// Actually we already published up to 2 which would publish seq 1 too.
|
||||||
|
// Let's test differently: insert a pending entry after publish.
|
||||||
|
_ = mt.PutPending([]byte("still_pending"), []byte("sp"), 3)
|
||||||
|
|
||||||
|
it := mt.NewIterator()
|
||||||
|
count := 0
|
||||||
|
for it.Valid() {
|
||||||
|
count++
|
||||||
|
it.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should see "pending" (seq 1, now published) and "published" (seq 2),
|
||||||
|
// but not "still_pending" (seq 3, still pending).
|
||||||
|
if count != 2 {
|
||||||
|
t.Fatalf("expected 2 visible entries, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemTableDeletePending(t *testing.T) {
|
||||||
|
mt := NewMemTable(4096)
|
||||||
|
|
||||||
|
// Insert then delete.
|
||||||
|
_ = mt.PutPending([]byte("key1"), []byte("val1"), 1)
|
||||||
|
_ = mt.DeletePending([]byte("key1"), 2)
|
||||||
|
|
||||||
|
mt.Publish(2)
|
||||||
|
|
||||||
|
// After publish, the latest entry is a tombstone (nil value).
|
||||||
|
res := mt.Get([]byte("key1"))
|
||||||
|
if !res.Found {
|
||||||
|
t.Fatal("tombstone entry should still be 'found'")
|
||||||
|
}
|
||||||
|
if res.Value != nil {
|
||||||
|
t.Fatalf("expected nil value for tombstone, got %q", res.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemTableApproximateSize(t *testing.T) {
|
||||||
|
mt := NewMemTable(4096)
|
||||||
|
|
||||||
|
if mt.ApproximateSize() != 0 {
|
||||||
|
t.Fatal("new memtable should have zero approximate size")
|
||||||
|
}
|
||||||
|
if mt.UsableCapacity() != 4096 {
|
||||||
|
t.Fatalf("usable capacity: got %d, want 4096", mt.UsableCapacity())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package memtable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"math/rand"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxLevel = 20
|
||||||
|
probability = 0.25
|
||||||
|
)
|
||||||
|
|
||||||
|
// SkipList is an ordered in-memory key-value index.
|
||||||
|
//
|
||||||
|
// Writers are serialized by mu. Readers never take mu: every next pointer is
|
||||||
|
// published with atomic.Pointer.Store and read with atomic.Pointer.Load, giving
|
||||||
|
// release/acquire ordering for fully initialized nodes.
|
||||||
|
type SkipList struct {
|
||||||
|
arena *Arena
|
||||||
|
mu sync.Mutex
|
||||||
|
head *skipNode
|
||||||
|
|
||||||
|
height atomic.Int32
|
||||||
|
length atomic.Uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type skipNode struct {
|
||||||
|
next [maxLevel]atomic.Pointer[skipNode]
|
||||||
|
|
||||||
|
key []byte
|
||||||
|
value []byte
|
||||||
|
sequence uint64
|
||||||
|
height int
|
||||||
|
pending bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSkipList creates an empty skip list. The arena is retained for the
|
||||||
|
// memtable API and future arena-backed nodes; Phase 1 stores nodes on the Go
|
||||||
|
// heap so atomic.Pointer can provide correct lock-free reads.
|
||||||
|
func NewSkipList(arena *Arena) *SkipList {
|
||||||
|
head := &skipNode{height: maxLevel}
|
||||||
|
sl := &SkipList{
|
||||||
|
arena: arena,
|
||||||
|
head: head,
|
||||||
|
}
|
||||||
|
sl.height.Store(1)
|
||||||
|
return sl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put inserts or replaces key with value and sequence.
|
||||||
|
func (sl *SkipList) Put(key []byte, value []byte, sequence uint64, pending bool) error {
|
||||||
|
sl.mu.Lock()
|
||||||
|
defer sl.mu.Unlock()
|
||||||
|
|
||||||
|
var prev [maxLevel]*skipNode
|
||||||
|
found := sl.findGreaterOrEqual(key, &prev)
|
||||||
|
|
||||||
|
if found != nil && bytes.Equal(found.key, key) {
|
||||||
|
sl.replaceLocked(&prev, found, key, value, sequence, pending)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
height := randomHeight()
|
||||||
|
sl.ensureHeightLocked(height, &prev)
|
||||||
|
node := newSkipNode(key, value, sequence, pending, height)
|
||||||
|
|
||||||
|
for level := range height {
|
||||||
|
node.next[level].Store(prev[level].next[level].Load())
|
||||||
|
}
|
||||||
|
for level := range height {
|
||||||
|
prev[level].next[level].Store(node)
|
||||||
|
}
|
||||||
|
sl.length.Add(1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the latest published value for key. It performs no locking.
|
||||||
|
func (sl *SkipList) Get(key []byte) (found bool, value []byte, sequence uint64) {
|
||||||
|
node := sl.findGreaterOrEqual(key, nil)
|
||||||
|
if node == nil || !bytes.Equal(node.key, key) || node.pending {
|
||||||
|
return false, nil, 0
|
||||||
|
}
|
||||||
|
return true, cloneBytes(node.value), node.sequence
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the number of distinct keys in the skip list.
|
||||||
|
func (sl *SkipList) Len() uint64 {
|
||||||
|
return sl.length.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIterator creates a forward iterator over published entries.
|
||||||
|
func (sl *SkipList) NewIterator() *Iterator {
|
||||||
|
it := &Iterator{node: sl.head.next[0].Load()}
|
||||||
|
it.skipPending()
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterator is a lock-free forward iterator over SkipList entries.
|
||||||
|
type Iterator struct {
|
||||||
|
node *skipNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid reports whether the iterator points at an entry.
|
||||||
|
func (it *Iterator) Valid() bool {
|
||||||
|
return it.node != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next advances the iterator to the next published entry.
|
||||||
|
func (it *Iterator) Next() {
|
||||||
|
if it.node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
it.node = it.node.next[0].Load()
|
||||||
|
it.skipPending()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key returns a copy of the current key.
|
||||||
|
func (it *Iterator) Key() []byte {
|
||||||
|
if it.node == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cloneBytes(it.node.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value returns a copy of the current value.
|
||||||
|
func (it *Iterator) Value() []byte {
|
||||||
|
if it.node == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cloneBytes(it.node.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sequence returns the current entry's sequence number.
|
||||||
|
func (it *Iterator) Sequence() uint64 {
|
||||||
|
if it.node == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return it.node.sequence
|
||||||
|
}
|
||||||
|
|
||||||
|
func (it *Iterator) skipPending() {
|
||||||
|
for it.node != nil && it.node.pending {
|
||||||
|
it.node = it.node.next[0].Load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sl *SkipList) replaceLocked(prev *[maxLevel]*skipNode, old *skipNode, key []byte, value []byte, sequence uint64, pending bool) {
|
||||||
|
height := randomHeight()
|
||||||
|
sl.ensureHeightLocked(height, prev)
|
||||||
|
node := newSkipNode(key, value, sequence, pending, height)
|
||||||
|
|
||||||
|
for level := range maxLevel {
|
||||||
|
oldNext := old.next[level].Load()
|
||||||
|
if level < height {
|
||||||
|
node.next[level].Store(oldNext)
|
||||||
|
prev[level].next[level].Store(node)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if level < old.height {
|
||||||
|
prev[level].next[level].Store(oldNext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sl *SkipList) ensureHeightLocked(height int, prev *[maxLevel]*skipNode) {
|
||||||
|
currentHeight := int(sl.height.Load())
|
||||||
|
if height <= currentHeight {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for level := currentHeight; level < height; level++ {
|
||||||
|
prev[level] = sl.head
|
||||||
|
}
|
||||||
|
sl.height.Store(int32(height))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sl *SkipList) findGreaterOrEqual(key []byte, prev *[maxLevel]*skipNode) *skipNode {
|
||||||
|
x := sl.head
|
||||||
|
level := int(sl.height.Load()) - 1
|
||||||
|
for level >= 0 {
|
||||||
|
next := x.next[level].Load()
|
||||||
|
if next != nil && bytes.Compare(next.key, key) < 0 {
|
||||||
|
x = next
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if prev != nil {
|
||||||
|
prev[level] = x
|
||||||
|
}
|
||||||
|
level--
|
||||||
|
}
|
||||||
|
return x.next[0].Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSkipNode(key []byte, value []byte, sequence uint64, pending bool, height int) *skipNode {
|
||||||
|
return &skipNode{
|
||||||
|
key: cloneBytes(key),
|
||||||
|
value: cloneBytes(value),
|
||||||
|
sequence: sequence,
|
||||||
|
height: height,
|
||||||
|
pending: pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomHeight() int {
|
||||||
|
height := 1
|
||||||
|
for height < maxLevel && rand.Float64() < probability {
|
||||||
|
height++
|
||||||
|
}
|
||||||
|
return height
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneBytes(src []byte) []byte {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dst := make([]byte, len(src))
|
||||||
|
copy(dst, src)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package memtable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSkipListEmpty(t *testing.T) {
|
||||||
|
sl := NewSkipList(NewArena(1024))
|
||||||
|
|
||||||
|
found, value, sequence := sl.Get([]byte("missing"))
|
||||||
|
require.False(t, found)
|
||||||
|
require.Nil(t, value)
|
||||||
|
require.Zero(t, sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkipListOrdered(t *testing.T) {
|
||||||
|
sl := NewSkipList(NewArena(64 << 20))
|
||||||
|
|
||||||
|
keys := []string{"c", "a", "e", "b", "d"}
|
||||||
|
for i, key := range keys {
|
||||||
|
require.NoError(t, sl.Put([]byte(key), []byte("value-"+key), uint64(i+1), false))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, key := range keys {
|
||||||
|
found, value, sequence := sl.Get([]byte(key))
|
||||||
|
require.True(t, found, "key %q", key)
|
||||||
|
require.Equal(t, []byte("value-"+key), value)
|
||||||
|
require.Equal(t, uint64(i+1), sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
it := sl.NewIterator()
|
||||||
|
var got []string
|
||||||
|
for it.Valid() {
|
||||||
|
got = append(got, string(it.Key()))
|
||||||
|
it.Next()
|
||||||
|
}
|
||||||
|
require.Equal(t, []string{"a", "b", "c", "d", "e"}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkipListOverwrite(t *testing.T) {
|
||||||
|
sl := NewSkipList(NewArena(1024))
|
||||||
|
|
||||||
|
require.NoError(t, sl.Put([]byte("key"), []byte("old"), 1, false))
|
||||||
|
require.NoError(t, sl.Put([]byte("key"), []byte("new"), 2, false))
|
||||||
|
|
||||||
|
found, value, sequence := sl.Get([]byte("key"))
|
||||||
|
require.True(t, found)
|
||||||
|
require.Equal(t, []byte("new"), value)
|
||||||
|
require.Equal(t, uint64(2), sequence)
|
||||||
|
|
||||||
|
it := sl.NewIterator()
|
||||||
|
require.True(t, it.Valid())
|
||||||
|
require.Equal(t, []byte("key"), it.Key())
|
||||||
|
require.Equal(t, []byte("new"), it.Value())
|
||||||
|
it.Next()
|
||||||
|
require.False(t, it.Valid())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkipListPendingEntriesAreHidden(t *testing.T) {
|
||||||
|
sl := NewSkipList(NewArena(1024))
|
||||||
|
|
||||||
|
require.NoError(t, sl.Put([]byte("a"), []byte("pending"), 1, true))
|
||||||
|
require.NoError(t, sl.Put([]byte("b"), []byte("published"), 2, false))
|
||||||
|
|
||||||
|
found, _, _ := sl.Get([]byte("a"))
|
||||||
|
require.False(t, found)
|
||||||
|
|
||||||
|
it := sl.NewIterator()
|
||||||
|
require.True(t, it.Valid())
|
||||||
|
require.Equal(t, []byte("b"), it.Key())
|
||||||
|
it.Next()
|
||||||
|
require.False(t, it.Valid())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkipListConcurrent(t *testing.T) {
|
||||||
|
sl := NewSkipList(NewArena(64 << 20))
|
||||||
|
|
||||||
|
const (
|
||||||
|
writers = 4
|
||||||
|
readers = 4
|
||||||
|
keysPerWriter = 100
|
||||||
|
readsPerReader = 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
start := make(chan struct{})
|
||||||
|
errCh := make(chan error, writers+readers)
|
||||||
|
|
||||||
|
for writer := range writers {
|
||||||
|
wg.Go(func() {
|
||||||
|
<-start
|
||||||
|
for i := range keysPerWriter {
|
||||||
|
key := fmt.Appendf(nil, "writer-%d-key-%03d", writer, i)
|
||||||
|
value := fmt.Appendf(nil, "value-%d-%03d", writer, i)
|
||||||
|
sequence := uint64(writer*keysPerWriter + i + 1)
|
||||||
|
if err := sl.Put(key, value, sequence, false); err != nil {
|
||||||
|
errCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for reader := range readers {
|
||||||
|
wg.Go(func() {
|
||||||
|
<-start
|
||||||
|
rng := rand.New(rand.NewSource(int64(reader)))
|
||||||
|
for range readsPerReader {
|
||||||
|
writer := rng.Intn(writers)
|
||||||
|
keyID := rng.Intn(keysPerWriter)
|
||||||
|
key := fmt.Appendf(nil, "writer-%d-key-%03d", writer, keyID)
|
||||||
|
found, value, sequence := sl.Get(key)
|
||||||
|
if found {
|
||||||
|
expectedValue := fmt.Appendf(nil, "value-%d-%03d", writer, keyID)
|
||||||
|
expectedSequence := uint64(writer*keysPerWriter + keyID + 1)
|
||||||
|
if err := compareEntry(value, sequence, expectedValue, expectedSequence); err != nil {
|
||||||
|
errCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
for err := range errCh {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for writer := range writers {
|
||||||
|
for i := range keysPerWriter {
|
||||||
|
key := fmt.Appendf(nil, "writer-%d-key-%03d", writer, i)
|
||||||
|
found, value, sequence := sl.Get(key)
|
||||||
|
require.True(t, found, "key %q", key)
|
||||||
|
require.Equal(t, fmt.Appendf(nil, "value-%d-%03d", writer, i), value)
|
||||||
|
require.Equal(t, uint64(writer*keysPerWriter+i+1), sequence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareEntry(value []byte, sequence uint64, expectedValue []byte, expectedSequence uint64) error {
|
||||||
|
if string(value) != string(expectedValue) {
|
||||||
|
return fmt.Errorf("value = %q, want %q", value, expectedValue)
|
||||||
|
}
|
||||||
|
if sequence != expectedSequence {
|
||||||
|
return fmt.Errorf("sequence = %d, want %d", sequence, expectedSequence)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+216
@@ -0,0 +1,216 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WalBatch represents a single WAL batch — the physical persistence unit.
|
||||||
|
// A batch consists of an 18-byte header followed by a contiguous Entries region.
|
||||||
|
type WalBatch struct {
|
||||||
|
Flags uint16
|
||||||
|
BaseSequence uint64
|
||||||
|
EntryCount uint32
|
||||||
|
EntriesSize uint32
|
||||||
|
Entries []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeWalBatch encodes a WAL batch from the given base sequence and entries.
|
||||||
|
// The returned byte slice is: BatchHeader(18) + encoded entries.
|
||||||
|
func EncodeWalBatch(baseSequence uint64, entries []*WalEntry) ([]byte, error) {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return nil, errors.New("wal: batch requires at least one entry")
|
||||||
|
}
|
||||||
|
if uint32(len(entries)) > MaxWalBatchEntryCount {
|
||||||
|
return nil, fmt.Errorf("wal: entry count %d exceeds maximum %d", len(entries), MaxWalBatchEntryCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode all entries.
|
||||||
|
var entriesBuf bytes.Buffer
|
||||||
|
for i, e := range entries {
|
||||||
|
encoded, err := EncodeEntry(e)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("wal: encoding entry %d: %w", i, err)
|
||||||
|
}
|
||||||
|
entriesBuf.Write(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
entriesSize := uint32(entriesBuf.Len())
|
||||||
|
if entriesSize > MaxWalBatchEntriesSize {
|
||||||
|
return nil, fmt.Errorf("wal: entries size %d exceeds maximum %d", entriesSize, MaxWalBatchEntriesSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build batch: header(18) + entries.
|
||||||
|
buf := make([]byte, WalBatchHeaderSize+entriesSize)
|
||||||
|
binary.LittleEndian.PutUint16(buf[0:2], 0) // flags
|
||||||
|
binary.LittleEndian.PutUint64(buf[2:10], baseSequence)
|
||||||
|
binary.LittleEndian.PutUint32(buf[10:14], uint32(len(entries)))
|
||||||
|
binary.LittleEndian.PutUint32(buf[14:18], entriesSize)
|
||||||
|
copy(buf[WalBatchHeaderSize:], entriesBuf.Bytes())
|
||||||
|
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeWalBatch decodes a WAL batch from raw bytes.
|
||||||
|
// The data must contain the full batch (header + entries).
|
||||||
|
func DecodeWalBatch(data []byte) (*WalBatch, error) {
|
||||||
|
if len(data) < WalBatchHeaderSize {
|
||||||
|
return nil, fmt.Errorf("wal: batch data too short: %d < %d", len(data), WalBatchHeaderSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := binary.LittleEndian.Uint16(data[0:2])
|
||||||
|
baseSeq := binary.LittleEndian.Uint64(data[2:10])
|
||||||
|
entryCount := binary.LittleEndian.Uint32(data[10:14])
|
||||||
|
entriesSize := binary.LittleEndian.Uint32(data[14:18])
|
||||||
|
|
||||||
|
if entryCount == 0 {
|
||||||
|
return nil, errors.New("wal: batch entry count is zero")
|
||||||
|
}
|
||||||
|
if entryCount > MaxWalBatchEntryCount {
|
||||||
|
return nil, fmt.Errorf("wal: entry count %d exceeds maximum %d", entryCount, MaxWalBatchEntryCount)
|
||||||
|
}
|
||||||
|
if entriesSize > MaxWalBatchEntriesSize {
|
||||||
|
return nil, fmt.Errorf("wal: entries size %d exceeds maximum %d", entriesSize, MaxWalBatchEntriesSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedLen := WalBatchHeaderSize + entriesSize
|
||||||
|
if uint32(len(data)) < expectedLen {
|
||||||
|
return nil, fmt.Errorf("wal: entries size mismatch: header says %d bytes, have %d bytes after header",
|
||||||
|
entriesSize, len(data)-WalBatchHeaderSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]byte, entriesSize)
|
||||||
|
copy(entries, data[WalBatchHeaderSize:WalBatchHeaderSize+entriesSize])
|
||||||
|
|
||||||
|
return &WalBatch{
|
||||||
|
Flags: flags,
|
||||||
|
BaseSequence: baseSeq,
|
||||||
|
EntryCount: entryCount,
|
||||||
|
EntriesSize: entriesSize,
|
||||||
|
Entries: entries,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FragmentState represents the state of the fragment collector state machine.
|
||||||
|
type FragmentState uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// FragmentIdle means no fragments are being collected.
|
||||||
|
FragmentIdle FragmentState = 0
|
||||||
|
// FragmentCollecting means fragments are being accumulated.
|
||||||
|
FragmentCollecting FragmentState = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxFragmentBufferSize is the maximum total bytes the fragment buffer can hold:
|
||||||
|
// Batch Header size + max entries size.
|
||||||
|
var maxFragmentBufferSize = uint32(WalBatchHeaderSize) + MaxWalBatchEntriesSize
|
||||||
|
|
||||||
|
// FragmentCollector reassembles WAL batches from physical record fragments.
|
||||||
|
// The state machine transitions between Idle and Collecting based on the
|
||||||
|
// record type (Full, First, Middle, Last).
|
||||||
|
type FragmentCollector struct {
|
||||||
|
state FragmentState
|
||||||
|
buf bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFragmentCollector creates a new FragmentCollector in Idle state.
|
||||||
|
func NewFragmentCollector() *FragmentCollector {
|
||||||
|
return &FragmentCollector{
|
||||||
|
state: FragmentIdle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clears all collected data and returns the collector to Idle state.
|
||||||
|
func (fc *FragmentCollector) Reset() {
|
||||||
|
fc.state = FragmentIdle
|
||||||
|
fc.buf.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append feeds a physical record fragment to the collector.
|
||||||
|
// The state machine enforces valid transitions:
|
||||||
|
// - Idle + Full → collect payload, stay Idle (complete)
|
||||||
|
// - Idle + First → collect payload, → Collecting
|
||||||
|
// - Idle + Middle → error
|
||||||
|
// - Idle + Last → error
|
||||||
|
// - Collecting + Middle → collect payload
|
||||||
|
// - Collecting + Last → collect payload, → Idle (complete)
|
||||||
|
// - Collecting + Full → error
|
||||||
|
// - Collecting + First → error
|
||||||
|
func (fc *FragmentCollector) Append(recType uint8, payload []byte) error {
|
||||||
|
switch fc.state {
|
||||||
|
case FragmentIdle:
|
||||||
|
switch recType {
|
||||||
|
case RecFull:
|
||||||
|
// Complete batch in one record.
|
||||||
|
if err := fc.checkBufferCapacity(len(payload)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fc.buf.Write(payload)
|
||||||
|
// State stays Idle — batch is complete.
|
||||||
|
return nil
|
||||||
|
case RecFirst:
|
||||||
|
// Start collecting fragments.
|
||||||
|
if err := fc.checkBufferCapacity(len(payload)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fc.buf.Write(payload)
|
||||||
|
fc.state = FragmentCollecting
|
||||||
|
return nil
|
||||||
|
case RecMiddle, RecLast:
|
||||||
|
return fmt.Errorf("wal: unexpected fragment type %d in Idle state", recType)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("wal: invalid fragment type %d", recType)
|
||||||
|
}
|
||||||
|
|
||||||
|
case FragmentCollecting:
|
||||||
|
switch recType {
|
||||||
|
case RecMiddle:
|
||||||
|
if err := fc.checkBufferCapacity(len(payload)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fc.buf.Write(payload)
|
||||||
|
return nil
|
||||||
|
case RecLast:
|
||||||
|
if err := fc.checkBufferCapacity(len(payload)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fc.buf.Write(payload)
|
||||||
|
fc.state = FragmentIdle
|
||||||
|
return nil
|
||||||
|
case RecFull, RecFirst:
|
||||||
|
return fmt.Errorf("wal: unexpected fragment type %d in Collecting state", recType)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("wal: invalid fragment type %d", recType)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("wal: invalid fragment collector state %d", fc.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsComplete reports whether a complete batch has been collected.
|
||||||
|
// A batch is complete when the state returns to Idle after collecting data.
|
||||||
|
func (fc *FragmentCollector) IsComplete() bool {
|
||||||
|
return fc.state == FragmentIdle && fc.buf.Len() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchData returns the collected batch bytes. Only valid when IsComplete() is true.
|
||||||
|
func (fc *FragmentCollector) BatchData() []byte {
|
||||||
|
return fc.buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// State returns the current fragment collector state.
|
||||||
|
func (fc *FragmentCollector) State() FragmentState {
|
||||||
|
return fc.state
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkBufferCapacity ensures the total collected bytes do not exceed the limit.
|
||||||
|
func (fc *FragmentCollector) checkBufferCapacity(additional int) error {
|
||||||
|
newSize := uint32(fc.buf.Len() + additional)
|
||||||
|
if newSize > maxFragmentBufferSize {
|
||||||
|
return fmt.Errorf("wal: fragment buffer size %d exceeds maximum %d", newSize, maxFragmentBufferSize)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// helper: make a Put+Inline entry.
|
||||||
|
func makePutEntry(key, value string) *WalEntry {
|
||||||
|
return &WalEntry{
|
||||||
|
OpType: OpPut,
|
||||||
|
ValueKind: VKInline,
|
||||||
|
Key: []byte(key),
|
||||||
|
Value: []byte(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper: make a Delete entry.
|
||||||
|
func makeDeleteEntry(key string) *WalEntry {
|
||||||
|
return &WalEntry{
|
||||||
|
OpType: OpDelete,
|
||||||
|
ValueKind: VKNone,
|
||||||
|
Key: []byte(key),
|
||||||
|
Value: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchRoundtrip(t *testing.T) {
|
||||||
|
// Build 10 entries: 5 Put+Inline, 3 Delete, 2 Put+Inline with empty value.
|
||||||
|
entries := []*WalEntry{
|
||||||
|
makePutEntry("key1", "val1"),
|
||||||
|
makePutEntry("key2", "val2"),
|
||||||
|
makePutEntry("key3", "val3"),
|
||||||
|
makePutEntry("key4", "val4"),
|
||||||
|
makePutEntry("key5", "val5"),
|
||||||
|
makeDeleteEntry("key6"),
|
||||||
|
makeDeleteEntry("key7"),
|
||||||
|
makeDeleteEntry("key8"),
|
||||||
|
makePutEntry("key9", ""), // empty value
|
||||||
|
makePutEntry("key10", ""), // empty value
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseSeq uint64 = 42
|
||||||
|
|
||||||
|
// Encode.
|
||||||
|
encoded, err := EncodeWalBatch(baseSeq, entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode.
|
||||||
|
batch, err := DecodeWalBatch(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify header fields.
|
||||||
|
if batch.Flags != 0 {
|
||||||
|
t.Errorf("Flags = %d, want 0", batch.Flags)
|
||||||
|
}
|
||||||
|
if batch.BaseSequence != baseSeq {
|
||||||
|
t.Errorf("BaseSequence = %d, want %d", batch.BaseSequence, baseSeq)
|
||||||
|
}
|
||||||
|
if batch.EntryCount != 10 {
|
||||||
|
t.Errorf("EntryCount = %d, want 10", batch.EntryCount)
|
||||||
|
}
|
||||||
|
if batch.EntriesSize != uint32(len(batch.Entries)) {
|
||||||
|
t.Errorf("EntriesSize = %d, len(Entries) = %d", batch.EntriesSize, len(batch.Entries))
|
||||||
|
}
|
||||||
|
if uint32(len(encoded)) != WalBatchHeaderSize+batch.EntriesSize {
|
||||||
|
t.Errorf("total encoded = %d, want %d", len(encoded), WalBatchHeaderSize+batch.EntriesSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse entries from Entries bytes.
|
||||||
|
offset := 0
|
||||||
|
for i, want := range entries {
|
||||||
|
got, consumed, err := DecodeEntry(batch.Entries[offset:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeEntry[%d]: %v", i, err)
|
||||||
|
}
|
||||||
|
if got.OpType != want.OpType {
|
||||||
|
t.Errorf("entry[%d].OpType = %d, want %d", i, got.OpType, want.OpType)
|
||||||
|
}
|
||||||
|
if got.ValueKind != want.ValueKind {
|
||||||
|
t.Errorf("entry[%d].ValueKind = %d, want %d", i, got.ValueKind, want.ValueKind)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got.Key, want.Key) {
|
||||||
|
t.Errorf("entry[%d].Key = %q, want %q", i, got.Key, want.Key)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got.Value, want.Value) {
|
||||||
|
t.Errorf("entry[%d].Value = %q, want %q", i, got.Value, want.Value)
|
||||||
|
}
|
||||||
|
offset += consumed
|
||||||
|
}
|
||||||
|
|
||||||
|
if offset != len(batch.Entries) {
|
||||||
|
t.Errorf("parsed %d bytes, Entries region is %d bytes", offset, len(batch.Entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFragmentCollector(t *testing.T) {
|
||||||
|
// Create a batch large enough to span multiple records (~100 KB).
|
||||||
|
var entries []*WalEntry
|
||||||
|
for i := 0; i < 5000; i++ {
|
||||||
|
key := fmt.Sprintf("key_%06d", i)
|
||||||
|
val := fmt.Sprintf("val_%06d_%050s", i, "x") // ~57 bytes value
|
||||||
|
entries = append(entries, makePutEntry(key, val))
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := EncodeWalBatch(1, entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split into records.
|
||||||
|
records := SplitIntoRecords(encoded)
|
||||||
|
if len(records) < 2 {
|
||||||
|
t.Fatalf("expected multiple records for ~100KB batch, got %d", len(records))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode each physical record to get (type, payload).
|
||||||
|
collector := NewFragmentCollector()
|
||||||
|
|
||||||
|
for i, rec := range records {
|
||||||
|
decoded, _, err := DecodePhysicalRecord(rec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodePhysicalRecord[%d]: %v", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify state transitions.
|
||||||
|
switch {
|
||||||
|
case i == 0:
|
||||||
|
if decoded.Type != RecFirst {
|
||||||
|
t.Errorf("record[0] type = %d, want RecFirst(%d)", decoded.Type, RecFirst)
|
||||||
|
}
|
||||||
|
if collector.State() != FragmentIdle {
|
||||||
|
t.Errorf("before append[0] state = %d, want FragmentIdle", collector.State())
|
||||||
|
}
|
||||||
|
case i == len(records)-1:
|
||||||
|
if decoded.Type != RecLast {
|
||||||
|
t.Errorf("record[%d] type = %d, want RecLast(%d)", i, decoded.Type, RecLast)
|
||||||
|
}
|
||||||
|
if collector.State() != FragmentCollecting {
|
||||||
|
t.Errorf("before append[%d] state = %d, want FragmentCollecting", i, collector.State())
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if decoded.Type != RecMiddle {
|
||||||
|
t.Errorf("record[%d] type = %d, want RecMiddle(%d)", i, decoded.Type, RecMiddle)
|
||||||
|
}
|
||||||
|
if collector.State() != FragmentCollecting {
|
||||||
|
t.Errorf("before append[%d] state = %d, want FragmentCollecting", i, collector.State())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := collector.Append(decoded.Type, decoded.Payload); err != nil {
|
||||||
|
t.Fatalf("Append[%d]: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After all fragments, batch should be complete.
|
||||||
|
if !collector.IsComplete() {
|
||||||
|
t.Fatal("expected IsComplete() after all fragments")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collected data should match original encoded batch.
|
||||||
|
if !bytes.Equal(collector.BatchData(), encoded) {
|
||||||
|
t.Errorf("collected data length = %d, want %d", len(collector.BatchData()), len(encoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the batch can be decoded.
|
||||||
|
batch, err := DecodeWalBatch(collector.BatchData())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeWalBatch from collected: %v", err)
|
||||||
|
}
|
||||||
|
if batch.EntryCount != uint32(len(entries)) {
|
||||||
|
t.Errorf("EntryCount = %d, want %d", batch.EntryCount, len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFragmentCollectorSingleRecord(t *testing.T) {
|
||||||
|
// Small batch fits in one record.
|
||||||
|
entries := []*WalEntry{makePutEntry("k", "v")}
|
||||||
|
encoded, err := EncodeWalBatch(1, entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
records := SplitIntoRecords(encoded)
|
||||||
|
if len(records) != 1 {
|
||||||
|
t.Fatalf("expected 1 record, got %d", len(records))
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, _, err := DecodePhysicalRecord(records[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodePhysicalRecord: %v", err)
|
||||||
|
}
|
||||||
|
if decoded.Type != RecFull {
|
||||||
|
t.Errorf("type = %d, want RecFull(%d)", decoded.Type, RecFull)
|
||||||
|
}
|
||||||
|
|
||||||
|
collector := NewFragmentCollector()
|
||||||
|
if err := collector.Append(decoded.Type, decoded.Payload); err != nil {
|
||||||
|
t.Fatalf("Append: %v", err)
|
||||||
|
}
|
||||||
|
if !collector.IsComplete() {
|
||||||
|
t.Fatal("expected IsComplete()")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(collector.BatchData(), encoded) {
|
||||||
|
t.Error("collected data mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFragmentIllegalTransitions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prepFunc func(fc *FragmentCollector) // set up initial state
|
||||||
|
recType uint8
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Idle+RecMiddle",
|
||||||
|
prepFunc: func(fc *FragmentCollector) {},
|
||||||
|
recType: RecMiddle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Idle+RecLast",
|
||||||
|
prepFunc: func(fc *FragmentCollector) {},
|
||||||
|
recType: RecLast,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Collecting+RecFull",
|
||||||
|
prepFunc: func(fc *FragmentCollector) {
|
||||||
|
_ = fc.Append(RecFirst, []byte("data"))
|
||||||
|
},
|
||||||
|
recType: RecFull,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Collecting+RecFirst",
|
||||||
|
prepFunc: func(fc *FragmentCollector) {
|
||||||
|
_ = fc.Append(RecFirst, []byte("data"))
|
||||||
|
},
|
||||||
|
recType: RecFirst,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fc := NewFragmentCollector()
|
||||||
|
tt.prepFunc(fc)
|
||||||
|
err := fc.Append(tt.recType, []byte("payload"))
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error for %s, got nil", tt.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFragmentBufferSizeLimit(t *testing.T) {
|
||||||
|
// Exceed the maximum fragment buffer size.
|
||||||
|
fc := NewFragmentCollector()
|
||||||
|
|
||||||
|
// Start collecting.
|
||||||
|
if err := fc.Append(RecFirst, make([]byte, maxFragmentBufferSize-1)); err != nil {
|
||||||
|
t.Fatalf("Append First: %v", err)
|
||||||
|
}
|
||||||
|
if fc.State() != FragmentCollecting {
|
||||||
|
t.Fatal("expected FragmentCollecting state")
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should exceed the limit.
|
||||||
|
err := fc.Append(RecMiddle, make([]byte, 10))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for exceeding buffer size limit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFragmentReset(t *testing.T) {
|
||||||
|
fc := NewFragmentCollector()
|
||||||
|
if err := fc.Append(RecFirst, []byte("data")); err != nil {
|
||||||
|
t.Fatalf("Append: %v", err)
|
||||||
|
}
|
||||||
|
if fc.State() != FragmentCollecting {
|
||||||
|
t.Fatal("expected FragmentCollecting")
|
||||||
|
}
|
||||||
|
|
||||||
|
fc.Reset()
|
||||||
|
if fc.State() != FragmentIdle {
|
||||||
|
t.Errorf("state after Reset = %d, want FragmentIdle", fc.State())
|
||||||
|
}
|
||||||
|
if fc.IsComplete() {
|
||||||
|
t.Error("IsComplete() should be false after Reset")
|
||||||
|
}
|
||||||
|
if fc.buf.Len() != 0 {
|
||||||
|
t.Errorf("buffer len after Reset = %d, want 0", fc.buf.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchValidationErrors(t *testing.T) {
|
||||||
|
t.Run("EntryCountZero", func(t *testing.T) {
|
||||||
|
// Manually craft a batch with EntryCount=0.
|
||||||
|
buf := make([]byte, WalBatchHeaderSize)
|
||||||
|
binary.LittleEndian.PutUint16(buf[0:2], 0)
|
||||||
|
binary.LittleEndian.PutUint64(buf[2:10], 1)
|
||||||
|
binary.LittleEndian.PutUint32(buf[10:14], 0) // entryCount = 0
|
||||||
|
binary.LittleEndian.PutUint32(buf[14:18], 0)
|
||||||
|
_, err := DecodeWalBatch(buf)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for entryCount=0")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("EntryCountExceedsMax", func(t *testing.T) {
|
||||||
|
buf := make([]byte, WalBatchHeaderSize)
|
||||||
|
binary.LittleEndian.PutUint16(buf[0:2], 0)
|
||||||
|
binary.LittleEndian.PutUint64(buf[2:10], 1)
|
||||||
|
binary.LittleEndian.PutUint32(buf[10:14], MaxWalBatchEntryCount+1)
|
||||||
|
binary.LittleEndian.PutUint32(buf[14:18], 1)
|
||||||
|
_, err := DecodeWalBatch(buf)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for entryCount > max")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("EntriesSizeMismatch", func(t *testing.T) {
|
||||||
|
buf := make([]byte, WalBatchHeaderSize)
|
||||||
|
binary.LittleEndian.PutUint16(buf[0:2], 0)
|
||||||
|
binary.LittleEndian.PutUint64(buf[2:10], 1)
|
||||||
|
binary.LittleEndian.PutUint32(buf[10:14], 1)
|
||||||
|
binary.LittleEndian.PutUint32(buf[14:18], 100) // entriesSize=100 but no data after header
|
||||||
|
_, err := DecodeWalBatch(buf)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for entriesSize mismatch")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("DataTooShort", func(t *testing.T) {
|
||||||
|
_, err := DecodeWalBatch([]byte{1, 2, 3})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for data too short")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("EmptyEntriesSlice", func(t *testing.T) {
|
||||||
|
_, err := EncodeWalBatch(1, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty entries slice")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BlockWriter manages a single 32 KB block buffer for writing physical records.
|
||||||
|
// It handles block boundary padding and flushing complete blocks to an io.Writer.
|
||||||
|
type BlockWriter struct {
|
||||||
|
buf [WalBlockSize]byte
|
||||||
|
offset uint32 // current write position within the block
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBlockWriter creates a BlockWriter ready to write into a fresh block.
|
||||||
|
func NewBlockWriter() *BlockWriter {
|
||||||
|
return &BlockWriter{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockOffset returns the current write offset within the block (0..WalBlockSize).
|
||||||
|
func (bw *BlockWriter) BlockOffset() uint32 {
|
||||||
|
return bw.offset
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteRecord writes a single physical record into the block buffer.
|
||||||
|
// If the record (header + payload) does not fit in the remaining space,
|
||||||
|
// the current block is padded with zeros and flushed to w, then the record
|
||||||
|
// is written at the start of a fresh block.
|
||||||
|
//
|
||||||
|
// Precondition: payload length must be ≤ WalBlockSize - PhysicalRecordHeaderSize
|
||||||
|
// (the caller is responsible for splitting large batches into appropriately-sized chunks).
|
||||||
|
func (bw *BlockWriter) WriteRecord(recType uint8, payload []byte, w io.Writer) error {
|
||||||
|
recordSize := PhysicalRecordHeaderSize + len(payload)
|
||||||
|
|
||||||
|
if recordSize > WalBlockSize {
|
||||||
|
return fmt.Errorf("wal: record size %d exceeds block size %d",
|
||||||
|
recordSize, WalBlockSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if padding is needed before writing this record.
|
||||||
|
pad := bw.paddingNeeded()
|
||||||
|
if pad > 0 {
|
||||||
|
// Pad remaining bytes with zeros and flush.
|
||||||
|
if err := bw.flushPadded(w, pad); err != nil {
|
||||||
|
return fmt.Errorf("wal: flushing padded block: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the record fits in the current block.
|
||||||
|
remaining := WalBlockSize - bw.offset
|
||||||
|
if uint32(recordSize) > remaining {
|
||||||
|
// Not enough room — pad the rest and flush, then start a new block.
|
||||||
|
pad = int(remaining)
|
||||||
|
if err := bw.flushPadded(w, pad); err != nil {
|
||||||
|
return fmt.Errorf("wal: flushing partial block: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode physical record directly into the block buffer.
|
||||||
|
encoded := EncodePhysicalRecord(recType, payload)
|
||||||
|
copy(bw.buf[bw.offset:], encoded)
|
||||||
|
bw.offset += uint32(len(encoded))
|
||||||
|
|
||||||
|
// If the block is exactly full, flush it immediately.
|
||||||
|
if bw.offset == WalBlockSize {
|
||||||
|
if _, err := w.Write(bw.buf[:]); err != nil {
|
||||||
|
return fmt.Errorf("wal: writing full block: %w", err)
|
||||||
|
}
|
||||||
|
bw.offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush writes the current block buffer to w, padding unused bytes with zeros.
|
||||||
|
// If the block is empty (offset == 0), this is a no-op.
|
||||||
|
func (bw *BlockWriter) Flush(w io.Writer) error {
|
||||||
|
if bw.offset == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return bw.flushPadded(w, int(WalBlockSize-bw.offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clears the block buffer, returning it to an empty state.
|
||||||
|
func (bw *BlockWriter) Reset() {
|
||||||
|
bw.offset = 0
|
||||||
|
// Zero the buffer so partial blocks are padded with zeros.
|
||||||
|
for i := range bw.buf {
|
||||||
|
bw.buf[i] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// paddingNeeded returns the number of zero-padding bytes required at the current
|
||||||
|
// block offset. When the remaining space in the block is ≤ PhysicalRecordHeaderSize (7),
|
||||||
|
// that space cannot hold even a minimal physical record and must be zero-padded.
|
||||||
|
func (bw *BlockWriter) paddingNeeded() int {
|
||||||
|
remaining := WalBlockSize - bw.offset
|
||||||
|
if remaining <= PhysicalRecordHeaderSize {
|
||||||
|
return int(remaining)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushPadded pads the remaining bytes with zeros and writes the full block to w.
|
||||||
|
// pad is the number of trailing bytes to zero-fill (WalBlockSize - offset - pad already zero
|
||||||
|
// from initial state or previous Reset).
|
||||||
|
func (bw *BlockWriter) flushPadded(w io.Writer, pad int) error {
|
||||||
|
if pad <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero-fill padding region. The buffer was zeroed at init/reset,
|
||||||
|
// but we write explicitly for safety after partial record writes.
|
||||||
|
for i := uint32(0); i < uint32(pad); i++ {
|
||||||
|
bw.buf[bw.offset+i] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := w.Write(bw.buf[:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
bw.offset = 0
|
||||||
|
for i := range bw.buf {
|
||||||
|
bw.buf[i] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes returns a copy of the current block contents up to the current offset.
|
||||||
|
// Useful for testing.
|
||||||
|
func (bw *BlockWriter) Bytes() []byte {
|
||||||
|
out := make([]byte, bw.offset)
|
||||||
|
copy(out, bw.buf[:bw.offset])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullBlockBytes returns the full block buffer. Only valid when offset == WalBlockSize.
|
||||||
|
func (bw *BlockWriter) FullBlockBytes() []byte {
|
||||||
|
out := make([]byte, WalBlockSize)
|
||||||
|
copy(out, bw.buf[:])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// errBlockWriterNil is returned when a nil writer is passed to write operations.
|
||||||
|
var errBlockWriterNil = errors.New("wal: writer must not be nil")
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"hash/crc32"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBlockWriterSingleRecord(t *testing.T) {
|
||||||
|
bw := NewBlockWriter()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
payload := []byte("hello world")
|
||||||
|
if err := bw.WriteRecord(RecFull, payload, &buf); err != nil {
|
||||||
|
t.Fatalf("WriteRecord: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record should still be buffered (block not full).
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Fatalf("expected no flush yet, got %d bytes", buf.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush to get the data.
|
||||||
|
if err := bw.Flush(&buf); err != nil {
|
||||||
|
t.Fatalf("Flush: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
written := buf.Bytes()
|
||||||
|
|
||||||
|
// Verify the record is at the start of a full block.
|
||||||
|
if len(written) != WalBlockSize {
|
||||||
|
t.Fatalf("expected full block %d bytes, got %d", WalBlockSize, len(written))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode and verify the physical record.
|
||||||
|
rec, consumed, err := DecodePhysicalRecord(written)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodePhysicalRecord: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Type != RecFull {
|
||||||
|
t.Errorf("type = %d, want RecFull(%d)", rec.Type, RecFull)
|
||||||
|
}
|
||||||
|
if string(rec.Payload) != "hello world" {
|
||||||
|
t.Errorf("payload = %q, want %q", rec.Payload, "hello world")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining bytes after the record should be zero padding.
|
||||||
|
recEnd := consumed
|
||||||
|
for i := recEnd; i < WalBlockSize; i++ {
|
||||||
|
if written[i] != 0 {
|
||||||
|
t.Errorf("padding byte [%d] = %d, want 0", i, written[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockWriterPadding(t *testing.T) {
|
||||||
|
bw := NewBlockWriter()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
payloadLen := WalBlockSize - PhysicalRecordHeaderSize
|
||||||
|
payload := make([]byte, payloadLen)
|
||||||
|
for i := range payload {
|
||||||
|
payload[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bw.WriteRecord(RecFull, payload, &buf); err != nil {
|
||||||
|
t.Fatalf("WriteRecord: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.Len() != WalBlockSize {
|
||||||
|
t.Fatalf("expected auto-flush of full block (%d bytes), got %d", WalBlockSize, buf.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
if bw.BlockOffset() != 0 {
|
||||||
|
t.Errorf("BlockOffset = %d, want 0 after full block write", bw.BlockOffset())
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.Reset()
|
||||||
|
smallPayload := []byte("next")
|
||||||
|
if err := bw.WriteRecord(RecFull, smallPayload, &buf); err != nil {
|
||||||
|
t.Fatalf("WriteRecord after full block: %v", err)
|
||||||
|
}
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Fatalf("expected no flush for partial block, got %d bytes", buf.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockWriterPaddingNeeded(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
offset uint32
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"beginning", 0, 0},
|
||||||
|
{"mid_block", 100, 0},
|
||||||
|
{"7_remaining", WalBlockSize - 7, 7},
|
||||||
|
{"6_remaining", WalBlockSize - 6, 6},
|
||||||
|
{"1_remaining", WalBlockSize - 1, 1},
|
||||||
|
{"full_block", WalBlockSize, 0}, // would be reset
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
bw := &BlockWriter{offset: tt.offset}
|
||||||
|
got := bw.paddingNeeded()
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("paddingNeeded() = %d, want %d", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockWriterCrossBlock(t *testing.T) {
|
||||||
|
bw := NewBlockWriter()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
payloadLen := WalBlockSize - PhysicalRecordHeaderSize - 5
|
||||||
|
payload1 := make([]byte, payloadLen)
|
||||||
|
for i := range payload1 {
|
||||||
|
payload1[i] = byte('A' + i%26)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bw.WriteRecord(RecFull, payload1, &buf); err != nil {
|
||||||
|
t.Fatalf("WriteRecord payload1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Fatalf("expected no flush after first record, got %d bytes", buf.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
payload2 := []byte("second")
|
||||||
|
if err := bw.WriteRecord(RecFull, payload2, &buf); err != nil {
|
||||||
|
t.Fatalf("WriteRecord payload2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.Len() != WalBlockSize {
|
||||||
|
t.Fatalf("expected %d bytes flushed, got %d", WalBlockSize, buf.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
firstBlock := buf.Bytes()[:WalBlockSize]
|
||||||
|
rec, _, err := DecodePhysicalRecord(firstBlock)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodePhysicalRecord block 1: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Type != RecFull {
|
||||||
|
t.Errorf("type = %d, want RecFull", rec.Type)
|
||||||
|
}
|
||||||
|
if len(rec.Payload) != payloadLen {
|
||||||
|
t.Errorf("payload len = %d, want %d", len(rec.Payload), payloadLen)
|
||||||
|
}
|
||||||
|
for i := WalBlockSize - 5; i < WalBlockSize; i++ {
|
||||||
|
if firstBlock[i] != 0 {
|
||||||
|
t.Errorf("padding byte [%d] = %d, want 0", i, firstBlock[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bw.Flush(&buf); err != nil {
|
||||||
|
t.Fatalf("Flush: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secondBlock := buf.Bytes()[WalBlockSize:]
|
||||||
|
if len(secondBlock) != WalBlockSize {
|
||||||
|
t.Fatalf("second block: expected %d bytes, got %d", WalBlockSize, len(secondBlock))
|
||||||
|
}
|
||||||
|
|
||||||
|
rec2, _, err := DecodePhysicalRecord(secondBlock)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodePhysicalRecord block 2: %v", err)
|
||||||
|
}
|
||||||
|
if string(rec2.Payload) != "second" {
|
||||||
|
t.Errorf("payload2 = %q, want %q", rec2.Payload, "second")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockWriterRecordTooLarge(t *testing.T) {
|
||||||
|
bw := NewBlockWriter()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Payload that exceeds block capacity.
|
||||||
|
payload := make([]byte, WalBlockSize)
|
||||||
|
err := bw.WriteRecord(RecFull, payload, &buf)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for oversized record")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockWriterMultipleRecords(t *testing.T) {
|
||||||
|
bw := NewBlockWriter()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Write several small records.
|
||||||
|
records := []struct {
|
||||||
|
recType uint8
|
||||||
|
payload []byte
|
||||||
|
}{
|
||||||
|
{RecFirst, []byte("part1")},
|
||||||
|
{RecMiddle, []byte("part2")},
|
||||||
|
{RecLast, []byte("part3")},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range records {
|
||||||
|
if err := bw.WriteRecord(r.recType, r.payload, &buf); err != nil {
|
||||||
|
t.Fatalf("WriteRecord(%d, %q): %v", r.recType, r.payload, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bw.Flush(&buf); err != nil {
|
||||||
|
t.Fatalf("Flush: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := buf.Bytes()
|
||||||
|
|
||||||
|
// Decode all three records from the block.
|
||||||
|
offset := 0
|
||||||
|
for i, expected := range records {
|
||||||
|
rec, consumed, err := DecodePhysicalRecord(data[offset:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("record %d: DecodePhysicalRecord at offset %d: %v", i, offset, err)
|
||||||
|
}
|
||||||
|
if rec.Type != expected.recType {
|
||||||
|
t.Errorf("record %d: type = %d, want %d", i, rec.Type, expected.recType)
|
||||||
|
}
|
||||||
|
if string(rec.Payload) != string(expected.payload) {
|
||||||
|
t.Errorf("record %d: payload = %q, want %q", i, rec.Payload, expected.payload)
|
||||||
|
}
|
||||||
|
offset += consumed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockWriterReset(t *testing.T) {
|
||||||
|
bw := NewBlockWriter()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
if err := bw.WriteRecord(RecFull, []byte("data"), &buf); err != nil {
|
||||||
|
t.Fatalf("WriteRecord: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bw.BlockOffset() == 0 {
|
||||||
|
t.Fatal("expected non-zero offset after write")
|
||||||
|
}
|
||||||
|
|
||||||
|
bw.Reset()
|
||||||
|
if bw.BlockOffset() != 0 {
|
||||||
|
t.Errorf("BlockOffset after Reset = %d, want 0", bw.BlockOffset())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockWriterFlushEmptyBlock(t *testing.T) {
|
||||||
|
bw := NewBlockWriter()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Flushing an empty block should be a no-op.
|
||||||
|
if err := bw.Flush(&buf); err != nil {
|
||||||
|
t.Fatalf("Flush empty: %v", err)
|
||||||
|
}
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Errorf("expected 0 bytes, got %d", buf.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockWriterOffsetTracking(t *testing.T) {
|
||||||
|
bw := NewBlockWriter()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Write a small record and verify offset.
|
||||||
|
payload := []byte("track-me")
|
||||||
|
if err := bw.WriteRecord(RecFull, payload, &buf); err != nil {
|
||||||
|
t.Fatalf("WriteRecord: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedOffset := uint32(PhysicalRecordHeaderSize + len(payload))
|
||||||
|
if bw.BlockOffset() != expectedOffset {
|
||||||
|
t.Errorf("BlockOffset = %d, want %d", bw.BlockOffset(), expectedOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush should write exactly one full block.
|
||||||
|
if err := bw.Flush(&buf); err != nil {
|
||||||
|
t.Fatalf("Flush: %v", err)
|
||||||
|
}
|
||||||
|
if buf.Len() != WalBlockSize {
|
||||||
|
t.Errorf("flushed %d bytes, want %d", buf.Len(), WalBlockSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockWriterAutoFlushFullBlock(t *testing.T) {
|
||||||
|
bw := NewBlockWriter()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
// Fill the block exactly.
|
||||||
|
payloadLen := WalBlockSize - PhysicalRecordHeaderSize
|
||||||
|
payload := make([]byte, payloadLen)
|
||||||
|
for i := range payload {
|
||||||
|
payload[i] = byte(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bw.WriteRecord(RecFull, payload, &buf); err != nil {
|
||||||
|
t.Fatalf("WriteRecord exact fill: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block should have been auto-flushed.
|
||||||
|
if buf.Len() != WalBlockSize {
|
||||||
|
t.Errorf("expected auto-flush of %d bytes, got %d", WalBlockSize, buf.Len())
|
||||||
|
}
|
||||||
|
if bw.BlockOffset() != 0 {
|
||||||
|
t.Errorf("BlockOffset after auto-flush = %d, want 0", bw.BlockOffset())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify CRC is correct by decoding.
|
||||||
|
data := buf.Bytes()
|
||||||
|
rec, _, err := DecodePhysicalRecord(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodePhysicalRecord: %v", err)
|
||||||
|
}
|
||||||
|
if len(rec.Payload) != payloadLen {
|
||||||
|
t.Errorf("payload len = %d, want %d", len(rec.Payload), payloadLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockWriterPhysicalRecordCRC(t *testing.T) {
|
||||||
|
bw := NewBlockWriter()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
payload := []byte("crc-check")
|
||||||
|
if err := bw.WriteRecord(RecFull, payload, &buf); err != nil {
|
||||||
|
t.Fatalf("WriteRecord: %v", err)
|
||||||
|
}
|
||||||
|
if err := bw.Flush(&buf); err != nil {
|
||||||
|
t.Fatalf("Flush: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := buf.Bytes()
|
||||||
|
|
||||||
|
// Manually verify CRC: covers length + type + payload.
|
||||||
|
storedCRC := binary.LittleEndian.Uint32(data[0:4])
|
||||||
|
length := binary.LittleEndian.Uint16(data[4:6])
|
||||||
|
recType := data[6]
|
||||||
|
|
||||||
|
if recType != RecFull {
|
||||||
|
t.Errorf("type = %d, want RecFull", recType)
|
||||||
|
}
|
||||||
|
if int(length) != len(payload) {
|
||||||
|
t.Errorf("length = %d, want %d", length, len(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify CRC over [length, type, payload].
|
||||||
|
crcData := data[4 : 7+length]
|
||||||
|
computedCRC := crc32.Checksum(crcData, crc32cTable)
|
||||||
|
if storedCRC != computedCRC {
|
||||||
|
t.Errorf("CRC mismatch: stored %d, computed %d", storedCRC, computedCRC)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
// WriteResult is sent to the caller when the write completes or fails.
|
||||||
|
type WriteResult struct {
|
||||||
|
Sequence uint64
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitRequest represents a single write request submitted by a caller.
|
||||||
|
type CommitRequest struct {
|
||||||
|
Entries []*WalEntry
|
||||||
|
Result chan WriteResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitQueue is a bounded buffered channel for write requests.
|
||||||
|
type CommitQueue struct {
|
||||||
|
ch chan *CommitRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCommitQueue creates a bounded queue for commit requests.
|
||||||
|
func NewCommitQueue(capacity int) *CommitQueue {
|
||||||
|
if capacity < 1 {
|
||||||
|
capacity = 1
|
||||||
|
}
|
||||||
|
return &CommitQueue{ch: make(chan *CommitRequest, capacity)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit creates a commit request, submits it to the queue, and returns it.
|
||||||
|
// The send blocks when the queue is full, preserving backpressure.
|
||||||
|
func (cq *CommitQueue) Submit(entries []*WalEntry) *CommitRequest {
|
||||||
|
req := &CommitRequest{
|
||||||
|
Entries: entries,
|
||||||
|
Result: make(chan WriteResult, 1),
|
||||||
|
}
|
||||||
|
cq.ch <- req
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect drains all currently pending requests from the queue.
|
||||||
|
func (cq *CommitQueue) Collect() []*CommitRequest {
|
||||||
|
var requests []*CommitRequest
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case req, ok := <-cq.ch:
|
||||||
|
if !ok {
|
||||||
|
return requests
|
||||||
|
}
|
||||||
|
requests = append(requests, req)
|
||||||
|
default:
|
||||||
|
return requests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the queue. Callers must stop submitting before Close.
|
||||||
|
func (cq *CommitQueue) Close() {
|
||||||
|
close(cq.ch)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
// WAL file format constants.
|
||||||
|
|
||||||
|
const (
|
||||||
|
// WalMagic is the file type identifier for WAL segment files ("WALK").
|
||||||
|
WalMagic uint32 = 0x57414C4B
|
||||||
|
|
||||||
|
// WalFormatVersion is the WAL file format version. First version is 1.
|
||||||
|
WalFormatVersion uint16 = 1
|
||||||
|
|
||||||
|
// WalFileHeaderSize is the size of the WAL file header in bytes.
|
||||||
|
// Fields: magic(4) + formatVersion(2) + headerSize(2) + blockSize(4) +
|
||||||
|
// segmentID(8) + startSequence(8) + headerCRC(4) = 32.
|
||||||
|
WalFileHeaderSize = 32
|
||||||
|
|
||||||
|
// WalBlockSize is the fixed size of each WAL block in bytes (32 KB).
|
||||||
|
WalBlockSize = 32 * 1024
|
||||||
|
|
||||||
|
// PhysicalRecordHeaderSize is the size of a physical record header in bytes.
|
||||||
|
// Fields: crc32c(4) + length(2) + type(1) = 7.
|
||||||
|
PhysicalRecordHeaderSize = 7
|
||||||
|
|
||||||
|
// WalBatchHeaderSize is the size of a WAL batch header in bytes.
|
||||||
|
// Fields: flags(2) + baseSequence(8) + entryCount(4) + entriesSize(4) = 18.
|
||||||
|
WalBatchHeaderSize = 18
|
||||||
|
|
||||||
|
// MaxWalBatchEntryCount limits the number of entries in a single batch.
|
||||||
|
MaxWalBatchEntryCount uint32 = 10000
|
||||||
|
|
||||||
|
// MaxWalBatchEntriesSize limits the total size of the entries region in bytes (4 MB).
|
||||||
|
MaxWalBatchEntriesSize uint32 = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
// MaxWalKeyBytes limits the size of a single key in bytes (4 KB).
|
||||||
|
MaxWalKeyBytes uint32 = 4 * 1024
|
||||||
|
|
||||||
|
// MaxWalInlineValueBytes limits the size of an inline value in bytes (4 KB).
|
||||||
|
// Values exceeding this must use ValueLogPointer.
|
||||||
|
MaxWalInlineValueBytes uint32 = 4 * 1024
|
||||||
|
|
||||||
|
// MaxWalVarintBytes is the maximum encoded length of a varint field.
|
||||||
|
MaxWalVarintBytes = 5
|
||||||
|
|
||||||
|
// DefaultMaxWalSegmentSize is the default maximum size of a WAL segment file (64 MB).
|
||||||
|
DefaultMaxWalSegmentSize uint64 = 64 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fragment types for physical records.
|
||||||
|
const (
|
||||||
|
// RecInvalid is an illegal fragment type used for corruption detection.
|
||||||
|
RecInvalid uint8 = 0
|
||||||
|
// RecFull indicates a complete WAL batch in a single physical record.
|
||||||
|
RecFull uint8 = 1
|
||||||
|
// RecFirst is the first fragment of a multi-record WAL batch.
|
||||||
|
RecFirst uint8 = 2
|
||||||
|
// RecMiddle is a middle fragment (may appear zero or more times).
|
||||||
|
RecMiddle uint8 = 3
|
||||||
|
// RecLast is the last fragment of a multi-record WAL batch.
|
||||||
|
RecLast uint8 = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpType represents the operation type of a WAL entry.
|
||||||
|
const (
|
||||||
|
// OpInvalid is an illegal operation type used for corruption detection.
|
||||||
|
OpInvalid uint8 = 0
|
||||||
|
// OpPut represents a key-value put operation.
|
||||||
|
OpPut uint8 = 1
|
||||||
|
// OpDelete represents a key deletion operation.
|
||||||
|
OpDelete uint8 = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValueKind represents how the value field is encoded in a WAL entry.
|
||||||
|
const (
|
||||||
|
// VKNone indicates no value (used with Delete operations).
|
||||||
|
VKNone uint8 = 0
|
||||||
|
// VKInline indicates the value field contains inline user bytes.
|
||||||
|
VKInline uint8 = 1
|
||||||
|
// VKValueLogPointer indicates the value field contains an encoded Value Log pointer.
|
||||||
|
VKValueLogPointer uint8 = 2
|
||||||
|
)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestConstantValues(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
got interface{}
|
||||||
|
expected interface{}
|
||||||
|
}{
|
||||||
|
{"WalBlockSize", WalBlockSize, 32 * 1024},
|
||||||
|
{"WalFileHeaderSize", WalFileHeaderSize, 32},
|
||||||
|
{"WalBatchHeaderSize", WalBatchHeaderSize, 18},
|
||||||
|
{"PhysicalRecordHeaderSize", PhysicalRecordHeaderSize, 7},
|
||||||
|
{"MaxWalBatchEntriesSize", MaxWalBatchEntriesSize, uint32(4 * 1024 * 1024)},
|
||||||
|
{"MaxWalBatchEntryCount", MaxWalBatchEntryCount, uint32(10000)},
|
||||||
|
{"MaxWalKeyBytes", MaxWalKeyBytes, uint32(4 * 1024)},
|
||||||
|
{"MaxWalInlineValueBytes", MaxWalInlineValueBytes, uint32(4 * 1024)},
|
||||||
|
{"MaxWalVarintBytes", MaxWalVarintBytes, 5},
|
||||||
|
{"DefaultMaxWalSegmentSize", DefaultMaxWalSegmentSize, uint64(64 * 1024 * 1024)},
|
||||||
|
{"WalMagic", WalMagic, uint32(0x57414C4B)},
|
||||||
|
{"WalFormatVersion", WalFormatVersion, uint16(1)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if tt.got != tt.expected {
|
||||||
|
t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFragmentTypes(t *testing.T) {
|
||||||
|
if RecInvalid != uint8(0) {
|
||||||
|
t.Errorf("RecInvalid = %d, want 0", RecInvalid)
|
||||||
|
}
|
||||||
|
if RecFull != uint8(1) {
|
||||||
|
t.Errorf("RecFull = %d, want 1", RecFull)
|
||||||
|
}
|
||||||
|
if RecFirst != uint8(2) {
|
||||||
|
t.Errorf("RecFirst = %d, want 2", RecFirst)
|
||||||
|
}
|
||||||
|
if RecMiddle != uint8(3) {
|
||||||
|
t.Errorf("RecMiddle = %d, want 3", RecMiddle)
|
||||||
|
}
|
||||||
|
if RecLast != uint8(4) {
|
||||||
|
t.Errorf("RecLast = %d, want 4", RecLast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpTypes(t *testing.T) {
|
||||||
|
if OpInvalid != uint8(0) {
|
||||||
|
t.Errorf("OpInvalid = %d, want 0", OpInvalid)
|
||||||
|
}
|
||||||
|
if OpPut != uint8(1) {
|
||||||
|
t.Errorf("OpPut = %d, want 1", OpPut)
|
||||||
|
}
|
||||||
|
if OpDelete != uint8(2) {
|
||||||
|
t.Errorf("OpDelete = %d, want 2", OpDelete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValueKinds(t *testing.T) {
|
||||||
|
if VKNone != uint8(0) {
|
||||||
|
t.Errorf("VKNone = %d, want 0", VKNone)
|
||||||
|
}
|
||||||
|
if VKInline != uint8(1) {
|
||||||
|
t.Errorf("VKInline = %d, want 1", VKInline)
|
||||||
|
}
|
||||||
|
if VKValueLogPointer != uint8(2) {
|
||||||
|
t.Errorf("VKValueLogPointer = %d, want 2", VKValueLogPointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import "hash/crc32"
|
||||||
|
|
||||||
|
// crc32cTable is the CRC-32 table using the Castagnoli polynomial (0x82F63B78
|
||||||
|
// reflected). Required by design §3.2 line 359 for all WAL CRC computations.
|
||||||
|
//
|
||||||
|
// Distinct from crc32.IEEE (Ethernet/PNG polynomial 0xEDB88320) — the two
|
||||||
|
// produce unrelated checksums for the same input. SSE4.2 native CRC32
|
||||||
|
// instruction only supports Castagnoli, so this table also enables hardware
|
||||||
|
// acceleration via Go's standard library internals.
|
||||||
|
var crc32cTable = crc32.MakeTable(crc32.Castagnoli)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"hash/crc32"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Regression guard for C1: verify all WAL CRC uses Castagnoli polynomial
|
||||||
|
// (crc32c), not IEEE. Per design §3.2 line 359.
|
||||||
|
//
|
||||||
|
// The standard CRC-32C test vector from RFC 3720 Appendix B is the 9-byte
|
||||||
|
// ASCII string "123456789":
|
||||||
|
// - crc32c: 0xE3069283
|
||||||
|
// - crc32 IEEE: 0xCBF43926
|
||||||
|
//
|
||||||
|
// If anyone changes crc32cTable back to IEEE, this test fails immediately.
|
||||||
|
func TestCRC32CStandardVector(t *testing.T) {
|
||||||
|
got := crc32.Checksum([]byte("123456789"), crc32cTable)
|
||||||
|
const want = uint32(0xE3069283)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("crc32c('123456789') = 0x%X, want 0x%X (Castagnoli)", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negative regression: confirm IEEE would produce a DIFFERENT value. This
|
||||||
|
// catches the case where someone "fixes" crc32cTable to use IEEE by mistake.
|
||||||
|
func TestCRC32CEdistinctFromIEEE(t *testing.T) {
|
||||||
|
data := []byte("123456789")
|
||||||
|
ieee := crc32.ChecksumIEEE(data)
|
||||||
|
castagnoli := crc32.Checksum(data, crc32cTable)
|
||||||
|
if ieee == castagnoli {
|
||||||
|
t.Errorf("IEEE and Castagnoli produced the same CRC (impossible unless table is wrong); both = 0x%X", ieee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C1: header CRC must be computed with crc32c, not IEEE.
|
||||||
|
// TestHeaderRoundtrip is self-consistent (encode + decode use same polynomial),
|
||||||
|
// so a paired reversion to IEEE would pass it. This test directly asserts
|
||||||
|
// the stored CRC matches Castagnoli.
|
||||||
|
func TestHeaderCRCUsesCastagnoli(t *testing.T) {
|
||||||
|
hdr := &WalFileHeader{
|
||||||
|
BlockSize: 32 * 1024,
|
||||||
|
SegmentID: 42,
|
||||||
|
StartSequence: 100,
|
||||||
|
}
|
||||||
|
encoded := EncodeWalHeader(hdr)
|
||||||
|
want := crc32.Checksum(encoded[0:28], crc32cTable)
|
||||||
|
got := binary.LittleEndian.Uint32(encoded[28:32])
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("stored headerCRC = 0x%X, want crc32c value 0x%X", got, want)
|
||||||
|
}
|
||||||
|
// Sanity: confirm IEEE would produce a different value (catches paired reversion).
|
||||||
|
ieeeValue := crc32.ChecksumIEEE(encoded[0:28])
|
||||||
|
if got == ieeeValue {
|
||||||
|
t.Errorf("stored headerCRC = 0x%X matches IEEE value (C1 regression)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C1: physical record CRC must be Castagnoli.
|
||||||
|
// Same rationale as TestHeaderCRCUsesCastagnoli — direct value assertion
|
||||||
|
// catches paired encode/decode reversion.
|
||||||
|
func TestPhysicalRecordCRCUsesCastagnoli(t *testing.T) {
|
||||||
|
payload := []byte("test-payload")
|
||||||
|
encoded := EncodePhysicalRecord(RecFull, payload)
|
||||||
|
// Record format: [crc u32][length u16][type u8][payload]
|
||||||
|
want := crc32.Checksum(encoded[4:7+len(payload)], crc32cTable)
|
||||||
|
got := binary.LittleEndian.Uint32(encoded[0:4])
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("stored record CRC = 0x%X, want crc32c value 0x%X", got, want)
|
||||||
|
}
|
||||||
|
ieeeValue := crc32.ChecksumIEEE(encoded[4 : 7+len(payload)])
|
||||||
|
if got == ieeeValue {
|
||||||
|
t.Errorf("stored record CRC = 0x%X matches IEEE value (C1 regression)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dirFsyncFn is the package-level indirection over dirFsync so tests can
|
||||||
|
// inject failures without interface plumbing in production code.
|
||||||
|
//
|
||||||
|
// NOT PARALLEL-SAFE: tests that override this must not use t.Parallel().
|
||||||
|
// All existing wal tests run serially within the package.
|
||||||
|
var dirFsyncFn = dirFsync
|
||||||
|
|
||||||
|
// dirFsync opens the directory and fsyncs it. Required for durable-ready
|
||||||
|
// state per design §3.2 line 258. This is a hard requirement, not
|
||||||
|
// best-effort: rename is atomic in memory but not guaranteed to survive
|
||||||
|
// power loss without a directory fsync.
|
||||||
|
//
|
||||||
|
// Order: os.Open → f.Stat → IsDir → f.Sync. The open-then-stat sequence
|
||||||
|
// avoids the TOCTOU window between a separate os.Stat and os.Open, and
|
||||||
|
// ensures IsDir is checked against the actually-opened file.
|
||||||
|
func dirFsync(dir string) error {
|
||||||
|
f, err := os.Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open dir %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stat dir %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return fmt.Errorf("path %q is not a directory", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("fsync dir %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDirFsyncSuccess(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := dirFsync(dir); err != nil {
|
||||||
|
t.Errorf("dirFsync on valid dir: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirFsyncNonExistentDir(t *testing.T) {
|
||||||
|
err := dirFsync("/nonexistent/path/that/should/not/exist")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on non-existent dir")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "open dir") {
|
||||||
|
t.Errorf("error should mention 'open dir', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirFsyncNotADirectory(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := dir + "/notadir"
|
||||||
|
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
err := dirFsync(filePath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when fsyncing a file as dir")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not a directory") {
|
||||||
|
t.Errorf("error should mention 'not a directory', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Package wal implements the Write-Ahead Log subsystem.
|
||||||
|
package wal
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WalEntry represents a single WAL record.
|
||||||
|
type WalEntry struct {
|
||||||
|
OpType uint8
|
||||||
|
ValueKind uint8
|
||||||
|
Key []byte
|
||||||
|
Value []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks that the entry fields are consistent with the design rules.
|
||||||
|
func (e *WalEntry) Validate() error {
|
||||||
|
keyLen := len(e.Key)
|
||||||
|
if keyLen == 0 || keyLen > int(MaxWalKeyBytes) {
|
||||||
|
return fmt.Errorf("wal: invalid key length %d", keyLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
valLen := len(e.Value)
|
||||||
|
|
||||||
|
switch e.OpType {
|
||||||
|
case OpPut:
|
||||||
|
switch e.ValueKind {
|
||||||
|
case VKInline:
|
||||||
|
if valLen > int(MaxWalInlineValueBytes) {
|
||||||
|
return fmt.Errorf("wal: inline value length %d out of range [0, %d]", valLen, MaxWalInlineValueBytes)
|
||||||
|
}
|
||||||
|
case VKValueLogPointer:
|
||||||
|
if valLen == 0 {
|
||||||
|
return errors.New("wal: value log pointer requires non-empty value")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("wal: put requires valueKind Inline(1) or ValueLogPointer(2), got %d", e.ValueKind)
|
||||||
|
}
|
||||||
|
|
||||||
|
case OpDelete:
|
||||||
|
if e.ValueKind != VKNone {
|
||||||
|
return fmt.Errorf("wal: delete requires valueKind None(0), got %d", e.ValueKind)
|
||||||
|
}
|
||||||
|
if valLen != 0 {
|
||||||
|
return fmt.Errorf("wal: delete requires empty value, got length %d", valLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("wal: invalid opType %d", e.OpType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeEntry serializes a WalEntry into a byte slice.
|
||||||
|
func EncodeEntry(e *WalEntry) ([]byte, error) {
|
||||||
|
if err := e.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
keyLen := uint64(len(e.Key))
|
||||||
|
valLen := uint64(len(e.Value))
|
||||||
|
|
||||||
|
// Size: 1 (opType) + 1 (valueKind) + varint(keyLen) + varint(valLen) + key + value
|
||||||
|
size := 2 + MaxWalVarintBytes + MaxWalVarintBytes + len(e.Key) + len(e.Value)
|
||||||
|
buf := make([]byte, size)
|
||||||
|
|
||||||
|
buf[0] = e.OpType
|
||||||
|
buf[1] = e.ValueKind
|
||||||
|
n := 2
|
||||||
|
n += binary.PutUvarint(buf[n:], keyLen)
|
||||||
|
n += binary.PutUvarint(buf[n:], valLen)
|
||||||
|
n += copy(buf[n:], e.Key)
|
||||||
|
n += copy(buf[n:], e.Value)
|
||||||
|
|
||||||
|
return buf[:n], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeEntry deserializes a WalEntry from a byte slice.
|
||||||
|
// Returns the decoded entry and the number of bytes consumed.
|
||||||
|
func DecodeEntry(data []byte) (entry *WalEntry, consumed int, err error) {
|
||||||
|
if len(data) < 2 {
|
||||||
|
return nil, 0, errors.New("wal: data too short for entry header")
|
||||||
|
}
|
||||||
|
|
||||||
|
opType := data[0]
|
||||||
|
valueKind := data[1]
|
||||||
|
r := bytes.NewReader(data[2:])
|
||||||
|
|
||||||
|
keyLen, err := binary.ReadUvarint(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("wal: reading key length: %w", err)
|
||||||
|
}
|
||||||
|
valLen, err := binary.ReadUvarint(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("wal: reading value length: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate consumed so far: 2 header bytes + bytes read from reader
|
||||||
|
consumed = 2 + (len(data) - 2 - r.Len())
|
||||||
|
|
||||||
|
// Read key
|
||||||
|
remaining := len(data) - consumed
|
||||||
|
if uint64(remaining) < keyLen {
|
||||||
|
return nil, 0, fmt.Errorf("wal: data truncated: need %d bytes for key, have %d", keyLen, remaining)
|
||||||
|
}
|
||||||
|
key := make([]byte, keyLen)
|
||||||
|
copy(key, data[consumed:consumed+int(keyLen)])
|
||||||
|
consumed += int(keyLen)
|
||||||
|
|
||||||
|
// Read value
|
||||||
|
remaining = len(data) - consumed
|
||||||
|
if uint64(remaining) < valLen {
|
||||||
|
return nil, 0, fmt.Errorf("wal: data truncated: need %d bytes for value, have %d", valLen, remaining)
|
||||||
|
}
|
||||||
|
value := make([]byte, valLen)
|
||||||
|
copy(value, data[consumed:consumed+int(valLen)])
|
||||||
|
consumed += int(valLen)
|
||||||
|
|
||||||
|
e := &WalEntry{
|
||||||
|
OpType: opType,
|
||||||
|
ValueKind: valueKind,
|
||||||
|
Key: key,
|
||||||
|
Value: value,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := e.Validate(); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return e, consumed, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEntryRoundtrip(t *testing.T) {
|
||||||
|
maxKey := bytes.Repeat([]byte("k"), int(MaxWalKeyBytes))
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
e *WalEntry
|
||||||
|
}{
|
||||||
|
{"put_inline", &WalEntry{OpPut, VKInline, []byte("key1"), []byte("val1")}},
|
||||||
|
{"put_inline_empty_value", &WalEntry{OpPut, VKInline, []byte("key2"), []byte{}}},
|
||||||
|
{"delete", &WalEntry{OpDelete, VKNone, []byte("key3"), nil}},
|
||||||
|
{"put_max_key", &WalEntry{OpPut, VKInline, maxKey, []byte("v")}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
encoded, err := EncodeEntry(tc.e)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, consumed, err := DecodeEntry(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if consumed != len(encoded) {
|
||||||
|
t.Fatalf("consumed %d != encoded len %d", consumed, len(encoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.OpType != tc.e.OpType {
|
||||||
|
t.Errorf("OpType: got %d, want %d", got.OpType, tc.e.OpType)
|
||||||
|
}
|
||||||
|
if got.ValueKind != tc.e.ValueKind {
|
||||||
|
t.Errorf("ValueKind: got %d, want %d", got.ValueKind, tc.e.ValueKind)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got.Key, tc.e.Key) {
|
||||||
|
t.Errorf("Key: got %q, want %q", got.Key, tc.e.Key)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got.Value, tc.e.Value) {
|
||||||
|
t.Errorf("Value: got %q, want %q", got.Value, tc.e.Value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntryValidation(t *testing.T) {
|
||||||
|
bigKey := bytes.Repeat([]byte("k"), int(MaxWalKeyBytes)+1)
|
||||||
|
bigVal := bytes.Repeat([]byte("v"), int(MaxWalInlineValueBytes)+1)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
e *WalEntry
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"op_invalid", &WalEntry{OpInvalid, VKNone, []byte("k"), nil}, true},
|
||||||
|
{"put_vk_none", &WalEntry{OpPut, VKNone, []byte("k"), nil}, true},
|
||||||
|
{"delete_vk_inline", &WalEntry{OpDelete, VKInline, []byte("k"), nil}, true},
|
||||||
|
{"key_empty", &WalEntry{OpPut, VKInline, []byte{}, []byte("v")}, true},
|
||||||
|
{"key_too_big", &WalEntry{OpPut, VKInline, bigKey, []byte("v")}, true},
|
||||||
|
{"put_inline_val_too_big", &WalEntry{OpPut, VKInline, []byte("k"), bigVal}, true},
|
||||||
|
{"put_inline_val_empty", &WalEntry{OpPut, VKInline, []byte("k"), []byte{}}, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := EncodeEntry(tc.e)
|
||||||
|
if (err != nil) != tc.wantErr {
|
||||||
|
t.Errorf("EncodeEntry() error = %v, wantErr %v", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntryDecodeTruncated(t *testing.T) {
|
||||||
|
// Build a valid encoded entry, then truncate mid-varint.
|
||||||
|
e := &WalEntry{OpPut, VKInline, []byte("key1"), []byte("value1")}
|
||||||
|
full, err := EncodeEntry(e)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate to just 1 byte — not enough for header.
|
||||||
|
_, _, err = DecodeEntry(full[:1])
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for 1-byte data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build data with an incomplete varint: opType + valueKind + start of varint (0xFF means more bytes follow).
|
||||||
|
trunc := []byte{OpPut, VKInline, 0xFF}
|
||||||
|
_, _, err = DecodeEntry(trunc)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for truncated varint")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also test: varint specifies more bytes than available.
|
||||||
|
// Encode a large key length varint but don't provide the key bytes.
|
||||||
|
varintBuf := make([]byte, binary.MaxVarintLen64)
|
||||||
|
n := binary.PutUvarint(varintBuf, 1000) // keyLen = 1000
|
||||||
|
data := []byte{OpPut, VKInline}
|
||||||
|
data = append(data, varintBuf[:n]...)
|
||||||
|
data = append(data, varintBuf[:n]...) // valLen varint (also 1000)
|
||||||
|
// Don't append any key/value bytes.
|
||||||
|
_, _, err = DecodeEntry(data)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing key/value bytes")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"hash/crc32"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
walFileHeaderSize = 32
|
||||||
|
walMagic = 0x57414C4B // "WALK" in ASCII
|
||||||
|
walFormatVersion = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// WalFileHeader is the 32-byte header written at the start of every WAL segment file.
|
||||||
|
type WalFileHeader struct {
|
||||||
|
Magic uint32
|
||||||
|
FormatVersion uint16
|
||||||
|
HeaderSize uint16
|
||||||
|
BlockSize uint32
|
||||||
|
SegmentID uint64
|
||||||
|
StartSequence uint64
|
||||||
|
HeaderCRC uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeWalHeader serializes h into a fixed-size 32-byte array using little-endian byte order.
|
||||||
|
// The HeaderCRC field is computed over bytes 0–27 (everything except the CRC itself).
|
||||||
|
func EncodeWalHeader(h *WalFileHeader) [walFileHeaderSize]byte {
|
||||||
|
h.HeaderSize = walFileHeaderSize
|
||||||
|
h.Magic = walMagic
|
||||||
|
h.FormatVersion = walFormatVersion
|
||||||
|
|
||||||
|
var buf [walFileHeaderSize]byte
|
||||||
|
le := binary.LittleEndian
|
||||||
|
|
||||||
|
le.PutUint32(buf[0:4], h.Magic)
|
||||||
|
le.PutUint16(buf[4:6], h.FormatVersion)
|
||||||
|
le.PutUint16(buf[6:8], h.HeaderSize)
|
||||||
|
le.PutUint32(buf[8:12], h.BlockSize)
|
||||||
|
le.PutUint64(buf[12:20], h.SegmentID)
|
||||||
|
le.PutUint64(buf[20:28], h.StartSequence)
|
||||||
|
|
||||||
|
// CRC32C over bytes 0–27 (per design §3.2 line 341, 359)
|
||||||
|
h.HeaderCRC = crc32.Checksum(buf[0:28], crc32cTable)
|
||||||
|
le.PutUint32(buf[28:32], h.HeaderCRC)
|
||||||
|
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
errBadMagic = errors.New("wal: bad magic number")
|
||||||
|
errBadVersion = errors.New("wal: unsupported format version")
|
||||||
|
errBadHeaderSize = errors.New("wal: bad header size")
|
||||||
|
errCRCMismatch = errors.New("wal: header CRC mismatch")
|
||||||
|
errHeaderTooShort = errors.New("wal: header data too short")
|
||||||
|
)
|
||||||
|
|
||||||
|
// DecodeWalHeader parses a 32-byte little-endian header and validates magic,
|
||||||
|
// format version, header size, and CRC.
|
||||||
|
func DecodeWalHeader(data []byte) (*WalFileHeader, error) {
|
||||||
|
if len(data) < walFileHeaderSize {
|
||||||
|
return nil, errHeaderTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
le := binary.LittleEndian
|
||||||
|
|
||||||
|
magic := le.Uint32(data[0:4])
|
||||||
|
if magic != walMagic {
|
||||||
|
return nil, errBadMagic
|
||||||
|
}
|
||||||
|
|
||||||
|
version := le.Uint16(data[4:6])
|
||||||
|
if version != walFormatVersion {
|
||||||
|
return nil, errBadVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
hdrSize := le.Uint16(data[6:8])
|
||||||
|
if hdrSize != walFileHeaderSize {
|
||||||
|
return nil, errBadHeaderSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify CRC before trusting any other fields
|
||||||
|
gotCRC := crc32.Checksum(data[0:28], crc32cTable)
|
||||||
|
storedCRC := le.Uint32(data[28:32])
|
||||||
|
if gotCRC != storedCRC {
|
||||||
|
return nil, errCRCMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
return &WalFileHeader{
|
||||||
|
Magic: magic,
|
||||||
|
FormatVersion: version,
|
||||||
|
HeaderSize: hdrSize,
|
||||||
|
BlockSize: le.Uint32(data[8:12]),
|
||||||
|
SegmentID: le.Uint64(data[12:20]),
|
||||||
|
StartSequence: le.Uint64(data[20:28]),
|
||||||
|
HeaderCRC: storedCRC,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHeaderRoundtrip(t *testing.T) {
|
||||||
|
orig := &WalFileHeader{
|
||||||
|
BlockSize: 32 * 1024, // 32 KB
|
||||||
|
SegmentID: 5,
|
||||||
|
StartSequence: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded := EncodeWalHeader(orig)
|
||||||
|
decoded, err := DecodeWalHeader(encoded[:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeWalHeader returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded.Magic != walMagic {
|
||||||
|
t.Errorf("Magic = %x, want %x", decoded.Magic, walMagic)
|
||||||
|
}
|
||||||
|
if decoded.FormatVersion != walFormatVersion {
|
||||||
|
t.Errorf("FormatVersion = %d, want %d", decoded.FormatVersion, walFormatVersion)
|
||||||
|
}
|
||||||
|
if decoded.HeaderSize != walFileHeaderSize {
|
||||||
|
t.Errorf("HeaderSize = %d, want %d", decoded.HeaderSize, walFileHeaderSize)
|
||||||
|
}
|
||||||
|
if decoded.BlockSize != orig.BlockSize {
|
||||||
|
t.Errorf("BlockSize = %d, want %d", decoded.BlockSize, orig.BlockSize)
|
||||||
|
}
|
||||||
|
if decoded.SegmentID != orig.SegmentID {
|
||||||
|
t.Errorf("SegmentID = %d, want %d", decoded.SegmentID, orig.SegmentID)
|
||||||
|
}
|
||||||
|
if decoded.StartSequence != orig.StartSequence {
|
||||||
|
t.Errorf("StartSequence = %d, want %d", decoded.StartSequence, orig.StartSequence)
|
||||||
|
}
|
||||||
|
if decoded.HeaderCRC != orig.HeaderCRC {
|
||||||
|
t.Errorf("HeaderCRC = %x, want %x", decoded.HeaderCRC, orig.HeaderCRC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeaderCRC(t *testing.T) {
|
||||||
|
h := &WalFileHeader{
|
||||||
|
BlockSize: 32 * 1024,
|
||||||
|
SegmentID: 1,
|
||||||
|
StartSequence: 0,
|
||||||
|
}
|
||||||
|
encoded := EncodeWalHeader(h)
|
||||||
|
|
||||||
|
// Flip a byte in the magic field (bytes 0-3)
|
||||||
|
encoded[0] ^= 0xFF
|
||||||
|
|
||||||
|
_, err := DecodeWalHeader(encoded[:])
|
||||||
|
if !errors.Is(err, errCRCMismatch) && !errors.Is(err, errBadMagic) {
|
||||||
|
// Flipping magic may fail on magic check first or CRC check
|
||||||
|
// Either way, decoding must fail
|
||||||
|
t.Fatalf("expected CRC or magic error, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore magic and flip a byte in the payload instead
|
||||||
|
encoded[0] = byte(walMagic & 0xFF)
|
||||||
|
encoded[12] ^= 0x01 // flip byte in SegmentID
|
||||||
|
|
||||||
|
_, err = DecodeWalHeader(encoded[:])
|
||||||
|
if !errors.Is(err, errCRCMismatch) {
|
||||||
|
t.Fatalf("expected errCRCMismatch, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeaderBadMagic(t *testing.T) {
|
||||||
|
data := make([]byte, 32)
|
||||||
|
// All zeros — magic won't match
|
||||||
|
_, err := DecodeWalHeader(data)
|
||||||
|
if !errors.Is(err, errBadMagic) {
|
||||||
|
t.Fatalf("expected errBadMagic, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeaderShortData(t *testing.T) {
|
||||||
|
data := make([]byte, 16) // too short
|
||||||
|
_, err := DecodeWalHeader(data)
|
||||||
|
if !errors.Is(err, errHeaderTooShort) {
|
||||||
|
t.Fatalf("expected errHeaderTooShort, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"hash/crc32"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PhysicalRecord represents a single physical record in the WAL.
|
||||||
|
type PhysicalRecord struct {
|
||||||
|
CRC uint32
|
||||||
|
Length uint16
|
||||||
|
Type uint8
|
||||||
|
Payload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodePhysicalRecord encodes a physical record with the given type and payload.
|
||||||
|
// Format: [crc32 u32 LE][length u16 LE][type u8][payload bytes]
|
||||||
|
// CRC covers length + type + payload.
|
||||||
|
func EncodePhysicalRecord(recType uint8, payload []byte) []byte {
|
||||||
|
length := uint16(len(payload))
|
||||||
|
buf := make([]byte, PhysicalRecordHeaderSize+len(payload))
|
||||||
|
|
||||||
|
// Write length and type first so we can compute CRC.
|
||||||
|
binary.LittleEndian.PutUint16(buf[4:6], length)
|
||||||
|
buf[6] = recType
|
||||||
|
copy(buf[7:], payload)
|
||||||
|
|
||||||
|
// CRC covers bytes [4:] = length + type + payload. Castagnoli polynomial
|
||||||
|
// per design §3.2 line 359.
|
||||||
|
crc := crc32.Checksum(buf[4:], crc32cTable)
|
||||||
|
binary.LittleEndian.PutUint32(buf[0:4], crc)
|
||||||
|
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodePhysicalRecord decodes a physical record from data.
|
||||||
|
// Returns the record, number of bytes consumed, and any error.
|
||||||
|
func DecodePhysicalRecord(data []byte) (rec *PhysicalRecord, consumed int, err error) {
|
||||||
|
if len(data) < PhysicalRecordHeaderSize {
|
||||||
|
return nil, 0, errors.New("record: data too short for header")
|
||||||
|
}
|
||||||
|
|
||||||
|
crc := binary.LittleEndian.Uint32(data[0:4])
|
||||||
|
length := binary.LittleEndian.Uint16(data[4:6])
|
||||||
|
recType := data[6]
|
||||||
|
|
||||||
|
if int(length) > len(data)-PhysicalRecordHeaderSize {
|
||||||
|
return nil, 0, errors.New("record: data too short for payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := make([]byte, length)
|
||||||
|
copy(payload, data[7:7+length])
|
||||||
|
|
||||||
|
// Verify CRC: covers length + type + payload.
|
||||||
|
expectedCRC := crc32.Checksum(data[4 : 7+length], crc32cTable)
|
||||||
|
if crc != expectedCRC {
|
||||||
|
return nil, 0, errors.New("record: CRC mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
consumed = PhysicalRecordHeaderSize + int(length)
|
||||||
|
return &PhysicalRecord{
|
||||||
|
CRC: crc,
|
||||||
|
Length: length,
|
||||||
|
Type: recType,
|
||||||
|
Payload: payload,
|
||||||
|
}, consumed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaddingNeeded returns the number of padding bytes needed at blockOffset.
|
||||||
|
// If the remaining space in the current block is <= PhysicalRecordHeaderSize (7),
|
||||||
|
// that remaining space must be zero-padded.
|
||||||
|
func PaddingNeeded(blockOffset uint32) int {
|
||||||
|
remaining := WalBlockSize - (blockOffset % WalBlockSize)
|
||||||
|
if remaining <= PhysicalRecordHeaderSize {
|
||||||
|
return int(remaining)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanFitRecord reports whether a physical record with the given payload length
|
||||||
|
// can fit in the current block starting at blockOffset.
|
||||||
|
func CanFitRecord(blockOffset uint32, payloadLen uint32) bool {
|
||||||
|
remaining := WalBlockSize - (blockOffset % WalBlockSize)
|
||||||
|
return int(remaining) >= PhysicalRecordHeaderSize+int(payloadLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitIntoRecords splits an encoded WAL batch into physical record payloads
|
||||||
|
// respecting 32 KB block boundaries.
|
||||||
|
// Each returned byte slice is the full encoded physical record (header + payload).
|
||||||
|
func SplitIntoRecords(encodedBatch []byte) [][]byte {
|
||||||
|
maxPayload := WalBlockSize - PhysicalRecordHeaderSize
|
||||||
|
total := len(encodedBatch)
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single record fits entirely.
|
||||||
|
if total <= maxPayload {
|
||||||
|
return [][]byte{EncodePhysicalRecord(RecFull, encodedBatch)}
|
||||||
|
}
|
||||||
|
|
||||||
|
var records [][]byte
|
||||||
|
offset := 0
|
||||||
|
|
||||||
|
for offset < total {
|
||||||
|
chunkLen := min(total-offset, maxPayload)
|
||||||
|
|
||||||
|
var recType uint8
|
||||||
|
switch {
|
||||||
|
case offset == 0 && offset+chunkLen == total:
|
||||||
|
recType = RecFull
|
||||||
|
case offset == 0:
|
||||||
|
recType = RecFirst
|
||||||
|
case offset+chunkLen == total:
|
||||||
|
recType = RecLast
|
||||||
|
default:
|
||||||
|
recType = RecMiddle
|
||||||
|
}
|
||||||
|
|
||||||
|
records = append(records, EncodePhysicalRecord(recType, encodedBatch[offset:offset+chunkLen]))
|
||||||
|
offset += chunkLen
|
||||||
|
}
|
||||||
|
|
||||||
|
return records
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TailCorruptionError indicates that the WAL tail contains corrupt data
|
||||||
|
// (bad CRC, unexpected non-zero padding bytes, etc.). Recovery may safely
|
||||||
|
// truncate at the last valid record.
|
||||||
|
//
|
||||||
|
// SegmentPath is set by ReplaySegmentFile when it propagates the error,
|
||||||
|
// so Recover can use it as the authoritative truncation target.
|
||||||
|
type TailCorruptionError struct {
|
||||||
|
Offset int
|
||||||
|
SegmentPath string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *TailCorruptionError) Error() string {
|
||||||
|
if e.SegmentPath != "" {
|
||||||
|
return fmt.Sprintf("wal: tail corruption in %s at offset %d: %v", e.SegmentPath, e.Offset, e.Err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("wal: tail corruption at offset %d: %v", e.Offset, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *TailCorruptionError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTailCorruption reports whether err indicates tail corruption in a WAL
|
||||||
|
// segment. Callers may safely truncate the segment at the last valid record.
|
||||||
|
func IsTailCorruption(err error) bool {
|
||||||
|
var tce *TailCorruptionError
|
||||||
|
return errors.As(err, &tce)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseBlock parses physical records from a single block of raw bytes.
|
||||||
|
// The block is typically WalBlockSize (32 KB) bytes, but the last block of a
|
||||||
|
// segment may be shorter. Trailing bytes after the last record must be all
|
||||||
|
// zeros (padding); non-zero trailing bytes produce a TailCorruptionError.
|
||||||
|
func ParseBlock(data []byte) ([]*PhysicalRecord, error) {
|
||||||
|
var records []*PhysicalRecord
|
||||||
|
pos := 0
|
||||||
|
|
||||||
|
for pos < len(data) {
|
||||||
|
remaining := len(data) - pos
|
||||||
|
|
||||||
|
// If fewer than PhysicalRecordHeaderSize bytes remain, they must be
|
||||||
|
// zero-padding.
|
||||||
|
if remaining < PhysicalRecordHeaderSize {
|
||||||
|
tail := data[pos:]
|
||||||
|
if !isAllZeros(tail) {
|
||||||
|
return records[:len(records):len(records)], &TailCorruptionError{
|
||||||
|
Offset: pos,
|
||||||
|
Err: fmt.Errorf("non-zero padding bytes in tail (%d bytes)", len(tail)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for zero-filled header (preallocated / unwritten space).
|
||||||
|
if isAllZeros(data[pos : pos+PhysicalRecordHeaderSize]) {
|
||||||
|
// Verify rest of block is also zeros.
|
||||||
|
if !isAllZeros(data[pos:]) {
|
||||||
|
return records[:len(records):len(records)], &TailCorruptionError{
|
||||||
|
Offset: pos,
|
||||||
|
Err: errors.New("zero header but non-zero bytes follow"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, consumed, err := DecodePhysicalRecord(data[pos:])
|
||||||
|
if err != nil {
|
||||||
|
return records[:len(records):len(records)], &TailCorruptionError{
|
||||||
|
Offset: pos,
|
||||||
|
Err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
records = append(records, rec)
|
||||||
|
pos += consumed
|
||||||
|
}
|
||||||
|
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRecordsFromFile opens a WAL segment file, skips the file header, reads
|
||||||
|
// blocks sequentially, and returns all physical records in order. Short final
|
||||||
|
// blocks are handled correctly.
|
||||||
|
func ParseRecordsFromFile(filePath string) ([]*PhysicalRecord, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("wal: parse records: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Skip file header.
|
||||||
|
if _, err := f.Seek(WalFileHeaderSize, 0); err != nil {
|
||||||
|
return nil, fmt.Errorf("wal: seek past header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var allRecords []*PhysicalRecord
|
||||||
|
buf := make([]byte, WalBlockSize)
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, readErr := f.Read(buf)
|
||||||
|
if readErr != nil {
|
||||||
|
if errors.Is(readErr, os.ErrClosed) {
|
||||||
|
return nil, fmt.Errorf("wal: file closed during read: %w", readErr)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
blockData := buf[:n]
|
||||||
|
recs, err := ParseBlock(blockData)
|
||||||
|
if err != nil {
|
||||||
|
// Return records collected so far along with the error.
|
||||||
|
return allRecords, err
|
||||||
|
}
|
||||||
|
allRecords = append(allRecords, recs...)
|
||||||
|
|
||||||
|
// If we got a short block, this was the last one.
|
||||||
|
if n < WalBlockSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allRecords, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAllZeros(data []byte) bool {
|
||||||
|
return bytes.Count(data, []byte{0}) == len(data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseBlockSingleRecord(t *testing.T) {
|
||||||
|
payload := []byte("hello world")
|
||||||
|
encoded := EncodePhysicalRecord(RecFull, payload)
|
||||||
|
|
||||||
|
block := make([]byte, WalBlockSize)
|
||||||
|
copy(block, encoded)
|
||||||
|
|
||||||
|
recs, err := ParseBlock(block)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseBlock: %v", err)
|
||||||
|
}
|
||||||
|
if len(recs) != 1 {
|
||||||
|
t.Fatalf("expected 1 record, got %d", len(recs))
|
||||||
|
}
|
||||||
|
if recs[0].Type != RecFull {
|
||||||
|
t.Errorf("Type = %d, want RecFull(%d)", recs[0].Type, RecFull)
|
||||||
|
}
|
||||||
|
if string(recs[0].Payload) != string(payload) {
|
||||||
|
t.Errorf("Payload = %q, want %q", recs[0].Payload, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBlockMultipleRecords(t *testing.T) {
|
||||||
|
payloads := [][]byte{
|
||||||
|
[]byte("first"),
|
||||||
|
[]byte("second"),
|
||||||
|
[]byte("third"),
|
||||||
|
}
|
||||||
|
|
||||||
|
block := make([]byte, WalBlockSize)
|
||||||
|
offset := 0
|
||||||
|
for i, p := range payloads {
|
||||||
|
rec := EncodePhysicalRecord(RecFull, p)
|
||||||
|
copy(block[offset:], rec)
|
||||||
|
offset += len(rec)
|
||||||
|
if offset > WalBlockSize {
|
||||||
|
t.Fatalf("record %d overflows block", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recs, err := ParseBlock(block)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseBlock: %v", err)
|
||||||
|
}
|
||||||
|
if len(recs) != 3 {
|
||||||
|
t.Fatalf("expected 3 records, got %d", len(recs))
|
||||||
|
}
|
||||||
|
for i, want := range payloads {
|
||||||
|
if string(recs[i].Payload) != string(want) {
|
||||||
|
t.Errorf("rec[%d].Payload = %q, want %q", i, recs[i].Payload, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBlockWithPadding(t *testing.T) {
|
||||||
|
payload := []byte("data")
|
||||||
|
encoded := EncodePhysicalRecord(RecFull, payload)
|
||||||
|
|
||||||
|
// Place record at offset 0, then fill rest with zeros.
|
||||||
|
block := make([]byte, WalBlockSize)
|
||||||
|
copy(block, encoded)
|
||||||
|
|
||||||
|
recs, err := ParseBlock(block)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseBlock: %v", err)
|
||||||
|
}
|
||||||
|
if len(recs) != 1 {
|
||||||
|
t.Fatalf("expected 1 record, got %d", len(recs))
|
||||||
|
}
|
||||||
|
if string(recs[0].Payload) != string(payload) {
|
||||||
|
t.Errorf("Payload = %q, want %q", recs[0].Payload, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBlockCorruptPadding(t *testing.T) {
|
||||||
|
payload := []byte("data")
|
||||||
|
encoded := EncodePhysicalRecord(RecFull, payload)
|
||||||
|
|
||||||
|
block := make([]byte, WalBlockSize)
|
||||||
|
copy(block, encoded)
|
||||||
|
// Write non-zero byte in the trailing padding area.
|
||||||
|
block[len(encoded)+3] = 0xFF
|
||||||
|
|
||||||
|
recs, err := ParseBlock(block)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected tail corruption error")
|
||||||
|
}
|
||||||
|
if !IsTailCorruption(err) {
|
||||||
|
t.Errorf("expected TailCorruptionError, got: %v", err)
|
||||||
|
}
|
||||||
|
// Should still return records parsed before the corruption.
|
||||||
|
if len(recs) != 1 {
|
||||||
|
t.Errorf("expected 1 record before corruption, got %d", len(recs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBlockBadCRC(t *testing.T) {
|
||||||
|
payload := []byte("data")
|
||||||
|
encoded := EncodePhysicalRecord(RecFull, payload)
|
||||||
|
|
||||||
|
block := make([]byte, WalBlockSize)
|
||||||
|
copy(block, encoded)
|
||||||
|
// Corrupt a payload byte.
|
||||||
|
block[PhysicalRecordHeaderSize+1] ^= 0xFF
|
||||||
|
|
||||||
|
_, err := ParseBlock(block)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected CRC mismatch error")
|
||||||
|
}
|
||||||
|
if !IsTailCorruption(err) {
|
||||||
|
t.Errorf("expected TailCorruptionError, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBlockShortBlock(t *testing.T) {
|
||||||
|
payload := []byte("short block data")
|
||||||
|
encoded := EncodePhysicalRecord(RecFull, payload)
|
||||||
|
|
||||||
|
// Simulate a short last block (less than WalBlockSize).
|
||||||
|
block := make([]byte, len(encoded)+10) // extra trailing zeros
|
||||||
|
copy(block, encoded)
|
||||||
|
|
||||||
|
recs, err := ParseBlock(block)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseBlock short block: %v", err)
|
||||||
|
}
|
||||||
|
if len(recs) != 1 {
|
||||||
|
t.Fatalf("expected 1 record, got %d", len(recs))
|
||||||
|
}
|
||||||
|
if string(recs[0].Payload) != string(payload) {
|
||||||
|
t.Errorf("Payload = %q, want %q", recs[0].Payload, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBlockEmpty(t *testing.T) {
|
||||||
|
block := make([]byte, 64) // all zeros
|
||||||
|
|
||||||
|
recs, err := ParseBlock(block)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseBlock empty: %v", err)
|
||||||
|
}
|
||||||
|
if len(recs) != 0 {
|
||||||
|
t.Fatalf("expected 0 records, got %d", len(recs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBlockZeroHeaderWithNonZeroTail(t *testing.T) {
|
||||||
|
block := make([]byte, WalBlockSize)
|
||||||
|
// First 7 bytes are zero (valid zero header), but byte at offset 8 is non-zero.
|
||||||
|
block[8] = 0x42
|
||||||
|
|
||||||
|
_, err := ParseBlock(block)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected tail corruption error for zero header + non-zero tail")
|
||||||
|
}
|
||||||
|
if !IsTailCorruption(err) {
|
||||||
|
t.Errorf("expected TailCorruptionError, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsTailCorruptionFalse(t *testing.T) {
|
||||||
|
if IsTailCorruption(nil) {
|
||||||
|
t.Error("IsTailCorruption(nil) = true, want false")
|
||||||
|
}
|
||||||
|
if IsTailCorruption(os.ErrNotExist) {
|
||||||
|
t.Error("IsTailCorruption(ErrNotExist) = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRecordsFromFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "segment-0.wal")
|
||||||
|
|
||||||
|
// Create a segment file with header + one full block containing 2 records.
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdr := &WalFileHeader{
|
||||||
|
SegmentID: 0,
|
||||||
|
StartSequence: 0,
|
||||||
|
BlockSize: WalBlockSize,
|
||||||
|
}
|
||||||
|
encoded := EncodeWalHeader(hdr)
|
||||||
|
if _, err := f.Write(encoded[:]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
block := make([]byte, WalBlockSize)
|
||||||
|
payload1 := []byte("record-one")
|
||||||
|
payload2 := []byte("record-two")
|
||||||
|
offset := 0
|
||||||
|
rec1 := EncodePhysicalRecord(RecFull, payload1)
|
||||||
|
copy(block[offset:], rec1)
|
||||||
|
offset += len(rec1)
|
||||||
|
rec2 := EncodePhysicalRecord(RecFull, payload2)
|
||||||
|
copy(block[offset:], rec2)
|
||||||
|
|
||||||
|
if _, err := f.Write(block); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recs, err := ParseRecordsFromFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseRecordsFromFile: %v", err)
|
||||||
|
}
|
||||||
|
if len(recs) != 2 {
|
||||||
|
t.Fatalf("expected 2 records, got %d", len(recs))
|
||||||
|
}
|
||||||
|
if string(recs[0].Payload) != string(payload1) {
|
||||||
|
t.Errorf("rec[0].Payload = %q, want %q", recs[0].Payload, payload1)
|
||||||
|
}
|
||||||
|
if string(recs[1].Payload) != string(payload2) {
|
||||||
|
t.Errorf("rec[1].Payload = %q, want %q", recs[1].Payload, payload2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRecordsFromFileShortLastBlock(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "segment-0.wal")
|
||||||
|
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdr := &WalFileHeader{
|
||||||
|
SegmentID: 0,
|
||||||
|
StartSequence: 0,
|
||||||
|
BlockSize: WalBlockSize,
|
||||||
|
}
|
||||||
|
encoded := EncodeWalHeader(hdr)
|
||||||
|
if _, err := f.Write(encoded[:]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write a partial block (just one record, no full 32KB).
|
||||||
|
payload := []byte("short")
|
||||||
|
rec := EncodePhysicalRecord(RecFull, payload)
|
||||||
|
if _, err := f.Write(rec); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recs, err := ParseRecordsFromFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseRecordsFromFile: %v", err)
|
||||||
|
}
|
||||||
|
if len(recs) != 1 {
|
||||||
|
t.Fatalf("expected 1 record, got %d", len(recs))
|
||||||
|
}
|
||||||
|
if string(recs[0].Payload) != string(payload) {
|
||||||
|
t.Errorf("Payload = %q, want %q", recs[0].Payload, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecordRoundtrip(t *testing.T) {
|
||||||
|
payload := []byte("hello world")
|
||||||
|
encoded := EncodePhysicalRecord(RecFull, payload)
|
||||||
|
|
||||||
|
rec, consumed, err := DecodePhysicalRecord(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodePhysicalRecord failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.Type != RecFull {
|
||||||
|
t.Errorf("expected type RecFull(%d), got %d", RecFull, rec.Type)
|
||||||
|
}
|
||||||
|
if string(rec.Payload) != "hello world" {
|
||||||
|
t.Errorf("expected payload 'hello world', got %q", string(rec.Payload))
|
||||||
|
}
|
||||||
|
if consumed != 7+len(payload) {
|
||||||
|
t.Errorf("expected consumed %d, got %d", 7+len(payload), consumed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitSmallPayload(t *testing.T) {
|
||||||
|
payload := make([]byte, 100)
|
||||||
|
for i := range payload {
|
||||||
|
payload[i] = byte(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
records := SplitIntoRecords(payload)
|
||||||
|
if len(records) != 1 {
|
||||||
|
t.Fatalf("expected 1 record, got %d", len(records))
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, _, err := DecodePhysicalRecord(records[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodePhysicalRecord failed: %v", err)
|
||||||
|
}
|
||||||
|
if rec.Type != RecFull {
|
||||||
|
t.Errorf("expected RecFull, got %d", rec.Type)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(rec.Payload, payload) {
|
||||||
|
t.Error("payload mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitIntoRecords(t *testing.T) {
|
||||||
|
// 40 KB payload → needs to split across blocks.
|
||||||
|
payload := make([]byte, 40*1024)
|
||||||
|
for i := range payload {
|
||||||
|
payload[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
records := SplitIntoRecords(payload)
|
||||||
|
if len(records) < 2 {
|
||||||
|
t.Fatalf("expected at least 2 records, got %d", len(records))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify fragment sequence.
|
||||||
|
types := make([]uint8, len(records))
|
||||||
|
var concatenated []byte
|
||||||
|
for i, enc := range records {
|
||||||
|
rec, _, err := DecodePhysicalRecord(enc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodePhysicalRecord record %d failed: %v", i, err)
|
||||||
|
}
|
||||||
|
types[i] = rec.Type
|
||||||
|
concatenated = append(concatenated, rec.Payload...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First record must be RecFirst.
|
||||||
|
if types[0] != RecFirst {
|
||||||
|
t.Errorf("first record type: expected RecFirst(%d), got %d", RecFirst, types[0])
|
||||||
|
}
|
||||||
|
// Last record must be RecLast.
|
||||||
|
if types[len(types)-1] != RecLast {
|
||||||
|
t.Errorf("last record type: expected RecLast(%d), got %d", RecLast, types[len(types)-1])
|
||||||
|
}
|
||||||
|
// Middle records must be RecMiddle.
|
||||||
|
for i := 1; i < len(types)-1; i++ {
|
||||||
|
if types[i] != RecMiddle {
|
||||||
|
t.Errorf("record %d type: expected RecMiddle(%d), got %d", i, RecMiddle, types[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concatenated payloads must equal original.
|
||||||
|
if !bytes.Equal(concatenated, payload) {
|
||||||
|
t.Error("concatenated payloads do not match original")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlockPadding(t *testing.T) {
|
||||||
|
// blockOffset = WalBlockSize - 5 → remaining = 5, which is <= 7 → padding needed = 5.
|
||||||
|
blockOffset := uint32(WalBlockSize - 5)
|
||||||
|
padding := PaddingNeeded(blockOffset)
|
||||||
|
if padding != 5 {
|
||||||
|
t.Errorf("PaddingNeeded(%d): expected 5, got %d", blockOffset, padding)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot fit a record.
|
||||||
|
if CanFitRecord(blockOffset, 1) {
|
||||||
|
t.Error("CanFitRecord should return false when remaining <= 7")
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockOffset = WalBlockSize - 8 → remaining = 8, which is > 7 → no padding needed.
|
||||||
|
blockOffset2 := uint32(WalBlockSize - 8)
|
||||||
|
padding2 := PaddingNeeded(blockOffset2)
|
||||||
|
if padding2 != 0 {
|
||||||
|
t.Errorf("PaddingNeeded(%d): expected 0, got %d", blockOffset2, padding2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can fit a 1-byte payload: remaining=8, header=7, payload=1 → 8 >= 8.
|
||||||
|
if !CanFitRecord(blockOffset2, 1) {
|
||||||
|
t.Error("CanFitRecord should return true when remaining=8 and payloadLen=1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot fit a 2-byte payload: remaining=8, header=7, payload=2 → 8 < 9.
|
||||||
|
if CanFitRecord(blockOffset2, 2) {
|
||||||
|
t.Error("CanFitRecord should return false when remaining=8 and payloadLen=2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCRCMismatch(t *testing.T) {
|
||||||
|
encoded := EncodePhysicalRecord(RecFull, []byte("test"))
|
||||||
|
// Corrupt a payload byte.
|
||||||
|
encoded[8] ^= 0xFF
|
||||||
|
|
||||||
|
_, _, err := DecodePhysicalRecord(encoded)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected CRC mismatch error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDataTooShort(t *testing.T) {
|
||||||
|
_, _, err := DecodePhysicalRecord([]byte{1, 2, 3})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for data too short")
|
||||||
|
}
|
||||||
|
}
|
||||||
+261
@@ -0,0 +1,261 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/dailz/go-kv/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RecoveryResult holds the outcome of a WAL recovery pass.
|
||||||
|
type RecoveryResult struct {
|
||||||
|
NextSequence uint64
|
||||||
|
NextSegmentID uint64
|
||||||
|
ReplayedEntries int
|
||||||
|
Truncated bool
|
||||||
|
// TruncateError is informational only: non-nil means "tail corruption
|
||||||
|
// was found and repair was attempted". It does NOT report persistence
|
||||||
|
// failures — those cause Recover to return an error instead.
|
||||||
|
TruncateError error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recover performs a full WAL recovery: reads the recovery checkpoint from
|
||||||
|
// MANIFEST, scans segments, replays entries, and persists tail truncation
|
||||||
|
// per design §3.2 line 787-800.
|
||||||
|
func Recover(dir string, replayer BatchReplayer) (*RecoveryResult, error) {
|
||||||
|
if replayer == nil {
|
||||||
|
return nil, fmt.Errorf("wal: recover: replayer is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Determine recovery segment ID from MANIFEST.
|
||||||
|
recoverySegmentID, err := resolveRecoverySegmentID(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("wal: recover: resolve segment id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Scan and replay segments.
|
||||||
|
nextSequence, err := RecoverFromSegments(dir, recoverySegmentID, replayer)
|
||||||
|
if err != nil {
|
||||||
|
if !IsTailCorruption(err) {
|
||||||
|
return nil, fmt.Errorf("wal: recover: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Tail corruption — truncate the last segment per design
|
||||||
|
// §3.2 line 787-800.
|
||||||
|
result := &RecoveryResult{
|
||||||
|
NextSequence: nextSequence,
|
||||||
|
Truncated: true,
|
||||||
|
TruncateError: err, // informational: tail corruption was detected
|
||||||
|
}
|
||||||
|
|
||||||
|
segments, scanErr := ScanSegments(dir, recoverySegmentID)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, fmt.Errorf("wal: recover: scan after tail corruption: %w", scanErr)
|
||||||
|
}
|
||||||
|
if len(segments) > 0 {
|
||||||
|
result.NextSegmentID = segments[len(segments)-1].SegmentID + 1
|
||||||
|
} else {
|
||||||
|
result.NextSegmentID = recoverySegmentID
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(segments) > 0 {
|
||||||
|
// After C4 fix, TailCorruptionError is only returned for the last
|
||||||
|
// segment. Use tce.SegmentPath as authoritative truncation target
|
||||||
|
// (defensive: fall back to segments[last] if missing).
|
||||||
|
corruptedPath := segments[len(segments)-1].FilePath
|
||||||
|
var tce *TailCorruptionError
|
||||||
|
if errors.As(err, &tce) && tce.SegmentPath != "" {
|
||||||
|
corruptedPath = tce.SegmentPath
|
||||||
|
}
|
||||||
|
|
||||||
|
lastCompleteBatchEnd, findErr := findLastCompleteBatchEnd(corruptedPath)
|
||||||
|
if findErr != nil {
|
||||||
|
return nil, fmt.Errorf("wal: recover: find truncation offset: %w", findErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: truncated segment is always segments[last], no
|
||||||
|
// trailing empty segments to clean up. C4 fix means non-last
|
||||||
|
// segment corruption hard-fails above, so we never reach here
|
||||||
|
// with a non-last corrupted segment.
|
||||||
|
var emptyTrailing []string
|
||||||
|
|
||||||
|
if err := truncateAndPersist(corruptedPath, lastCompleteBatchEnd, dir, emptyTrailing); err != nil {
|
||||||
|
// Per design §3.2 line 799: DB must NOT enter writable state.
|
||||||
|
return nil, fmt.Errorf("wal: recover: persist tail truncation: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.ReplayedEntries = 0 // Phase 1: replayer interface doesn't expose count
|
||||||
|
|
||||||
|
// Per design §3.2 line 280, recovery repair must NOT update MANIFEST.
|
||||||
|
// The truncated WAL state is persisted via ftruncate + fsync segment
|
||||||
|
// + fsync dir (see truncateAndPersist). MANIFEST can only advance via
|
||||||
|
// checkpoint (MemTable flush) in future phases.
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Successful recovery — compute result.
|
||||||
|
segments, scanErr := ScanSegments(dir, recoverySegmentID)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, fmt.Errorf("wal: recover: scan after replay: %w", scanErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &RecoveryResult{
|
||||||
|
NextSequence: nextSequence,
|
||||||
|
NextSegmentID: recoverySegmentID,
|
||||||
|
Truncated: false,
|
||||||
|
}
|
||||||
|
if len(segments) > 0 {
|
||||||
|
result.NextSegmentID = segments[len(segments)-1].SegmentID + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per design §3.2 line 280, recovery must NOT update MANIFEST.
|
||||||
|
// RecoveryResult.NextSegmentID is in-memory only, consumed by DB.Open to
|
||||||
|
// seed the new WalWriter. MANIFEST stays at its pre-recovery value.
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRecoverySegmentID returns the recovery start segment ID from MANIFEST.
|
||||||
|
// MANIFEST is the only authoritative source of recovery start per design §3.2
|
||||||
|
// line 600-06. CURRENT is a write-side hint and must NOT be used here.
|
||||||
|
func resolveRecoverySegmentID(dir string) (uint64, error) {
|
||||||
|
mf, err := manifest.Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("load manifest: %w", err)
|
||||||
|
}
|
||||||
|
return mf.RecoverySegmentID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// segmentFsyncFn is the package-level indirection for fsyncing a truncated
|
||||||
|
// segment file. Tests that override this must not use t.Parallel().
|
||||||
|
// Same pattern as dirFsyncFn (see wal/dir_fsync.go).
|
||||||
|
var segmentFsyncFn = segmentFsync
|
||||||
|
|
||||||
|
func segmentFsync(filePath string) error {
|
||||||
|
f, err := os.OpenFile(filePath, os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open for fsync: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("fsync: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateAndPersist executes the 4-step tail-truncation protocol per
|
||||||
|
// design §3.2 line 787-794. Any step failure is fatal: per line 799,
|
||||||
|
// DB must NOT enter writable state if truncation cannot be persisted.
|
||||||
|
func truncateAndPersist(
|
||||||
|
filePath string,
|
||||||
|
lastCompleteBatchEnd int64,
|
||||||
|
dir string,
|
||||||
|
emptyTrailingSegments []string,
|
||||||
|
) error {
|
||||||
|
// Step 1: ftruncate
|
||||||
|
if lastCompleteBatchEnd < 0 {
|
||||||
|
return fmt.Errorf("wal: invalid truncate offset %d", lastCompleteBatchEnd)
|
||||||
|
}
|
||||||
|
if err := os.Truncate(filePath, lastCompleteBatchEnd); err != nil {
|
||||||
|
return fmt.Errorf("ftruncate %s to %d: %w", filePath, lastCompleteBatchEnd, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: fsync the truncated segment
|
||||||
|
if err := segmentFsyncFn(filePath); err != nil {
|
||||||
|
return fmt.Errorf("fsync truncated segment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: delete empty trailing segments
|
||||||
|
for _, segPath := range emptyTrailingSegments {
|
||||||
|
if err := os.Remove(segPath); err != nil {
|
||||||
|
return fmt.Errorf("remove empty segment %s: %w", segPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: fsync WAL directory (reuses C6's dirFsyncFn)
|
||||||
|
if err := dirFsyncFn(dir); err != nil {
|
||||||
|
return fmt.Errorf("fsync WAL dir after truncation: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLastCompleteBatchEnd walks the segment file, runs physical records
|
||||||
|
// through the FragmentCollector state machine, and returns the byte offset
|
||||||
|
// of the END of the last complete WAL Batch.
|
||||||
|
//
|
||||||
|
// This is the correct truncation target per design §3.2 line 786. A previous
|
||||||
|
// version (findValidOffset) only checked physical record CRCs, missing the
|
||||||
|
// case where a First + Middle* fragment chain has no Last (H8 bug): physical
|
||||||
|
// CRCs pass but no complete batch exists at that offset.
|
||||||
|
//
|
||||||
|
// Block-boundary handling: WAL format allows a full block to end with zero
|
||||||
|
// padding when the next record doesn't fit (see BlockWriter.paddingNeeded).
|
||||||
|
// This function CONTINUES to the next block on padding in a full block, and
|
||||||
|
// only RETURNS on padding in a short (final) block or actual corruption.
|
||||||
|
func findLastCompleteBatchEnd(filePath string) (int64, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("open %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if _, err := f.Seek(WalFileHeaderSize, 0); err != nil {
|
||||||
|
return 0, fmt.Errorf("seek past header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
collector := NewFragmentCollector()
|
||||||
|
lastCompleteEnd := int64(WalFileHeaderSize)
|
||||||
|
blockStartOffset := int64(WalFileHeaderSize)
|
||||||
|
buf := make([]byte, WalBlockSize)
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, readErr := f.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
blockData := buf[:n]
|
||||||
|
isFullBlock := n == WalBlockSize && readErr == nil
|
||||||
|
pos := 0
|
||||||
|
for pos < len(blockData) {
|
||||||
|
remaining := len(blockData) - pos
|
||||||
|
|
||||||
|
if remaining < PhysicalRecordHeaderSize {
|
||||||
|
if isFullBlock {
|
||||||
|
break // padding in full block, continue to next block
|
||||||
|
}
|
||||||
|
return lastCompleteEnd, nil // tail padding in short block
|
||||||
|
}
|
||||||
|
|
||||||
|
if isAllZeros(blockData[pos : pos+PhysicalRecordHeaderSize]) {
|
||||||
|
if isFullBlock {
|
||||||
|
break // zero-led padding in full block, continue
|
||||||
|
}
|
||||||
|
return lastCompleteEnd, nil // tail padding
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, consumed, err := DecodePhysicalRecord(blockData[pos:])
|
||||||
|
if err != nil {
|
||||||
|
return lastCompleteEnd, nil // physical corruption
|
||||||
|
}
|
||||||
|
if err := collector.Append(rec.Type, rec.Payload); err != nil {
|
||||||
|
return lastCompleteEnd, nil // fragment state machine rejected
|
||||||
|
}
|
||||||
|
|
||||||
|
pos += consumed
|
||||||
|
recordEndAbsolute := blockStartOffset + int64(pos)
|
||||||
|
|
||||||
|
if collector.IsComplete() {
|
||||||
|
lastCompleteEnd = recordEndAbsolute
|
||||||
|
collector.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blockStartOffset += int64(n)
|
||||||
|
}
|
||||||
|
if readErr != nil || n < WalBlockSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastCompleteEnd, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeRawSegmentHeader writes a 32-byte WAL header to filePath. Used by
|
||||||
|
// findLastCompleteBatchEnd tests to construct minimal segment files.
|
||||||
|
func writeRawSegmentHeader(t *testing.T, filePath string) {
|
||||||
|
t.Helper()
|
||||||
|
hdr := &WalFileHeader{
|
||||||
|
BlockSize: 32 * 1024,
|
||||||
|
SegmentID: 0,
|
||||||
|
StartSequence: 0,
|
||||||
|
}
|
||||||
|
encoded := EncodeWalHeader(hdr)
|
||||||
|
if err := os.WriteFile(filePath, encoded[:], 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile header: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendRawBytes appends arbitrary bytes to filePath.
|
||||||
|
func appendRawBytes(t *testing.T, filePath string, data []byte) {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenFile append: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := f.Write(data); err != nil {
|
||||||
|
t.Fatalf("Write: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendFullRecord encodes and appends a complete WAL batch as a Full record.
|
||||||
|
func appendFullRecord(t *testing.T, filePath string, batch []byte) {
|
||||||
|
t.Helper()
|
||||||
|
rec := EncodePhysicalRecord(RecFull, batch)
|
||||||
|
appendRawBytes(t, filePath, rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendFragment encodes and appends a single fragment record.
|
||||||
|
func appendFragment(t *testing.T, filePath string, recType uint8, payload []byte) {
|
||||||
|
t.Helper()
|
||||||
|
rec := EncodePhysicalRecord(recType, payload)
|
||||||
|
appendRawBytes(t, filePath, rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindLastCompleteBatchEnd_CleanSegment(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := filepath.Join(dir, "segment-0.wal")
|
||||||
|
writeRawSegmentHeader(t, filePath)
|
||||||
|
|
||||||
|
batchA := []byte("batch-A-content")
|
||||||
|
batchB := []byte("batch-B-content")
|
||||||
|
appendFullRecord(t, filePath, batchA)
|
||||||
|
endOfA, _ := fileSize(filePath)
|
||||||
|
appendFullRecord(t, filePath, batchB)
|
||||||
|
endOfB, _ := fileSize(filePath)
|
||||||
|
|
||||||
|
got, err := findLastCompleteBatchEnd(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findLastCompleteBatchEnd: %v", err)
|
||||||
|
}
|
||||||
|
if got != endOfB {
|
||||||
|
t.Errorf("got %d, want %d (end of Batch B)", got, endOfB)
|
||||||
|
}
|
||||||
|
if got == endOfA {
|
||||||
|
t.Errorf("got end of Batch A, should be end of Batch B")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for H8: partial fragment tail must return end of last
|
||||||
|
// COMPLETE batch, not end of last physical record.
|
||||||
|
func TestFindLastCompleteBatchEnd_PartialTailFragment(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := filepath.Join(dir, "segment-0.wal")
|
||||||
|
writeRawSegmentHeader(t, filePath)
|
||||||
|
|
||||||
|
batchA := []byte("batch-A-content")
|
||||||
|
appendFullRecord(t, filePath, batchA)
|
||||||
|
endOfA, _ := fileSize(filePath)
|
||||||
|
|
||||||
|
// Append First + Middle fragments (no Last) — H8 case.
|
||||||
|
appendFragment(t, filePath, RecFirst, []byte("first-fragment-data"))
|
||||||
|
appendFragment(t, filePath, RecMiddle, []byte("middle-fragment-data"))
|
||||||
|
|
||||||
|
got, err := findLastCompleteBatchEnd(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findLastCompleteBatchEnd: %v", err)
|
||||||
|
}
|
||||||
|
if got != endOfA {
|
||||||
|
t.Errorf("got %d, want %d (end of Batch A, NOT end of Middle fragment)", got, endOfA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindLastCompleteBatchEnd_NoBatches(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := filepath.Join(dir, "segment-0.wal")
|
||||||
|
writeRawSegmentHeader(t, filePath)
|
||||||
|
|
||||||
|
got, err := findLastCompleteBatchEnd(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findLastCompleteBatchEnd: %v", err)
|
||||||
|
}
|
||||||
|
if got != int64(WalFileHeaderSize) {
|
||||||
|
t.Errorf("got %d, want %d (WalFileHeaderSize)", got, WalFileHeaderSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindLastCompleteBatchEnd_PhysicalCorruption(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := filepath.Join(dir, "segment-0.wal")
|
||||||
|
writeRawSegmentHeader(t, filePath)
|
||||||
|
|
||||||
|
batchA := []byte("batch-A-content")
|
||||||
|
appendFullRecord(t, filePath, batchA)
|
||||||
|
endOfA, _ := fileSize(filePath)
|
||||||
|
|
||||||
|
// Append corrupted bytes (will fail CRC).
|
||||||
|
appendRawBytes(t, filePath, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF})
|
||||||
|
|
||||||
|
got, err := findLastCompleteBatchEnd(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findLastCompleteBatchEnd: %v", err)
|
||||||
|
}
|
||||||
|
if got != endOfA {
|
||||||
|
t.Errorf("got %d, want %d (end of Batch A, before corruption)", got, endOfA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindLastCompleteBatchEnd_PartialBatchOnly(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := filepath.Join(dir, "segment-0.wal")
|
||||||
|
writeRawSegmentHeader(t, filePath)
|
||||||
|
|
||||||
|
// Only First + Middle fragments, no complete batch ever.
|
||||||
|
appendFragment(t, filePath, RecFirst, []byte("first-data"))
|
||||||
|
appendFragment(t, filePath, RecMiddle, []byte("middle-data"))
|
||||||
|
|
||||||
|
got, err := findLastCompleteBatchEnd(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findLastCompleteBatchEnd: %v", err)
|
||||||
|
}
|
||||||
|
if got != int64(WalFileHeaderSize) {
|
||||||
|
t.Errorf("got %d, want %d (no complete batch, stay at header)", got, WalFileHeaderSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oracle BLOCKING test: must continue past padding in a full block to read
|
||||||
|
// the next block. Original code returned on first padding, truncating all
|
||||||
|
// later batches.
|
||||||
|
func TestFindLastCompleteBatchEnd_BlockBoundaryPadding(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := filepath.Join(dir, "segment-0.wal")
|
||||||
|
writeRawSegmentHeader(t, filePath)
|
||||||
|
|
||||||
|
// Batch A: large enough to nearly fill block 1.
|
||||||
|
// Block 1 layout: [32-byte header][Batch A (Full record)] [padding to end]
|
||||||
|
// We need: 32 + len(Full record of A) + padding == 32 + WalBlockSize
|
||||||
|
// Full record = 7 (header) + len(payload). So:
|
||||||
|
// len(A) such that 7 + len(A) leaves < 7 bytes before block end
|
||||||
|
// Then writer pads to end of block and Batch B goes in block 2.
|
||||||
|
|
||||||
|
// Available in block 1 after file header = WalBlockSize - 32 = 32736
|
||||||
|
// We want Batch A record size to be 32736 - 6 = 32730 (leaving 6 bytes, < 7, padding)
|
||||||
|
// So payload size = 32730 - 7 = 32723
|
||||||
|
// Batch A = batch header (18) + entry bytes. Entry: Put with key + value.
|
||||||
|
// Simplest: use raw bytes (we're testing physical layout, not batch validity).
|
||||||
|
bigPayload := make([]byte, 32723)
|
||||||
|
for i := range bigPayload {
|
||||||
|
bigPayload[i] = byte('A')
|
||||||
|
}
|
||||||
|
recA := EncodePhysicalRecord(RecFull, bigPayload)
|
||||||
|
appendRawBytes(t, filePath, recA)
|
||||||
|
|
||||||
|
endOfBlock1 := int64(WalFileHeaderSize + WalBlockSize) // 32 + 32768
|
||||||
|
currentSize, _ := fileSize(filePath)
|
||||||
|
// Pad to end of block 1 with zeros.
|
||||||
|
padLen := int(endOfBlock1 - currentSize)
|
||||||
|
if padLen > 0 {
|
||||||
|
appendRawBytes(t, filePath, make([]byte, padLen))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch B in block 2 (normal small batch after padding boundary).
|
||||||
|
batchB := []byte("batch-B")
|
||||||
|
recB := EncodePhysicalRecord(RecFull, batchB)
|
||||||
|
appendRawBytes(t, filePath, recB)
|
||||||
|
endOfB, _ := fileSize(filePath)
|
||||||
|
|
||||||
|
got, err := findLastCompleteBatchEnd(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findLastCompleteBatchEnd: %v", err)
|
||||||
|
}
|
||||||
|
if got != endOfB {
|
||||||
|
t.Errorf("got %d, want %d (end of Batch B in block 2, after padding)", got, endOfB)
|
||||||
|
}
|
||||||
|
// Specifically: must NOT be at end of Batch A (would be ~32755, before padding).
|
||||||
|
if got < endOfBlock1 {
|
||||||
|
t.Errorf("got %d < %d (returned at end of Batch A, missed block 2)", got, endOfBlock1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindLastCompleteBatchEnd_NonZeroTailPadding(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := filepath.Join(dir, "segment-0.wal")
|
||||||
|
writeRawSegmentHeader(t, filePath)
|
||||||
|
|
||||||
|
batchA := []byte("batch-A")
|
||||||
|
appendFullRecord(t, filePath, batchA)
|
||||||
|
endOfA, _ := fileSize(filePath)
|
||||||
|
|
||||||
|
// Append 3 non-zero bytes (< PhysicalRecordHeaderSize=7). This is
|
||||||
|
// technically invalid padding (per design: padding must be zeros), but
|
||||||
|
// findLastCompleteBatchEnd should still return end of last complete
|
||||||
|
// batch — this is "tail corruption" classification territory.
|
||||||
|
appendRawBytes(t, filePath, []byte{0xFF, 0xFF, 0xFF})
|
||||||
|
|
||||||
|
got, err := findLastCompleteBatchEnd(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findLastCompleteBatchEnd: %v", err)
|
||||||
|
}
|
||||||
|
if got != endOfA {
|
||||||
|
t.Errorf("got %d, want %d (end of Batch A)", got, endOfA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindLastCompleteBatchEnd_ZeroTailPadding(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := filepath.Join(dir, "segment-0.wal")
|
||||||
|
writeRawSegmentHeader(t, filePath)
|
||||||
|
|
||||||
|
batchA := []byte("batch-A")
|
||||||
|
appendFullRecord(t, filePath, batchA)
|
||||||
|
endOfA, _ := fileSize(filePath)
|
||||||
|
|
||||||
|
// Append 3 zero bytes (valid tail padding in a short final block).
|
||||||
|
appendRawBytes(t, filePath, []byte{0, 0, 0})
|
||||||
|
|
||||||
|
got, err := findLastCompleteBatchEnd(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findLastCompleteBatchEnd: %v", err)
|
||||||
|
}
|
||||||
|
if got != endOfA {
|
||||||
|
t.Errorf("got %d, want %d (end of Batch A)", got, endOfA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileSize(filePath string) (int64, error) {
|
||||||
|
fi, err := os.Stat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return fi.Size(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- truncateAndPersist tests --------
|
||||||
|
|
||||||
|
func TestTruncateAndPersist_Success(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := filepath.Join(dir, "segment-0.wal")
|
||||||
|
writeRawSegmentHeader(t, filePath)
|
||||||
|
appendRawBytes(t, filePath, []byte("batch-A"))
|
||||||
|
appendRawBytes(t, filePath, []byte("extra-bytes-to-be-truncated"))
|
||||||
|
|
||||||
|
endOfA, _ := fileSize(filePath)
|
||||||
|
truncateOffset := int64(WalFileHeaderSize) + 7 // just past header, before "batch-A"
|
||||||
|
|
||||||
|
if err := truncateAndPersist(filePath, truncateOffset, dir, nil); err != nil {
|
||||||
|
t.Fatalf("truncateAndPersist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotSize, _ := fileSize(filePath)
|
||||||
|
if gotSize != truncateOffset {
|
||||||
|
t.Errorf("file size = %d, want %d (truncated)", gotSize, truncateOffset)
|
||||||
|
}
|
||||||
|
_ = endOfA // not used; verify only that size matches truncate point
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateAndPersist_FtruncateFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
nonExistent := filepath.Join(dir, "no-such-file.wal")
|
||||||
|
|
||||||
|
err := truncateAndPersist(nonExistent, 100, dir, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on non-existent file")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "ftruncate") {
|
||||||
|
t.Errorf("error should mention 'ftruncate', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateAndPersist_DirFsyncFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := filepath.Join(dir, "segment-0.wal")
|
||||||
|
writeRawSegmentHeader(t, filePath)
|
||||||
|
appendRawBytes(t, filePath, []byte("extra"))
|
||||||
|
|
||||||
|
orig := dirFsyncFn
|
||||||
|
dirFsyncFn = func(string) error { return errors.New("simulated dir fsync failure") }
|
||||||
|
t.Cleanup(func() { dirFsyncFn = orig })
|
||||||
|
|
||||||
|
err := truncateAndPersist(filePath, int64(WalFileHeaderSize), dir, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on dir fsync failure")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "fsync WAL dir") {
|
||||||
|
t.Errorf("error should mention 'fsync WAL dir', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncateAndPersist_SegmentFsyncFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := filepath.Join(dir, "segment-0.wal")
|
||||||
|
writeRawSegmentHeader(t, filePath)
|
||||||
|
appendRawBytes(t, filePath, []byte("extra"))
|
||||||
|
|
||||||
|
orig := segmentFsyncFn
|
||||||
|
segmentFsyncFn = func(string) error { return errors.New("simulated segment fsync failure") }
|
||||||
|
t.Cleanup(func() { segmentFsyncFn = orig })
|
||||||
|
|
||||||
|
err := truncateAndPersist(filePath, int64(WalFileHeaderSize), dir, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on segment fsync failure")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "fsync truncated segment") {
|
||||||
|
t.Errorf("error should mention 'fsync truncated segment', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oracle nice-to-have: verify retry after dir-fsync failure. The first
|
||||||
|
// call fails after ftruncate succeeded; the second call (with fsync
|
||||||
|
// restored) must succeed and the file must end up correctly truncated.
|
||||||
|
func TestTruncateAndPersist_RetryAfterDirFsyncFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := filepath.Join(dir, "segment-0.wal")
|
||||||
|
writeRawSegmentHeader(t, filePath)
|
||||||
|
appendRawBytes(t, filePath, []byte("extra-bytes"))
|
||||||
|
|
||||||
|
truncateOffset := int64(WalFileHeaderSize)
|
||||||
|
|
||||||
|
// First call: inject dir fsync failure.
|
||||||
|
orig := dirFsyncFn
|
||||||
|
dirFsyncFn = func(string) error { return errors.New("simulated") }
|
||||||
|
if err := truncateAndPersist(filePath, truncateOffset, dir, nil); err == nil {
|
||||||
|
t.Fatal("first call should fail")
|
||||||
|
}
|
||||||
|
dirFsyncFn = orig
|
||||||
|
|
||||||
|
// Second call: must succeed (ftruncate is idempotent).
|
||||||
|
if err := truncateAndPersist(filePath, truncateOffset, dir, nil); err != nil {
|
||||||
|
t.Fatalf("retry truncateAndPersist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotSize, _ := fileSize(filePath)
|
||||||
|
if gotSize != truncateOffset {
|
||||||
|
t.Errorf("after retry, file size = %d, want %d", gotSize, truncateOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dailz/go-kv/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecoverFromEmptyDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
result, err := Recover(dir, replayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover empty dir: %v", err)
|
||||||
|
}
|
||||||
|
if result.NextSequence != 0 {
|
||||||
|
t.Errorf("NextSequence = %d, want 0", result.NextSequence)
|
||||||
|
}
|
||||||
|
if result.Truncated {
|
||||||
|
t.Error("Truncated = true, want false")
|
||||||
|
}
|
||||||
|
if result.TruncateError != nil {
|
||||||
|
t.Errorf("TruncateError = %v, want nil", result.TruncateError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoverFullFlow(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Write test data: 2 batches across 1 segment.
|
||||||
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||||
|
{makePutEntry("key1", "val1"), makeDeleteEntry("key2")},
|
||||||
|
{makePutEntry("key3", "val3")},
|
||||||
|
})
|
||||||
|
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
result, err := Recover(dir, replayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover: %v", err)
|
||||||
|
}
|
||||||
|
if result.NextSequence != 3 {
|
||||||
|
t.Errorf("NextSequence = %d, want 3", result.NextSequence)
|
||||||
|
}
|
||||||
|
if result.NextSegmentID != 1 {
|
||||||
|
t.Errorf("NextSegmentID = %d, want 1", result.NextSegmentID)
|
||||||
|
}
|
||||||
|
if result.Truncated {
|
||||||
|
t.Error("Truncated = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPuts := []replayPut{
|
||||||
|
{key: "key1", value: "val1", seq: 0},
|
||||||
|
{key: "key3", value: "val3", seq: 2},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(replayer.puts, wantPuts) {
|
||||||
|
t.Errorf("puts = %#v, want %#v", replayer.puts, wantPuts)
|
||||||
|
}
|
||||||
|
wantDeletes := []replayDelete{
|
||||||
|
{key: "key2", seq: 1},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(replayer.deletes, wantDeletes) {
|
||||||
|
t.Errorf("deletes = %#v, want %#v", replayer.deletes, wantDeletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoverWithTailCorruption(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Write valid batches, then corrupt the tail.
|
||||||
|
filePath := writeTestSegment(t, dir, 0, 100, [][]*WalEntry{
|
||||||
|
{makePutEntry("good1", "before-corruption")},
|
||||||
|
{makePutEntry("good2", "also-before")},
|
||||||
|
})
|
||||||
|
appendFileBytes(t, filePath, []byte{0xDE, 0xAD, 0xBE, 0xEF})
|
||||||
|
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
result, err := Recover(dir, replayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover with tail corruption: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Truncated {
|
||||||
|
t.Fatal("Truncated = false, want true")
|
||||||
|
}
|
||||||
|
if result.TruncateError == nil {
|
||||||
|
t.Fatal("TruncateError = nil, want non-nil")
|
||||||
|
}
|
||||||
|
if result.NextSequence != 102 {
|
||||||
|
t.Errorf("NextSequence = %d, want 102", result.NextSequence)
|
||||||
|
}
|
||||||
|
if result.NextSegmentID != 1 {
|
||||||
|
t.Errorf("NextSegmentID = %d, want 1", result.NextSegmentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify only the valid entries were replayed.
|
||||||
|
wantPuts := []replayPut{
|
||||||
|
{key: "good1", value: "before-corruption", seq: 100},
|
||||||
|
{key: "good2", value: "also-before", seq: 101},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(replayer.puts, wantPuts) {
|
||||||
|
t.Errorf("puts = %#v, want %#v", replayer.puts, wantPuts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the file was truncated — it should be smaller than before.
|
||||||
|
fi, fiErr := os.Stat(filePath)
|
||||||
|
if fiErr != nil {
|
||||||
|
t.Fatalf("Stat truncated file: %v", fiErr)
|
||||||
|
}
|
||||||
|
if fi.Size() == 0 {
|
||||||
|
t.Error("truncated file is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the truncated file still parses cleanly.
|
||||||
|
replayer2 := &mockReplayer{}
|
||||||
|
_, parseErr := ReplaySegmentFile(filePath, 100, true, replayer2)
|
||||||
|
if parseErr != nil {
|
||||||
|
t.Fatalf("replay after truncation: %v", parseErr)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(replayer2.puts, wantPuts) {
|
||||||
|
t.Errorf("replay after truncation puts = %#v, want %#v", replayer2.puts, wantPuts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoverDoesNotUpdateManifest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
writeTestSegment(t, dir, 0, 50, [][]*WalEntry{
|
||||||
|
{makePutEntry("a", "b")},
|
||||||
|
{makePutEntry("c", "d")},
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeMF, err := manifest.Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("manifest.Load before recover: %v", err)
|
||||||
|
}
|
||||||
|
beforeExists := fileExists(t, filepath.Join(dir, "MANIFEST"))
|
||||||
|
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
if _, err := Recover(dir, replayer); err != nil {
|
||||||
|
t.Fatalf("Recover: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterMF, err := manifest.Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("manifest.Load after recover: %v", err)
|
||||||
|
}
|
||||||
|
if afterMF.RecoverySegmentID != beforeMF.RecoverySegmentID {
|
||||||
|
t.Errorf("MANIFEST RecoverySegmentID changed: %d -> %d",
|
||||||
|
beforeMF.RecoverySegmentID, afterMF.RecoverySegmentID)
|
||||||
|
}
|
||||||
|
afterExists := fileExists(t, filepath.Join(dir, "MANIFEST"))
|
||||||
|
if beforeExists != afterExists {
|
||||||
|
t.Errorf("MANIFEST file existence changed: before=%v after=%v",
|
||||||
|
beforeExists, afterExists)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C3: covers the case where MANIFEST already exists.
|
||||||
|
// The fresh-DB test above cannot catch accidental overwrites of an existing
|
||||||
|
// MANIFEST.
|
||||||
|
func TestRecoverPreservesExistingManifest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
writeTestSegment(t, dir, 0, 50, [][]*WalEntry{
|
||||||
|
{makePutEntry("a", "b")},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := manifest.Save(dir, 0); err != nil {
|
||||||
|
t.Fatalf("manifest.Save setup: %v", err)
|
||||||
|
}
|
||||||
|
beforeBytes, err := os.ReadFile(filepath.Join(dir, "MANIFEST"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile MANIFEST: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
if _, err := Recover(dir, replayer); err != nil {
|
||||||
|
t.Fatalf("Recover: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterBytes, err := os.ReadFile(filepath.Join(dir, "MANIFEST"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile MANIFEST after recover: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(beforeBytes, afterBytes) {
|
||||||
|
t.Errorf("MANIFEST bytes changed:\n before=%q\n after=%q",
|
||||||
|
string(beforeBytes), string(afterBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C3: design §3.2 line 280 requires recovery to be
|
||||||
|
// idempotent on a clean WAL (no MANIFEST side effects).
|
||||||
|
func TestRecoverIdempotentClean(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||||
|
{makePutEntry("k1", "v1")},
|
||||||
|
{makePutEntry("k2", "v2")},
|
||||||
|
})
|
||||||
|
|
||||||
|
replayer1 := &mockReplayer{}
|
||||||
|
result1, err := Recover(dir, replayer1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover (1st): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
replayer2 := &mockReplayer{}
|
||||||
|
result2, err := Recover(dir, replayer2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover (2nd): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result1.NextSequence != result2.NextSequence {
|
||||||
|
t.Errorf("NextSequence differs: %d vs %d", result1.NextSequence, result2.NextSequence)
|
||||||
|
}
|
||||||
|
if result1.NextSegmentID != result2.NextSegmentID {
|
||||||
|
t.Errorf("NextSegmentID differs: %d vs %d", result1.NextSegmentID, result2.NextSegmentID)
|
||||||
|
}
|
||||||
|
if result1.Truncated || result2.Truncated {
|
||||||
|
t.Errorf("Truncated should be false for clean WAL: r1=%v r2=%v",
|
||||||
|
result1.Truncated, result2.Truncated)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(replayer1.puts, replayer2.puts) {
|
||||||
|
t.Errorf("replayed puts differ:\n r1=%#v\n r2=%#v", replayer1.puts, replayer2.puts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C3: after the first recovery truncates a corrupted
|
||||||
|
// tail, the second recovery must observe stable state (Truncated=false, same
|
||||||
|
// NextSequence, same replayed entries).
|
||||||
|
func TestRecoverIdempotentAfterTruncation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
filePath := writeTestSegment(t, dir, 0, 100, [][]*WalEntry{
|
||||||
|
{makePutEntry("good1", "before-corruption")},
|
||||||
|
{makePutEntry("good2", "also-before")},
|
||||||
|
})
|
||||||
|
appendFileBytes(t, filePath, []byte{0xDE, 0xAD, 0xBE, 0xEF})
|
||||||
|
|
||||||
|
replayer1 := &mockReplayer{}
|
||||||
|
result1, err := Recover(dir, replayer1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover (1st): %v", err)
|
||||||
|
}
|
||||||
|
if !result1.Truncated {
|
||||||
|
t.Fatal("1st Recover: Truncated = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
replayer2 := &mockReplayer{}
|
||||||
|
result2, err := Recover(dir, replayer2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover (2nd): %v", err)
|
||||||
|
}
|
||||||
|
if result2.Truncated {
|
||||||
|
t.Error("2nd Recover: Truncated = true, want false (tail already repaired)")
|
||||||
|
}
|
||||||
|
if result1.NextSequence != result2.NextSequence {
|
||||||
|
t.Errorf("NextSequence differs: %d vs %d", result1.NextSequence, result2.NextSequence)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(replayer1.puts, replayer2.puts) {
|
||||||
|
t.Errorf("replayed puts differ:\n r1=%#v\n r2=%#v", replayer1.puts, replayer2.puts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C2: design §3.2 line 604-06 forbids using CURRENT as
|
||||||
|
// recovery start. writeTestSegment uses NewSegmentWriter directly, which does
|
||||||
|
// NOT write CURRENT (only SegmentManager does), so we write CURRENT explicitly
|
||||||
|
// to simulate the Phase 1 default state.
|
||||||
|
func TestRecoverIgnoresCurrentFallback(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||||
|
{makePutEntry("seg0-k1", "v1")},
|
||||||
|
})
|
||||||
|
writeTestSegment(t, dir, 1, 1, [][]*WalEntry{
|
||||||
|
{makePutEntry("seg1-k1", "v1")},
|
||||||
|
})
|
||||||
|
writeTestSegment(t, dir, 2, 2, [][]*WalEntry{
|
||||||
|
{makePutEntry("seg2-k1", "v1")},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := manifest.WriteCurrent(dir, 2); err != nil {
|
||||||
|
t.Fatalf("WriteCurrent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSegID, ok := manifest.ReadCurrent(dir)
|
||||||
|
if !ok || currentSegID != 2 {
|
||||||
|
t.Fatalf("CURRENT setup wrong: segID=%d ok=%v", currentSegID, ok)
|
||||||
|
}
|
||||||
|
mf, err := manifest.Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if mf.RecoverySegmentID != 0 {
|
||||||
|
t.Fatalf("MANIFEST.RecoverySegmentID = %d, want 0", mf.RecoverySegmentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
result, err := Recover(dir, replayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPuts := []replayPut{
|
||||||
|
{key: "seg0-k1", value: "v1", seq: 0},
|
||||||
|
{key: "seg1-k1", value: "v1", seq: 1},
|
||||||
|
{key: "seg2-k1", value: "v1", seq: 2},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(replayer.puts, wantPuts) {
|
||||||
|
t.Errorf("puts = %#v, want %#v", replayer.puts, wantPuts)
|
||||||
|
}
|
||||||
|
if result.NextSequence != 3 {
|
||||||
|
t.Errorf("NextSequence = %d, want 3", result.NextSequence)
|
||||||
|
}
|
||||||
|
if result.NextSegmentID != 3 {
|
||||||
|
t.Errorf("NextSegmentID = %d, want 3", result.NextSegmentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(t *testing.T, path string) bool {
|
||||||
|
t.Helper()
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t.Fatalf("stat %s: %v", path, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C5+H8: end-to-end recovery with partial fragment
|
||||||
|
// tail must persist truncation at the last COMPLETE batch boundary
|
||||||
|
// (H8), and the truncation must be persisted with all 4 steps (C5).
|
||||||
|
// After repair, second recovery must not see corruption.
|
||||||
|
func TestRecoverPartialFragmentTailIdempotent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
batchA := []*WalEntry{makePutEntry("key-A", "val-A")}
|
||||||
|
batchB := []*WalEntry{makePutEntry("key-B", "val-B")}
|
||||||
|
|
||||||
|
filePath := writeTestSegment(t, dir, 0, 0, [][]*WalEntry{batchA, batchB})
|
||||||
|
|
||||||
|
// Compute exact byte offset where Batch B's Full record ends.
|
||||||
|
// Layout: [header][Batch A Full record][Batch B Full record][padding to 32KB]
|
||||||
|
encA, _ := EncodeWalBatch(0, batchA)
|
||||||
|
encB, _ := EncodeWalBatch(1, batchB)
|
||||||
|
endOfBatchB := int64(WalFileHeaderSize) +
|
||||||
|
int64(PhysicalRecordHeaderSize+len(encA)) +
|
||||||
|
int64(PhysicalRecordHeaderSize+len(encB))
|
||||||
|
|
||||||
|
fiBefore, _ := os.Stat(filePath)
|
||||||
|
|
||||||
|
// Append First + Middle* (no Last) to simulate partial fragment tail.
|
||||||
|
appendFileBytes(t, filePath, EncodePhysicalRecord(RecFirst, []byte("first-fragment-payload")))
|
||||||
|
appendFileBytes(t, filePath, EncodePhysicalRecord(RecMiddle, []byte("middle-fragment-payload")))
|
||||||
|
|
||||||
|
replayer1 := &mockReplayer{}
|
||||||
|
result1, err := Recover(dir, replayer1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("1st Recover: %v", err)
|
||||||
|
}
|
||||||
|
if !result1.Truncated {
|
||||||
|
t.Fatal("1st Recover: Truncated = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
fiAfter, _ := os.Stat(filePath)
|
||||||
|
if fiAfter.Size() != endOfBatchB {
|
||||||
|
t.Errorf("file size after truncation = %d, want %d (end of Batch B, H8)",
|
||||||
|
fiAfter.Size(), endOfBatchB)
|
||||||
|
}
|
||||||
|
if fiAfter.Size() >= fiBefore.Size() {
|
||||||
|
t.Errorf("file should shrink after truncation: before=%d after=%d",
|
||||||
|
fiBefore.Size(), fiAfter.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
replayer2 := &mockReplayer{}
|
||||||
|
result2, err := Recover(dir, replayer2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("2nd Recover: %v", err)
|
||||||
|
}
|
||||||
|
if result2.Truncated {
|
||||||
|
t.Error("2nd Recover: Truncated = true, want false (truncation should be persisted)")
|
||||||
|
}
|
||||||
|
if result1.NextSequence != result2.NextSequence {
|
||||||
|
t.Errorf("NextSequence differs: %d vs %d", result1.NextSequence, result2.NextSequence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C5: any truncation persist step failure must
|
||||||
|
// fail Recover, causing DB.Open to fail. No swallowing allowed.
|
||||||
|
func TestRecoverTruncationFailureFailsRecovery(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||||
|
{makePutEntry("key-A", "val-A")},
|
||||||
|
})
|
||||||
|
// Append corruption to trigger tail corruption path.
|
||||||
|
appendFileBytes(t, filePath, []byte{0xDE, 0xAD, 0xBE, 0xEF})
|
||||||
|
|
||||||
|
// Inject dir fsync failure (Step 4 of truncateAndPersist).
|
||||||
|
orig := dirFsyncFn
|
||||||
|
dirFsyncFn = func(string) error { return errors.New("simulated dir fsync failure") }
|
||||||
|
t.Cleanup(func() { dirFsyncFn = orig })
|
||||||
|
|
||||||
|
_, err := Recover(dir, &mockReplayer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected Recover to fail when truncation persist fails")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "persist tail truncation") {
|
||||||
|
t.Errorf("error should mention 'persist tail truncation', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for design line 778-781: CRC-valid but batch-content-
|
||||||
|
// invalid must hard-fail through DecodeWalBatch, NOT enter truncation path.
|
||||||
|
func TestRecoverInvalidBatchNotTruncatable(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// Build segment with: physical records CRC-valid, but assembled batch
|
||||||
|
// has invalid header (entryCount=0).
|
||||||
|
filePath := filepath.Join(dir, "segment-0.wal")
|
||||||
|
writeRawSegmentHeader(t, filePath)
|
||||||
|
|
||||||
|
// Construct an "invalid batch": WalBatchHeaderSize=18 bytes, with
|
||||||
|
// entryCount=0 (invalid per ReplayBatch check at recovery.go).
|
||||||
|
invalidBatch := make([]byte, WalBatchHeaderSize)
|
||||||
|
// flags(2) + baseSequence(8) + entryCount(4)=0 + entriesSize(4)=0
|
||||||
|
// All zeros, except entryCount=0 is invalid by itself.
|
||||||
|
// Encode as Full physical record (CRC-valid).
|
||||||
|
rec := EncodePhysicalRecord(RecFull, invalidBatch)
|
||||||
|
appendFileBytes(t, filePath, rec)
|
||||||
|
|
||||||
|
_, err := Recover(dir, &mockReplayer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected Recover to fail on invalid batch content")
|
||||||
|
}
|
||||||
|
// Should NOT mention truncation — must be a different error path.
|
||||||
|
if strings.Contains(err.Error(), "truncat") {
|
||||||
|
t.Errorf("error should not be about truncation; got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// File must NOT have been truncated (size unchanged).
|
||||||
|
fi, _ := os.Stat(filePath)
|
||||||
|
if fi.Size() != int64(WalFileHeaderSize)+int64(len(rec)) {
|
||||||
|
t.Errorf("file was truncated; size = %d, want %d",
|
||||||
|
fi.Size(), int64(WalFileHeaderSize)+int64(len(rec)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- C4 integration regression guards --------
|
||||||
|
|
||||||
|
// Regression guard for C4: middle segment CollectingFragments must hard-fail
|
||||||
|
// Recover, NOT truncate the (valid) last segment. This is the key bug Oracle
|
||||||
|
// flagged: "wal/recover.go 总是对 segments[len(segments)-1] 调用截断".
|
||||||
|
func TestRecoverMiddleSegmentCorruptionHardFails(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// segment-0: [Batch seq 0-1] (2 entries → next=2)
|
||||||
|
// segment-1: [Batch seq 2-3] + [First][Middle no Last] ← middle corruption
|
||||||
|
// (2 complete entries → next=4 if recovery reached end)
|
||||||
|
// segment-2: [Batch seq 4-5] (valid; never reached)
|
||||||
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||||
|
{makePutEntry("k0", "v0"), makePutEntry("k1", "v1")},
|
||||||
|
})
|
||||||
|
|
||||||
|
seg1Path := writeTestSegment(t, dir, 1, 2, [][]*WalEntry{
|
||||||
|
{makePutEntry("k2", "v2"), makePutEntry("k3", "v3")},
|
||||||
|
})
|
||||||
|
appendFileBytes(t, seg1Path, EncodePhysicalRecord(RecFirst, []byte("first-frag")))
|
||||||
|
appendFileBytes(t, seg1Path, EncodePhysicalRecord(RecMiddle, []byte("middle-frag")))
|
||||||
|
|
||||||
|
writeTestSegment(t, dir, 2, 4, [][]*WalEntry{
|
||||||
|
{makePutEntry("k4", "v4"), makePutEntry("k5", "v5")},
|
||||||
|
})
|
||||||
|
|
||||||
|
seg2Path := filepath.Join(dir, "segment-2.wal")
|
||||||
|
fiBefore, _ := os.Stat(seg2Path)
|
||||||
|
|
||||||
|
_, err := Recover(dir, &mockReplayer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected Recover to fail on middle segment corruption")
|
||||||
|
}
|
||||||
|
if IsTailCorruption(err) {
|
||||||
|
t.Errorf("expected HARD error (not tail corruption) for middle segment; got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: segment-2 must NOT be truncated (it's completely valid).
|
||||||
|
fiAfter, _ := os.Stat(seg2Path)
|
||||||
|
if fiAfter.Size() != fiBefore.Size() {
|
||||||
|
t.Errorf("segment-2 was modified: before=%d after=%d (C4-2 regression)",
|
||||||
|
fiBefore.Size(), fiAfter.Size())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C4 (parser corruption half): middle segment CRC
|
||||||
|
// corruption must hard-fail Recover, NOT truncate the (valid) last segment.
|
||||||
|
// Tests the path where ParseBlock returns TailCorruptionError and
|
||||||
|
// ReplaySegmentFile converts it to hard error for non-last segment.
|
||||||
|
func TestRecoverMiddleSegmentCRCCorruptionHardFails(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||||
|
{makePutEntry("k0", "v0"), makePutEntry("k1", "v1")},
|
||||||
|
})
|
||||||
|
|
||||||
|
seg1Path := writeTestSegment(t, dir, 1, 2, [][]*WalEntry{
|
||||||
|
{makePutEntry("k2", "v2"), makePutEntry("k3", "v3")},
|
||||||
|
})
|
||||||
|
// Append CRC-corrupted bytes (will fail DecodePhysicalRecord CRC check).
|
||||||
|
appendFileBytes(t, seg1Path, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF})
|
||||||
|
|
||||||
|
writeTestSegment(t, dir, 2, 4, [][]*WalEntry{
|
||||||
|
{makePutEntry("k4", "v4"), makePutEntry("k5", "v5")},
|
||||||
|
})
|
||||||
|
|
||||||
|
seg2Path := filepath.Join(dir, "segment-2.wal")
|
||||||
|
fiBefore, _ := os.Stat(seg2Path)
|
||||||
|
|
||||||
|
_, err := Recover(dir, &mockReplayer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected Recover to fail on middle segment CRC corruption")
|
||||||
|
}
|
||||||
|
if IsTailCorruption(err) {
|
||||||
|
t.Errorf("expected HARD error (not tail corruption); got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fiAfter, _ := os.Stat(seg2Path)
|
||||||
|
if fiAfter.Size() != fiBefore.Size() {
|
||||||
|
t.Errorf("segment-2 modified: before=%d after=%d", fiBefore.Size(), fiAfter.Size())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C4: single-segment tail corruption still truncates
|
||||||
|
// correctly (existing behavior preserved).
|
||||||
|
func TestRecoverLastSegmentCorruptionTruncatesCorrectly(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
batchA := []*WalEntry{makePutEntry("k0", "v0")}
|
||||||
|
batchB := []*WalEntry{makePutEntry("k1", "v1")}
|
||||||
|
filePath := writeTestSegment(t, dir, 0, 0, [][]*WalEntry{batchA, batchB})
|
||||||
|
|
||||||
|
encA, _ := EncodeWalBatch(0, batchA)
|
||||||
|
encB, _ := EncodeWalBatch(1, batchB)
|
||||||
|
endOfBatch1 := int64(WalFileHeaderSize) +
|
||||||
|
int64(PhysicalRecordHeaderSize+len(encA)) +
|
||||||
|
int64(PhysicalRecordHeaderSize+len(encB))
|
||||||
|
|
||||||
|
// Append partial fragments to last (only) segment.
|
||||||
|
appendFileBytes(t, filePath, EncodePhysicalRecord(RecFirst, []byte("first")))
|
||||||
|
appendFileBytes(t, filePath, EncodePhysicalRecord(RecMiddle, []byte("middle")))
|
||||||
|
|
||||||
|
result, err := Recover(dir, &mockReplayer{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Truncated {
|
||||||
|
t.Fatal("Truncated = false, want true for last-segment corruption")
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, _ := os.Stat(filePath)
|
||||||
|
if fi.Size() != endOfBatch1 {
|
||||||
|
t.Errorf("file size = %d, want %d", fi.Size(), endOfBatch1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C4: multi-segment with last-segment corruption still
|
||||||
|
// works correctly (the legitimate tail-truncation case).
|
||||||
|
func TestRecoverMultiSegmentLastSegmentCorruptionTruncatesLast(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||||
|
{makePutEntry("k0", "v0")},
|
||||||
|
})
|
||||||
|
seg1Path := writeTestSegment(t, dir, 1, 1, [][]*WalEntry{
|
||||||
|
{makePutEntry("k1", "v1")},
|
||||||
|
{makePutEntry("k2", "v2")},
|
||||||
|
})
|
||||||
|
|
||||||
|
encA, _ := EncodeWalBatch(1, []*WalEntry{makePutEntry("k1", "v1")})
|
||||||
|
encB, _ := EncodeWalBatch(2, []*WalEntry{makePutEntry("k2", "v2")})
|
||||||
|
endOfBatch2 := int64(WalFileHeaderSize) +
|
||||||
|
int64(PhysicalRecordHeaderSize+len(encA)) +
|
||||||
|
int64(PhysicalRecordHeaderSize+len(encB))
|
||||||
|
|
||||||
|
// Append partial fragments to segment-1 (the LAST segment).
|
||||||
|
appendFileBytes(t, seg1Path, EncodePhysicalRecord(RecFirst, []byte("first")))
|
||||||
|
appendFileBytes(t, seg1Path, EncodePhysicalRecord(RecMiddle, []byte("middle")))
|
||||||
|
|
||||||
|
result, err := Recover(dir, &mockReplayer{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Truncated {
|
||||||
|
t.Fatal("Truncated = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, _ := os.Stat(seg1Path)
|
||||||
|
if fi.Size() != endOfBatch2 {
|
||||||
|
t.Errorf("segment-1 size = %d, want %d (end of Batch 2)", fi.Size(), endOfBatch2)
|
||||||
|
}
|
||||||
|
}
|
||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BatchReplayer is the interface for replaying recovered WAL entries.
|
||||||
|
// The recovery process calls the appropriate method for each entry.
|
||||||
|
type BatchReplayer interface {
|
||||||
|
ReplayPut(key, value []byte, sequence uint64)
|
||||||
|
ReplayDelete(key []byte, sequence uint64)
|
||||||
|
}
|
||||||
|
|
||||||
|
type replayAction struct {
|
||||||
|
opType uint8
|
||||||
|
key []byte
|
||||||
|
value []byte
|
||||||
|
sequence uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplayBatch validates and replays a decoded WAL batch, returning the next
|
||||||
|
// expected sequence after the batch.
|
||||||
|
func ReplayBatch(batch *WalBatch, expectedSequence uint64, replayer BatchReplayer) (nextSequence uint64, err error) {
|
||||||
|
if batch == nil {
|
||||||
|
return expectedSequence, errors.New("wal: batch is nil")
|
||||||
|
}
|
||||||
|
if replayer == nil {
|
||||||
|
return expectedSequence, errors.New("wal: batch replayer is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if batch.Flags != 0 {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: invalid batch flags %d", batch.Flags)
|
||||||
|
}
|
||||||
|
if batch.EntryCount == 0 {
|
||||||
|
return expectedSequence, errors.New("wal: batch entry count is zero")
|
||||||
|
}
|
||||||
|
if batch.EntryCount > MaxWalBatchEntryCount {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: batch entry count %d exceeds maximum %d", batch.EntryCount, MaxWalBatchEntryCount)
|
||||||
|
}
|
||||||
|
if batch.EntriesSize != uint32(len(batch.Entries)) {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: batch entries size mismatch: header says %d bytes, have %d bytes", batch.EntriesSize, len(batch.Entries))
|
||||||
|
}
|
||||||
|
if batch.EntriesSize == 0 {
|
||||||
|
return expectedSequence, errors.New("wal: batch entries size is zero")
|
||||||
|
}
|
||||||
|
if batch.EntriesSize > MaxWalBatchEntriesSize {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: batch entries size %d exceeds maximum %d", batch.EntriesSize, MaxWalBatchEntriesSize)
|
||||||
|
}
|
||||||
|
if batch.BaseSequence != expectedSequence {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: batch base sequence %d does not match expected sequence %d", batch.BaseSequence, expectedSequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
entryCount := uint64(batch.EntryCount)
|
||||||
|
if batch.BaseSequence > math.MaxUint64-(entryCount-1) {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: batch sequence range overflows uint64: base sequence %d entry count %d", batch.BaseSequence, batch.EntryCount)
|
||||||
|
}
|
||||||
|
if expectedSequence > math.MaxUint64-entryCount {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: next sequence overflows uint64: expected sequence %d entry count %d", expectedSequence, batch.EntryCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
actions := make([]replayAction, 0, batch.EntryCount)
|
||||||
|
offset := 0
|
||||||
|
for i := range entryCount {
|
||||||
|
entry, consumed, err := DecodeEntry(batch.Entries[offset:])
|
||||||
|
if err != nil {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: decode batch entry: %w", err)
|
||||||
|
}
|
||||||
|
if consumed <= 0 {
|
||||||
|
return expectedSequence, errors.New("wal: decoded batch entry consumed no bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
sequence := batch.BaseSequence + i
|
||||||
|
actions = append(actions, replayAction{
|
||||||
|
opType: entry.OpType,
|
||||||
|
key: entry.Key,
|
||||||
|
value: entry.Value,
|
||||||
|
sequence: sequence,
|
||||||
|
})
|
||||||
|
offset += consumed
|
||||||
|
}
|
||||||
|
|
||||||
|
if offset != len(batch.Entries) {
|
||||||
|
return expectedSequence, fmt.Errorf("wal: batch entries contain trailing bytes: parsed %d of %d", offset, len(batch.Entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, action := range actions {
|
||||||
|
switch action.opType {
|
||||||
|
case OpPut:
|
||||||
|
replayer.ReplayPut(action.key, action.value, action.sequence)
|
||||||
|
case OpDelete:
|
||||||
|
replayer.ReplayDelete(action.key, action.sequence)
|
||||||
|
default:
|
||||||
|
return expectedSequence, fmt.Errorf("wal: invalid op type %d", action.opType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expectedSequence + entryCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaySegmentFile replays all complete WAL batches from one segment file.
|
||||||
|
//
|
||||||
|
// isLastSegment controls how parse errors and CollectingFragments-at-end are
|
||||||
|
// classified per design §3.2 line 704:
|
||||||
|
// - true: tail corruption (TailCorruptionError, truncatable by Recover)
|
||||||
|
// - false: hard corruption (plain error, Recover must hard-fail)
|
||||||
|
func ReplaySegmentFile(filePath string, startSequence uint64, isLastSegment bool, replayer BatchReplayer) (nextSequence uint64, err error) {
|
||||||
|
nextSequence = startSequence
|
||||||
|
records, parseErr := ParseRecordsFromFile(filePath)
|
||||||
|
if parseErr != nil && !IsTailCorruption(parseErr) {
|
||||||
|
return nextSequence, fmt.Errorf("wal: parse segment records: %w", parseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach SegmentPath to parseErr for downstream diagnostics + truncation target.
|
||||||
|
if parseErr != nil {
|
||||||
|
var tce *TailCorruptionError
|
||||||
|
if errors.As(parseErr, &tce) {
|
||||||
|
tce.SegmentPath = filePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// C4 fix: tail corruption in non-last segment is hard corruption per
|
||||||
|
// design §3.2 line 704. Use %v (NOT %w) so IsTailCorruption returns false
|
||||||
|
// — otherwise errors.As would still find the underlying *TailCorruptionError
|
||||||
|
// through the %w chain and Recover would treat it as truncatable.
|
||||||
|
if parseErr != nil && !isLastSegment {
|
||||||
|
return nextSequence, fmt.Errorf("wal: corruption in non-last segment %s (hard corruption): %v",
|
||||||
|
filePath, parseErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
collector := NewFragmentCollector()
|
||||||
|
for _, record := range records {
|
||||||
|
if err := collector.Append(record.Type, record.Payload); err != nil {
|
||||||
|
return nextSequence, fmt.Errorf("wal: collect segment fragments: %w", err)
|
||||||
|
}
|
||||||
|
if !collector.IsComplete() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
batch, err := DecodeWalBatch(collector.BatchData())
|
||||||
|
if err != nil {
|
||||||
|
return nextSequence, fmt.Errorf("wal: decode recovered batch: %w", err)
|
||||||
|
}
|
||||||
|
nextSequence, err = ReplayBatch(batch, nextSequence, replayer)
|
||||||
|
if err != nil {
|
||||||
|
return nextSequence, fmt.Errorf("wal: replay recovered batch: %w", err)
|
||||||
|
}
|
||||||
|
collector.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
if parseErr != nil {
|
||||||
|
return nextSequence, parseErr
|
||||||
|
}
|
||||||
|
if collector.State() == FragmentCollecting {
|
||||||
|
if isLastSegment {
|
||||||
|
return nextSequence, &TailCorruptionError{
|
||||||
|
Offset: 0,
|
||||||
|
SegmentPath: filePath,
|
||||||
|
Err: errors.New("incomplete fragmented batch at segment tail"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Non-last segment with incomplete fragments = middle corruption.
|
||||||
|
// Plain error (no TailCorruptionError) so IsTailCorruption is false.
|
||||||
|
return nextSequence, fmt.Errorf("wal: incomplete fragmented batch in non-last segment %s (hard corruption)", filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextSequence, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecoverFromSegments scans and replays WAL segments from recoverySegmentID.
|
||||||
|
func RecoverFromSegments(dir string, recoverySegmentID uint64, replayer BatchReplayer) (nextSequence uint64, err error) {
|
||||||
|
segments, err := ScanSegments(dir, recoverySegmentID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("wal: scan recovery segments: %w", err)
|
||||||
|
}
|
||||||
|
if len(segments) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSequence = segments[0].StartSequence
|
||||||
|
for i, segment := range segments {
|
||||||
|
if segment.StartSequence != nextSequence {
|
||||||
|
return nextSequence, fmt.Errorf("wal: segment start sequence %d does not match expected sequence %d", segment.StartSequence, nextSequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
isLastSegment := i == len(segments)-1
|
||||||
|
nextSequence, err = ReplaySegmentFile(segment.FilePath, nextSequence, isLastSegment, replayer)
|
||||||
|
if err != nil {
|
||||||
|
if IsTailCorruption(err) {
|
||||||
|
return nextSequence, err
|
||||||
|
}
|
||||||
|
return nextSequence, fmt.Errorf("wal: replay segment: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextSequence, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dailz/go-kv/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockReplayer struct {
|
||||||
|
puts []replayPut
|
||||||
|
deletes []replayDelete
|
||||||
|
}
|
||||||
|
|
||||||
|
type replayPut struct {
|
||||||
|
key string
|
||||||
|
value string
|
||||||
|
seq uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
type replayDelete struct {
|
||||||
|
key string
|
||||||
|
seq uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockReplayer) ReplayPut(key, value []byte, sequence uint64) {
|
||||||
|
m.puts = append(m.puts, replayPut{key: string(key), value: string(value), seq: sequence})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockReplayer) ReplayDelete(key []byte, sequence uint64) {
|
||||||
|
m.deletes = append(m.deletes, replayDelete{key: string(key), seq: sequence})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplayBatchValid(t *testing.T) {
|
||||||
|
encoded, err := EncodeWalBatch(100, []*WalEntry{
|
||||||
|
makePutEntry("alpha", "one"),
|
||||||
|
makeDeleteEntry("beta"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
batch, err := DecodeWalBatch(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
next, err := ReplayBatch(batch, 100, replayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReplayBatch: %v", err)
|
||||||
|
}
|
||||||
|
if next != 102 {
|
||||||
|
t.Fatalf("nextSequence = %d, want 102", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPuts := []replayPut{{key: "alpha", value: "one", seq: 100}}
|
||||||
|
if !reflect.DeepEqual(replayer.puts, wantPuts) {
|
||||||
|
t.Fatalf("puts = %#v, want %#v", replayer.puts, wantPuts)
|
||||||
|
}
|
||||||
|
wantDeletes := []replayDelete{{key: "beta", seq: 101}}
|
||||||
|
if !reflect.DeepEqual(replayer.deletes, wantDeletes) {
|
||||||
|
t.Fatalf("deletes = %#v, want %#v", replayer.deletes, wantDeletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplayBatchSequenceMismatch(t *testing.T) {
|
||||||
|
batch := mustDecodeTestBatch(t, 10, []*WalEntry{makePutEntry("k", "v")})
|
||||||
|
|
||||||
|
_, err := ReplayBatch(batch, 11, &mockReplayer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ReplayBatch succeeded, want sequence mismatch error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "base sequence") {
|
||||||
|
t.Fatalf("error = %v, want base sequence context", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplayBatchZeroEntries(t *testing.T) {
|
||||||
|
batch := &WalBatch{
|
||||||
|
Flags: 0,
|
||||||
|
BaseSequence: 10,
|
||||||
|
EntryCount: 0,
|
||||||
|
EntriesSize: 0,
|
||||||
|
Entries: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ReplayBatch(batch, 10, &mockReplayer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ReplayBatch succeeded, want zero entries error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplayBatchOverflowCheck(t *testing.T) {
|
||||||
|
entryBytes, err := EncodeEntry(makePutEntry("k", "v"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeEntry: %v", err)
|
||||||
|
}
|
||||||
|
batch := &WalBatch{
|
||||||
|
Flags: 0,
|
||||||
|
BaseSequence: math.MaxUint64 - 1,
|
||||||
|
EntryCount: 3,
|
||||||
|
EntriesSize: uint32(len(entryBytes) * 3),
|
||||||
|
Entries: append(append(append([]byte{}, entryBytes...), entryBytes...), entryBytes...),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ReplayBatch(batch, math.MaxUint64-1, &mockReplayer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ReplayBatch succeeded, want overflow error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaySegmentFileFull(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := writeTestSegment(t, dir, 0, 50, [][]*WalEntry{
|
||||||
|
{makePutEntry("a", "1"), makeDeleteEntry("b")},
|
||||||
|
{makePutEntry("c", "3")},
|
||||||
|
})
|
||||||
|
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
next, err := ReplaySegmentFile(filePath, 50, true, replayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReplaySegmentFile: %v", err)
|
||||||
|
}
|
||||||
|
if next != 53 {
|
||||||
|
t.Fatalf("nextSequence = %d, want 53", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPuts := []replayPut{{key: "a", value: "1", seq: 50}, {key: "c", value: "3", seq: 52}}
|
||||||
|
if !reflect.DeepEqual(replayer.puts, wantPuts) {
|
||||||
|
t.Fatalf("puts = %#v, want %#v", replayer.puts, wantPuts)
|
||||||
|
}
|
||||||
|
wantDeletes := []replayDelete{{key: "b", seq: 51}}
|
||||||
|
if !reflect.DeepEqual(replayer.deletes, wantDeletes) {
|
||||||
|
t.Fatalf("deletes = %#v, want %#v", replayer.deletes, wantDeletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaySegmentFileTailCorruption(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := writeTestSegment(t, dir, 0, 70, [][]*WalEntry{
|
||||||
|
{makePutEntry("ok", "before-corruption")},
|
||||||
|
})
|
||||||
|
appendFileBytes(t, filePath, []byte{0x01, 0x02, 0x03})
|
||||||
|
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
next, err := ReplaySegmentFile(filePath, 70, true, replayer)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ReplaySegmentFile succeeded, want tail corruption error")
|
||||||
|
}
|
||||||
|
if !IsTailCorruption(err) {
|
||||||
|
t.Fatalf("error = %v, want tail corruption", err)
|
||||||
|
}
|
||||||
|
if next != 71 {
|
||||||
|
t.Fatalf("nextSequence = %d, want 71", next)
|
||||||
|
}
|
||||||
|
wantPuts := []replayPut{{key: "ok", value: "before-corruption", seq: 70}}
|
||||||
|
if !reflect.DeepEqual(replayer.puts, wantPuts) {
|
||||||
|
t.Fatalf("puts = %#v, want %#v", replayer.puts, wantPuts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecoverFromSegmentsMultiple(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeTestSegment(t, dir, 0, 10, [][]*WalEntry{
|
||||||
|
{makePutEntry("s0-a", "a"), makeDeleteEntry("s0-b")},
|
||||||
|
})
|
||||||
|
writeTestSegment(t, dir, 1, 12, [][]*WalEntry{
|
||||||
|
{makePutEntry("s1-c", "c")},
|
||||||
|
{makeDeleteEntry("s1-d")},
|
||||||
|
})
|
||||||
|
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
next, err := RecoverFromSegments(dir, 0, replayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecoverFromSegments: %v", err)
|
||||||
|
}
|
||||||
|
if next != 14 {
|
||||||
|
t.Fatalf("nextSequence = %d, want 14", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantPuts := []replayPut{{key: "s0-a", value: "a", seq: 10}, {key: "s1-c", value: "c", seq: 12}}
|
||||||
|
if !reflect.DeepEqual(replayer.puts, wantPuts) {
|
||||||
|
t.Fatalf("puts = %#v, want %#v", replayer.puts, wantPuts)
|
||||||
|
}
|
||||||
|
wantDeletes := []replayDelete{{key: "s0-b", seq: 11}, {key: "s1-d", seq: 13}}
|
||||||
|
if !reflect.DeepEqual(replayer.deletes, wantDeletes) {
|
||||||
|
t.Fatalf("deletes = %#v, want %#v", replayer.deletes, wantDeletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustDecodeTestBatch(t *testing.T, baseSequence uint64, entries []*WalEntry) *WalBatch {
|
||||||
|
t.Helper()
|
||||||
|
encoded, err := EncodeWalBatch(baseSequence, entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
batch, err := DecodeWalBatch(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestSegment(t *testing.T, dir string, segmentID, startSequence uint64, batches [][]*WalEntry) string {
|
||||||
|
t.Helper()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
sw, err := NewSegmentWriter(dir, segmentID, startSequence, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentWriter: %v", err)
|
||||||
|
}
|
||||||
|
closed := false
|
||||||
|
defer func() {
|
||||||
|
if !closed {
|
||||||
|
if closeErr := sw.Close(); closeErr != nil && !errors.Is(closeErr, os.ErrClosed) {
|
||||||
|
t.Fatalf("SegmentWriter.Close cleanup: %v", closeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
nextSequence := startSequence
|
||||||
|
for i, entries := range batches {
|
||||||
|
encoded, err := EncodeWalBatch(nextSequence, entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch[%d]: %v", i, err)
|
||||||
|
}
|
||||||
|
if err := sw.AppendBatch(encoded); err != nil {
|
||||||
|
t.Fatalf("AppendBatch[%d]: %v", i, err)
|
||||||
|
}
|
||||||
|
nextSequence += uint64(len(entries))
|
||||||
|
}
|
||||||
|
if err := sw.Close(); err != nil {
|
||||||
|
t.Fatalf("SegmentWriter.Close: %v", err)
|
||||||
|
}
|
||||||
|
closed = true
|
||||||
|
return sw.SegmentPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendFileBytes(t *testing.T, filePath string, data []byte) {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.OpenFile(filePath, os.O_WRONLY|os.O_APPEND, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenFile append: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := f.Write(data); err != nil {
|
||||||
|
t.Fatalf("Write corruption bytes: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- C4 regression guards: isLastSegment controls corruption classification --------
|
||||||
|
|
||||||
|
// Regression guards for C4: isLastSegment controls whether CollectingFragments
|
||||||
|
// at end is tail corruption (truncatable) or hard corruption (must hard-fail).
|
||||||
|
|
||||||
|
func TestReplaySegmentFile_LastSegmentCollectingFragmentsIsTailCorruption(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := filepath.Join(dir, "segment-0.wal")
|
||||||
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||||
|
{makePutEntry("k", "v")},
|
||||||
|
})
|
||||||
|
// Append First + Middle (no Last) to leave collector in Collecting state.
|
||||||
|
appendFileBytes(t, filePath, EncodePhysicalRecord(RecFirst, []byte("first")))
|
||||||
|
appendFileBytes(t, filePath, EncodePhysicalRecord(RecMiddle, []byte("middle")))
|
||||||
|
|
||||||
|
_, err := ReplaySegmentFile(filePath, 0, true, &mockReplayer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !IsTailCorruption(err) {
|
||||||
|
t.Errorf("expected TailCorruptionError for last segment, got: %v", err)
|
||||||
|
}
|
||||||
|
var tce *TailCorruptionError
|
||||||
|
if errors.As(err, &tce) {
|
||||||
|
if tce.SegmentPath != filePath {
|
||||||
|
t.Errorf("SegmentPath = %q, want %q", tce.SegmentPath, filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaySegmentFile_NonLastSegmentCollectingFragmentsIsHardError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := filepath.Join(dir, "segment-0.wal")
|
||||||
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||||
|
{makePutEntry("k", "v")},
|
||||||
|
})
|
||||||
|
appendFileBytes(t, filePath, EncodePhysicalRecord(RecFirst, []byte("first")))
|
||||||
|
appendFileBytes(t, filePath, EncodePhysicalRecord(RecMiddle, []byte("middle")))
|
||||||
|
|
||||||
|
_, err := ReplaySegmentFile(filePath, 0, false, &mockReplayer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if IsTailCorruption(err) {
|
||||||
|
t.Errorf("expected HARD error for non-last segment, got TailCorruptionError: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaySegmentFile_LastSegmentParseErrorIsTailCorruption(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := filepath.Join(dir, "segment-0.wal")
|
||||||
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||||
|
{makePutEntry("k", "v")},
|
||||||
|
})
|
||||||
|
// Append corrupted bytes to trigger ParseBlock's CRC failure path.
|
||||||
|
appendFileBytes(t, filePath, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF})
|
||||||
|
|
||||||
|
_, err := ReplaySegmentFile(filePath, 0, true, &mockReplayer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !IsTailCorruption(err) {
|
||||||
|
t.Errorf("expected TailCorruptionError for last segment parse error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaySegmentFile_NonLastSegmentParseErrorIsHardError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
filePath := filepath.Join(dir, "segment-0.wal")
|
||||||
|
writeTestSegment(t, dir, 0, 0, [][]*WalEntry{
|
||||||
|
{makePutEntry("k", "v")},
|
||||||
|
})
|
||||||
|
appendFileBytes(t, filePath, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF})
|
||||||
|
|
||||||
|
_, err := ReplaySegmentFile(filePath, 0, false, &mockReplayer{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
// Per C4 fix: non-last segment parser corruption is hard error (NOT TailCorruption).
|
||||||
|
// Implemented via %v (not %w) so errors.As cannot find underlying TailCorruptionError.
|
||||||
|
if IsTailCorruption(err) {
|
||||||
|
t.Errorf("expected HARD error for non-last segment parse error, got TailCorruptionError: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SegmentInfo holds metadata about a WAL segment file.
|
||||||
|
type SegmentInfo struct {
|
||||||
|
FilePath string
|
||||||
|
SegmentID uint64
|
||||||
|
StartSequence uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSegmentFilename extracts the segment ID from a filename matching the
|
||||||
|
// pattern "segment-{N}.wal". Returns (N, true) on match, (0, false) otherwise.
|
||||||
|
func ParseSegmentFilename(name string) (segmentID uint64, ok bool) {
|
||||||
|
if !strings.HasPrefix(name, "segment-") || !strings.HasSuffix(name, ".wal") {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
// Strip "segment-" prefix and ".wal" suffix.
|
||||||
|
middle := name[len("segment-") : len(name)-len(".wal")]
|
||||||
|
if len(middle) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(middle, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrSegmentGap indicates missing WAL segment(s) in the expected sequence.
|
||||||
|
ErrSegmentGap = errors.New("wal: gap detected in segment sequence")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScanSegments discovers WAL segment files in dir, filters those with
|
||||||
|
// SegmentID >= recoverySegmentID, validates file headers, and returns them
|
||||||
|
// sorted by SegmentID ascending. Returns an error if a gap is detected in the
|
||||||
|
// segment ID sequence.
|
||||||
|
func ScanSegments(dir string, recoverySegmentID uint64) ([]*SegmentInfo, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("wal: scan segments: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates []*SegmentInfo
|
||||||
|
for _, ent := range entries {
|
||||||
|
if ent.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
segID, ok := ParseSegmentFilename(ent.Name())
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if segID < recoverySegmentID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidates = append(candidates, &SegmentInfo{
|
||||||
|
FilePath: filepath.Join(dir, ent.Name()),
|
||||||
|
SegmentID: segID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by SegmentID ascending.
|
||||||
|
sort.Slice(candidates, func(i, j int) bool {
|
||||||
|
return candidates[i].SegmentID < candidates[j].SegmentID
|
||||||
|
})
|
||||||
|
|
||||||
|
// Decode each file header to populate StartSequence and validate.
|
||||||
|
for _, si := range candidates {
|
||||||
|
hdr, err := readFileHeader(si.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("wal: segment %d: %w", si.SegmentID, err)
|
||||||
|
}
|
||||||
|
si.StartSequence = hdr.StartSequence
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify continuity: segment IDs must form a consecutive sequence
|
||||||
|
// starting from recoverySegmentID.
|
||||||
|
for i, si := range candidates {
|
||||||
|
expected := recoverySegmentID + uint64(i)
|
||||||
|
if si.SegmentID != expected {
|
||||||
|
return nil, fmt.Errorf("%w: expected segment %d, found %d",
|
||||||
|
ErrSegmentGap, expected, si.SegmentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFileHeader opens the file, reads the header portion, and decodes it.
|
||||||
|
func readFileHeader(path string) (*WalFileHeader, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
hdrBuf := make([]byte, WalFileHeaderSize)
|
||||||
|
n, err := f.Read(hdrBuf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read header: %w", err)
|
||||||
|
}
|
||||||
|
if n < WalFileHeaderSize {
|
||||||
|
return nil, fmt.Errorf("read header: got %d bytes, need %d", n, WalFileHeaderSize)
|
||||||
|
}
|
||||||
|
return DecodeWalHeader(hdrBuf)
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeSegmentFile creates a minimal WAL segment file in dir with the given
|
||||||
|
// segment ID and start sequence.
|
||||||
|
func writeSegmentFile(t *testing.T, dir string, segmentID, startSequence uint64) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, fmt.Sprintf("segment-%d.wal", segmentID))
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create segment file: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
hdr := &WalFileHeader{
|
||||||
|
SegmentID: segmentID,
|
||||||
|
StartSequence: startSequence,
|
||||||
|
BlockSize: WalBlockSize,
|
||||||
|
}
|
||||||
|
encoded := EncodeWalHeader(hdr)
|
||||||
|
if _, err := f.Write(encoded[:]); err != nil {
|
||||||
|
t.Fatalf("write header: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSegmentFilename(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
wantID uint64
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"segment-0.wal", 0, true},
|
||||||
|
{"segment-1.wal", 1, true},
|
||||||
|
{"segment-42.wal", 42, true},
|
||||||
|
{"segment-18446744073709551615.wal", 18446744073709551615, true},
|
||||||
|
{"segment-.wal", 0, false}, // no digits
|
||||||
|
{"segment-abc.wal", 0, false}, // non-numeric
|
||||||
|
{"segment-1.wal.bak", 0, false}, // extra suffix
|
||||||
|
{"foo-segment-1.wal", 0, false}, // extra prefix
|
||||||
|
{" Segment-1.wal", 0, false}, // space prefix
|
||||||
|
{"segment--1.wal", 0, false}, // negative sign
|
||||||
|
{"", 0, false},
|
||||||
|
{"readme.md", 0, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gotID, gotOK := ParseSegmentFilename(tt.name)
|
||||||
|
if gotOK != tt.wantOK {
|
||||||
|
t.Errorf("ParseSegmentFilename(%q) ok = %v, want %v", tt.name, gotOK, tt.wantOK)
|
||||||
|
}
|
||||||
|
if gotOK && gotID != tt.wantID {
|
||||||
|
t.Errorf("ParseSegmentFilename(%q) id = %d, want %d", tt.name, gotID, tt.wantID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanSegmentsOrdered(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeSegmentFile(t, dir, 0, 100)
|
||||||
|
writeSegmentFile(t, dir, 1, 200)
|
||||||
|
writeSegmentFile(t, dir, 2, 300)
|
||||||
|
|
||||||
|
segs, err := ScanSegments(dir, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ScanSegments: %v", err)
|
||||||
|
}
|
||||||
|
if len(segs) != 3 {
|
||||||
|
t.Fatalf("expected 3 segments, got %d", len(segs))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, want := range []struct {
|
||||||
|
id uint64
|
||||||
|
seq uint64
|
||||||
|
}{
|
||||||
|
{0, 100}, {1, 200}, {2, 300},
|
||||||
|
} {
|
||||||
|
if segs[i].SegmentID != want.id {
|
||||||
|
t.Errorf("seg[%d].SegmentID = %d, want %d", i, segs[i].SegmentID, want.id)
|
||||||
|
}
|
||||||
|
if segs[i].StartSequence != want.seq {
|
||||||
|
t.Errorf("seg[%d].StartSequence = %d, want %d", i, segs[i].StartSequence, want.seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanSegmentsFiltersBelowRecoveryID(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for i := uint64(0); i < 5; i++ {
|
||||||
|
writeSegmentFile(t, dir, i, i*100)
|
||||||
|
}
|
||||||
|
|
||||||
|
segs, err := ScanSegments(dir, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ScanSegments: %v", err)
|
||||||
|
}
|
||||||
|
if len(segs) != 3 {
|
||||||
|
t.Fatalf("expected 3 segments, got %d", len(segs))
|
||||||
|
}
|
||||||
|
|
||||||
|
wantIDs := []uint64{2, 3, 4}
|
||||||
|
for i, s := range segs {
|
||||||
|
if s.SegmentID != wantIDs[i] {
|
||||||
|
t.Errorf("seg[%d].SegmentID = %d, want %d", i, s.SegmentID, wantIDs[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanSegmentsGapDetection(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeSegmentFile(t, dir, 0, 0)
|
||||||
|
writeSegmentFile(t, dir, 1, 100)
|
||||||
|
// segment 2 is missing
|
||||||
|
writeSegmentFile(t, dir, 3, 300)
|
||||||
|
|
||||||
|
_, err := ScanSegments(dir, 0)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for gap in segment sequence")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrSegmentGap) {
|
||||||
|
t.Errorf("expected ErrSegmentGap, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanSegmentsEmpty(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
segs, err := ScanSegments(dir, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ScanSegments empty dir: %v", err)
|
||||||
|
}
|
||||||
|
if len(segs) != 0 {
|
||||||
|
t.Fatalf("expected 0 segments, got %d", len(segs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanSegmentsIgnoresNonWalFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeSegmentFile(t, dir, 0, 0)
|
||||||
|
// Create non-WAL files that should be ignored.
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "segment-abc.wal"), []byte("nope"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
segs, err := ScanSegments(dir, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ScanSegments: %v", err)
|
||||||
|
}
|
||||||
|
if len(segs) != 1 {
|
||||||
|
t.Fatalf("expected 1 segment, got %d", len(segs))
|
||||||
|
}
|
||||||
|
if segs[0].SegmentID != 0 {
|
||||||
|
t.Errorf("SegmentID = %d, want 0", segs[0].SegmentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/dailz/go-kv/config"
|
||||||
|
"github.com/dailz/go-kv/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SegmentManager manages the lifecycle of WAL segment files, handling segment
|
||||||
|
// rotation when the active segment runs out of payload capacity. A batch is
|
||||||
|
// never split across segments — if it doesn't fit, a new segment is created
|
||||||
|
// first and the entire batch is written there.
|
||||||
|
type SegmentManager struct {
|
||||||
|
dir string
|
||||||
|
cfg *config.WalConfig
|
||||||
|
active *SegmentWriter // currently active segment writer
|
||||||
|
nextSegID uint64 // next segment ID to allocate
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSegmentManager creates a new SegmentManager and its first segment file.
|
||||||
|
// It creates the directory if needed, writes the initial segment, and updates
|
||||||
|
// the CURRENT file to point to it.
|
||||||
|
func NewSegmentManager(
|
||||||
|
dir string,
|
||||||
|
startSegmentID uint64,
|
||||||
|
startSequence uint64,
|
||||||
|
cfg *config.WalConfig,
|
||||||
|
) (*SegmentManager, error) {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("wal: create segment directory %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sw, err := NewSegmentWriter(dir, startSegmentID, startSequence, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("wal: create initial segment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sm := &SegmentManager{
|
||||||
|
dir: dir,
|
||||||
|
cfg: cfg,
|
||||||
|
active: sw,
|
||||||
|
nextSegID: startSegmentID + 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort CURRENT file update.
|
||||||
|
_ = manifest.WriteCurrent(dir, sw.SegmentID())
|
||||||
|
|
||||||
|
return sm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendBatch writes encodedBatch to the active segment, rotating first if
|
||||||
|
// the batch doesn't fit. batchStartSequence is the sequence number of the
|
||||||
|
// FIRST entry in this batch — used as the new segment's startSequence when
|
||||||
|
// rotation occurs, so multi-segment recovery's continuity check passes per
|
||||||
|
// design §3.2 line 639-663.
|
||||||
|
func (sm *SegmentManager) AppendBatch(encodedBatch []byte, batchStartSequence uint64) error {
|
||||||
|
worstCaseSize := uint64(len(encodedBatch)) + uint64(PhysicalRecordHeaderSize) + uint64(PhysicalRecordHeaderSize)
|
||||||
|
|
||||||
|
if sm.active.RemainingPayload() < worstCaseSize {
|
||||||
|
// C8 fix: new segment's first batch is THIS batch, so its
|
||||||
|
// startSequence must equal batchStartSequence (not byte offset).
|
||||||
|
if err := sm.rotate(batchStartSequence); err != nil {
|
||||||
|
return fmt.Errorf("wal: rotate segment: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sm.active.AppendBatch(encodedBatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotate closes the current segment and creates a new one. The CURRENT file is
|
||||||
|
// updated on a best-effort basis — a failure is logged but does not prevent
|
||||||
|
// the rotation from succeeding.
|
||||||
|
func (sm *SegmentManager) rotate(newStartSequence uint64) error {
|
||||||
|
if err := sm.active.Close(); err != nil {
|
||||||
|
return fmt.Errorf("wal: close segment %d: %w", sm.active.SegmentID(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sw, err := NewSegmentWriter(sm.dir, sm.nextSegID, newStartSequence, sm.cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wal: create segment %d: %w", sm.nextSegID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.nextSegID++
|
||||||
|
sm.active = sw
|
||||||
|
|
||||||
|
// Best-effort CURRENT file update — failure must not fail the write.
|
||||||
|
_ = manifest.WriteCurrent(sm.dir, sw.SegmentID())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveSegmentID returns the segment ID of the currently active segment.
|
||||||
|
func (sm *SegmentManager) ActiveSegmentID() uint64 {
|
||||||
|
return sm.active.SegmentID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemainingPayload returns the number of bytes that can still be written to
|
||||||
|
// the active segment.
|
||||||
|
func (sm *SegmentManager) RemainingPayload() uint64 {
|
||||||
|
return sm.active.RemainingPayload()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync flushes the active segment to durable storage.
|
||||||
|
func (sm *SegmentManager) Sync() error {
|
||||||
|
return sm.active.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close flushes and closes the active segment.
|
||||||
|
func (sm *SegmentManager) Close() error {
|
||||||
|
return sm.active.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dailz/go-kv/config"
|
||||||
|
"github.com/dailz/go-kv/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tinyWalConfig returns a config with a very small MaxSegmentSize to force
|
||||||
|
// quick rotation. The minimum is derived from config.Validate: we need enough
|
||||||
|
// room for the file header, batch header, physical record overhead, and block
|
||||||
|
// padding. We use 256 bytes which is well above the minimum for default
|
||||||
|
// block/batch settings.
|
||||||
|
func tinyWalConfig() *config.WalConfig {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
// Use a small segment size to force rotation quickly.
|
||||||
|
// MaxSegmentSize must be > WalFileHeaderSize (32) and pass Validate().
|
||||||
|
// With defaults, minimum is around 4MB+overhead, so we must also reduce
|
||||||
|
// MaxBatchSize and BlockSize to make a small segment valid.
|
||||||
|
cfg.BlockSize = 512
|
||||||
|
cfg.MaxBatchSize = 64 // very small batches
|
||||||
|
cfg.MaxBatchEntries = 5
|
||||||
|
cfg.MaxKeyBytes = 16
|
||||||
|
cfg.MaxInlineValue = 16
|
||||||
|
cfg.MaxSegmentSize = 512 // small enough to trigger rotation with a few writes
|
||||||
|
return &cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSegmentManagerCreation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := testWalConfig()
|
||||||
|
|
||||||
|
sm, err := NewSegmentManager(dir, 0, 1, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentManager: %v", err)
|
||||||
|
}
|
||||||
|
defer sm.Close()
|
||||||
|
|
||||||
|
// Verify segment-0.wal exists.
|
||||||
|
expected := filepath.Join(dir, "segment-0.wal")
|
||||||
|
if _, err := os.Stat(expected); err != nil {
|
||||||
|
t.Errorf("segment file %q should exist: %v", expected, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sm.ActiveSegmentID() != 0 {
|
||||||
|
t.Errorf("ActiveSegmentID = %d, want 0", sm.ActiveSegmentID())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify CURRENT file points to segment-0.
|
||||||
|
segID, ok := manifest.ReadCurrent(dir)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ReadCurrent: expected CURRENT file to exist")
|
||||||
|
}
|
||||||
|
if segID != 0 {
|
||||||
|
t.Errorf("CURRENT segment ID = %d, want 0", segID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSegmentManagerRotation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := tinyWalConfig()
|
||||||
|
|
||||||
|
sm, err := NewSegmentManager(dir, 0, 1, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentManager: %v", err)
|
||||||
|
}
|
||||||
|
defer sm.Close()
|
||||||
|
|
||||||
|
// Write small batches until rotation occurs.
|
||||||
|
// Each batch is a minimal encoded WAL batch: just a small payload.
|
||||||
|
// We'll write enough to exhaust the tiny segment.
|
||||||
|
batch := make([]byte, 32) // 32-byte dummy batch
|
||||||
|
for i := range batch {
|
||||||
|
batch[i] = byte(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write until we rotate past segment 0.
|
||||||
|
currentSeq := uint64(1) // matches NewSegmentManager startSequence above
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if err := sm.AppendBatch(batch, currentSeq); err != nil {
|
||||||
|
t.Fatalf("AppendBatch %d: %v", i, err)
|
||||||
|
}
|
||||||
|
currentSeq++
|
||||||
|
}
|
||||||
|
|
||||||
|
// After many writes, we should have rotated to a higher segment.
|
||||||
|
if sm.ActiveSegmentID() == 0 {
|
||||||
|
t.Error("expected segment rotation, but still on segment 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that segment-1.wal (or higher) exists on disk.
|
||||||
|
segment1Path := filepath.Join(dir, "segment-1.wal")
|
||||||
|
if _, err := os.Stat(segment1Path); err != nil {
|
||||||
|
t.Errorf("segment-1.wal should exist after rotation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSegmentManagerBatchNotSplit(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := tinyWalConfig()
|
||||||
|
|
||||||
|
sm, err := NewSegmentManager(dir, 0, 1, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentManager: %v", err)
|
||||||
|
}
|
||||||
|
defer sm.Close()
|
||||||
|
|
||||||
|
// Fill segment 0 until it's nearly full.
|
||||||
|
smallBatch := make([]byte, 16)
|
||||||
|
for i := range smallBatch {
|
||||||
|
smallBatch[i] = byte(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write until we're close to rotation threshold.
|
||||||
|
currentSeq := uint64(1) // matches NewSegmentManager startSequence above
|
||||||
|
for sm.RemainingPayload() > 256 {
|
||||||
|
if err := sm.AppendBatch(smallBatch, currentSeq); err != nil {
|
||||||
|
t.Fatalf("AppendBatch small: %v", err)
|
||||||
|
}
|
||||||
|
currentSeq++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now write a batch that triggers rotation.
|
||||||
|
// This batch must go entirely into the new segment.
|
||||||
|
triggerBatch := make([]byte, 128)
|
||||||
|
for i := range triggerBatch {
|
||||||
|
triggerBatch[i] = 0xAA
|
||||||
|
}
|
||||||
|
|
||||||
|
segIDBefore := sm.ActiveSegmentID()
|
||||||
|
if err := sm.AppendBatch(triggerBatch, currentSeq); err != nil {
|
||||||
|
t.Fatalf("AppendBatch trigger: %v", err)
|
||||||
|
}
|
||||||
|
segIDAfter := sm.ActiveSegmentID()
|
||||||
|
|
||||||
|
// The trigger batch should have caused rotation (or the segment was big enough).
|
||||||
|
// If rotation happened, verify the batch is in the new segment.
|
||||||
|
if segIDAfter != segIDBefore {
|
||||||
|
// Rotation occurred — the batch should be in the new segment.
|
||||||
|
// Read the new segment file and verify it contains our trigger data.
|
||||||
|
newSegPath := filepath.Join(dir, fmtSegName(segIDAfter))
|
||||||
|
data, err := os.ReadFile(newSegPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read new segment: %v", err)
|
||||||
|
}
|
||||||
|
// The trigger batch bytes should appear somewhere after the file header.
|
||||||
|
found := false
|
||||||
|
for i := WalFileHeaderSize; i <= len(data)-len(triggerBatch); i++ {
|
||||||
|
if data[i] == 0xAA {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("trigger batch data not found in new segment after rotation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSegmentManagerCurrentFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := tinyWalConfig()
|
||||||
|
|
||||||
|
sm, err := NewSegmentManager(dir, 0, 1, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentManager: %v", err)
|
||||||
|
}
|
||||||
|
defer sm.Close()
|
||||||
|
|
||||||
|
// Initial CURRENT should point to segment 0.
|
||||||
|
segID, ok := manifest.ReadCurrent(dir)
|
||||||
|
if !ok || segID != 0 {
|
||||||
|
t.Fatalf("initial CURRENT: got segment %d, ok=%v, want 0", segID, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write enough to force rotation.
|
||||||
|
batch := make([]byte, 32)
|
||||||
|
currentSeq := uint64(1) // matches NewSegmentManager startSequence above
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if err := sm.AppendBatch(batch, currentSeq); err != nil {
|
||||||
|
t.Fatalf("AppendBatch %d: %v", i, err)
|
||||||
|
}
|
||||||
|
currentSeq++
|
||||||
|
}
|
||||||
|
|
||||||
|
// CURRENT should now point to the active segment.
|
||||||
|
currentSegID, ok := manifest.ReadCurrent(dir)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ReadCurrent after rotation: expected CURRENT file to exist")
|
||||||
|
}
|
||||||
|
if currentSegID != sm.ActiveSegmentID() {
|
||||||
|
t.Errorf("CURRENT segment ID = %d, want %d", currentSegID, sm.ActiveSegmentID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSegmentManagerSync(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := testWalConfig()
|
||||||
|
|
||||||
|
sm, err := NewSegmentManager(dir, 0, 1, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentManager: %v", err)
|
||||||
|
}
|
||||||
|
defer sm.Close()
|
||||||
|
|
||||||
|
if err := sm.Sync(); err != nil {
|
||||||
|
t.Errorf("Sync: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSegmentManagerRemainingPayload(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := testWalConfig()
|
||||||
|
|
||||||
|
sm, err := NewSegmentManager(dir, 0, 1, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentManager: %v", err)
|
||||||
|
}
|
||||||
|
defer sm.Close()
|
||||||
|
|
||||||
|
expected := cfg.MaxSegmentSize - WalFileHeaderSize
|
||||||
|
if got := sm.RemainingPayload(); got != expected {
|
||||||
|
t.Errorf("RemainingPayload = %d, want %d", got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fmtSegName formats a segment filename.
|
||||||
|
func fmtSegName(segID uint64) string {
|
||||||
|
return filepath.Join("", "segment-"+itoa(segID)+".wal")
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n uint64) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C6: NewSegmentManager must propagate dir fsync
|
||||||
|
// failure from initial segment creation. This is the DB.Open failure path.
|
||||||
|
func TestNewSegmentManagerFailsOnDirFsyncFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := tinyWalConfig()
|
||||||
|
|
||||||
|
orig := dirFsyncFn
|
||||||
|
dirFsyncFn = func(string) error { return errors.New("simulated dir fsync failure") }
|
||||||
|
t.Cleanup(func() { dirFsyncFn = orig })
|
||||||
|
|
||||||
|
sm, err := NewSegmentManager(dir, 0, 0, cfg)
|
||||||
|
if err == nil {
|
||||||
|
if sm != nil {
|
||||||
|
sm.Close()
|
||||||
|
}
|
||||||
|
t.Fatal("NewSegmentManager: expected error on dir fsync failure, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "create initial segment") {
|
||||||
|
t.Errorf("error should be wrapped as 'create initial segment', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C6: SegmentManager.AppendBatch must propagate
|
||||||
|
// rotation failure (which now includes dir fsync failure) as error.
|
||||||
|
//
|
||||||
|
// Note: C8 (segment_manager.go:64-66 passes byte offset as startSequence)
|
||||||
|
// makes multi-segment recovery broken, but this test only verifies error
|
||||||
|
// propagation through AppendBatch; it does not exercise recovery.
|
||||||
|
func TestSegmentManagerRotateFailsOnDirFsyncFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := tinyWalConfig()
|
||||||
|
|
||||||
|
sm, err := NewSegmentManager(dir, 0, 0, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentManager: %v", err)
|
||||||
|
}
|
||||||
|
defer sm.Close()
|
||||||
|
|
||||||
|
encoded, err := EncodeWalBatch(0, []*WalEntry{
|
||||||
|
{OpType: OpPut, ValueKind: VKInline, Key: []byte("k"), Value: []byte("v")},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill the active segment until next AppendBatch would trigger rotation.
|
||||||
|
// segment_manager.go:62 triggers rotate when
|
||||||
|
// RemainingPayload() < len(encoded) + 2*PhysicalRecordHeaderSize
|
||||||
|
worstCaseSize := uint64(len(encoded)) + 2*uint64(PhysicalRecordHeaderSize)
|
||||||
|
currentSeq := uint64(0) // matches NewSegmentManager startSequence above
|
||||||
|
for sm.RemainingPayload() >= worstCaseSize {
|
||||||
|
if err := sm.AppendBatch(encoded, currentSeq); err != nil {
|
||||||
|
t.Fatalf("fill AppendBatch: %v", err)
|
||||||
|
}
|
||||||
|
currentSeq++
|
||||||
|
}
|
||||||
|
|
||||||
|
orig := dirFsyncFn
|
||||||
|
dirFsyncFn = func(string) error { return errors.New("simulated dir fsync failure") }
|
||||||
|
t.Cleanup(func() { dirFsyncFn = orig })
|
||||||
|
|
||||||
|
if err := sm.AppendBatch(encoded, currentSeq); err == nil {
|
||||||
|
t.Fatal("AppendBatch: expected rotation failure, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- C8 regression guards --------
|
||||||
|
|
||||||
|
// Regression guard for C8: after rotation, the new segment's header
|
||||||
|
// startSequence must equal the rotated batch's baseSequence, NOT the
|
||||||
|
// previous segment's byte offset.
|
||||||
|
func TestSegmentManagerRotationWritesCorrectStartSequence(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := tinyWalConfig()
|
||||||
|
|
||||||
|
sm, err := NewSegmentManager(dir, 0, 0, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentManager: %v", err)
|
||||||
|
}
|
||||||
|
defer sm.Close()
|
||||||
|
|
||||||
|
var firstRotationSeq uint64
|
||||||
|
var oldOffset uint64
|
||||||
|
var hadRotation bool
|
||||||
|
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
// Snapshot offset + segment ID BEFORE append to capture pre-rotation state.
|
||||||
|
offsetBefore := sm.active.CurrentOffset()
|
||||||
|
segIDBefore := sm.ActiveSegmentID()
|
||||||
|
|
||||||
|
encoded, err := EncodeWalBatch(uint64(i), []*WalEntry{
|
||||||
|
makePutEntry(fmt.Sprintf("k%d", i), "v"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if err := sm.AppendBatch(encoded, uint64(i)); err != nil {
|
||||||
|
t.Fatalf("AppendBatch %d: %v", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hadRotation && sm.ActiveSegmentID() != segIDBefore {
|
||||||
|
hadRotation = true
|
||||||
|
firstRotationSeq = uint64(i)
|
||||||
|
oldOffset = offsetBefore
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hadRotation {
|
||||||
|
t.Skip("no rotation occurred with tinyWalConfig; test setup needs adjustment")
|
||||||
|
}
|
||||||
|
|
||||||
|
seg1Path := filepath.Join(dir, "segment-1.wal")
|
||||||
|
if _, err := os.Stat(seg1Path); err != nil {
|
||||||
|
t.Fatalf("segment-1 should exist after rotation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seg1Header := readSegmentHeader(t, seg1Path)
|
||||||
|
if seg1Header.StartSequence != firstRotationSeq {
|
||||||
|
t.Errorf("segment-1 startSequence = %d, want %d (first rotation sequence)",
|
||||||
|
seg1Header.StartSequence, firstRotationSeq)
|
||||||
|
}
|
||||||
|
if seg1Header.StartSequence == oldOffset {
|
||||||
|
t.Errorf("segment-1 startSequence = %d matches old byte offset (C8 regression)",
|
||||||
|
seg1Header.StartSequence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C8: writing across multiple segments, then closing
|
||||||
|
// and reopening, must successfully recover ALL data. Before C8 fix, the
|
||||||
|
// second segment's startSequence was a byte offset, causing recovery's
|
||||||
|
// continuity check to fail.
|
||||||
|
func TestSegmentManagerMultiSegmentRecoveryRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := tinyWalConfig()
|
||||||
|
|
||||||
|
sm, err := NewSegmentManager(dir, 0, 0, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentManager: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalBatches = 50
|
||||||
|
for i := 0; i < totalBatches; i++ {
|
||||||
|
encoded, err := EncodeWalBatch(uint64(i), []*WalEntry{
|
||||||
|
makePutEntry(fmt.Sprintf("k%d", i), fmt.Sprintf("v%d", i)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if err := sm.AppendBatch(encoded, uint64(i)); err != nil {
|
||||||
|
t.Fatalf("AppendBatch %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sm.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
segCount := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
if _, ok := ParseSegmentFilename(e.Name()); ok {
|
||||||
|
segCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if segCount < 2 {
|
||||||
|
t.Fatalf("expected at least 2 segments after rotation, got %d", segCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
replayer := &mockReplayer{}
|
||||||
|
result, err := Recover(dir, replayer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recover failed (C8 regression): %v", err)
|
||||||
|
}
|
||||||
|
if result.NextSequence != totalBatches {
|
||||||
|
t.Errorf("NextSequence = %d, want %d", result.NextSequence, totalBatches)
|
||||||
|
}
|
||||||
|
if len(replayer.puts) != totalBatches {
|
||||||
|
t.Errorf("replayed puts = %d, want %d", len(replayer.puts), totalBatches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSegmentHeader(t *testing.T, path string) *WalFileHeader {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open %s: %v", path, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
hdrBuf := make([]byte, WalFileHeaderSize)
|
||||||
|
if _, err := io.ReadFull(f, hdrBuf); err != nil {
|
||||||
|
t.Fatalf("read header: %v", err)
|
||||||
|
}
|
||||||
|
hdr, err := DecodeWalHeader(hdrBuf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeWalHeader: %v", err)
|
||||||
|
}
|
||||||
|
return hdr
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/dailz/go-kv/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SegmentWriter handles appending WAL batches to a single segment file.
|
||||||
|
// It manages block-aligned writes via BlockWriter and tracks file offset
|
||||||
|
// for segment rotation decisions.
|
||||||
|
type SegmentWriter struct {
|
||||||
|
fd *os.File
|
||||||
|
dir string
|
||||||
|
cfg *config.WalConfig
|
||||||
|
segmentID uint64
|
||||||
|
startSequence uint64
|
||||||
|
blockWriter *BlockWriter
|
||||||
|
currentOffset uint64 // total bytes written (starts at WalFileHeaderSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSegmentWriter creates a new WAL segment file and writes the file header.
|
||||||
|
// The segment file is created with a .tmp extension, the header is written and
|
||||||
|
// synced, then the file is atomically renamed to its final name and synced again.
|
||||||
|
func NewSegmentWriter(
|
||||||
|
dir string,
|
||||||
|
segmentID uint64,
|
||||||
|
startSequence uint64,
|
||||||
|
cfg *config.WalConfig,
|
||||||
|
) (*SegmentWriter, error) {
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("wal: invalid config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseName := fmt.Sprintf("segment-%d.wal", segmentID)
|
||||||
|
tmpPath := filepath.Join(dir, baseName+".tmp")
|
||||||
|
finalPath := filepath.Join(dir, baseName)
|
||||||
|
|
||||||
|
// Create the temp file.
|
||||||
|
fd, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("wal: create segment temp file %s: %w", tmpPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and write the file header.
|
||||||
|
hdr := &WalFileHeader{
|
||||||
|
BlockSize: cfg.BlockSize,
|
||||||
|
SegmentID: segmentID,
|
||||||
|
StartSequence: startSequence,
|
||||||
|
}
|
||||||
|
encoded := EncodeWalHeader(hdr)
|
||||||
|
|
||||||
|
if _, err := fd.Write(encoded[:]); err != nil {
|
||||||
|
fd.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return nil, fmt.Errorf("wal: write segment header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync the header to disk.
|
||||||
|
if err := fd.Sync(); err != nil {
|
||||||
|
fd.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return nil, fmt.Errorf("wal: sync segment header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically rename temp file to final name.
|
||||||
|
if err := fd.Close(); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return nil, fmt.Errorf("wal: close temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(tmpPath, finalPath); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return nil, fmt.Errorf("wal: rename segment file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the final file for appending.
|
||||||
|
fd, err = os.OpenFile(finalPath, os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("wal: open segment file for append: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per design §3.2 line 258, directory fsync is a hard requirement for
|
||||||
|
// durable-ready. Without it, the rename above is not guaranteed to survive
|
||||||
|
// power loss, violating the Always-mode "no loss of acknowledged writes"
|
||||||
|
// promise.
|
||||||
|
if err := dirFsyncFn(dir); err != nil {
|
||||||
|
closeErr := fd.Close()
|
||||||
|
removeErr := os.Remove(finalPath)
|
||||||
|
if closeErr != nil || removeErr != nil {
|
||||||
|
cleanup := errors.Join(closeErr, removeErr)
|
||||||
|
return nil, fmt.Errorf("wal: fsync directory after segment rename (cleanup: %v): %w", cleanup, err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("wal: fsync directory after segment rename: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SegmentWriter{
|
||||||
|
fd: fd,
|
||||||
|
dir: dir,
|
||||||
|
cfg: cfg,
|
||||||
|
segmentID: segmentID,
|
||||||
|
startSequence: startSequence,
|
||||||
|
blockWriter: NewBlockWriter(),
|
||||||
|
currentOffset: WalFileHeaderSize,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendBatch encodes the batch into physical records and appends them to the
|
||||||
|
// segment file. The encoded batch is split into block-aligned physical records
|
||||||
|
// using SplitIntoRecords.
|
||||||
|
func (sw *SegmentWriter) AppendBatch(encodedBatch []byte) error {
|
||||||
|
records := SplitIntoRecords(encodedBatch)
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rec := range records {
|
||||||
|
if len(rec) < PhysicalRecordHeaderSize {
|
||||||
|
return fmt.Errorf("wal: corrupted physical record: size %d < header size %d",
|
||||||
|
len(rec), PhysicalRecordHeaderSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
recType := rec[6] // type byte is at offset 6 in the encoded record
|
||||||
|
payload := rec[PhysicalRecordHeaderSize:]
|
||||||
|
|
||||||
|
if err := sw.blockWriter.WriteRecord(recType, payload, sw.fd); err != nil {
|
||||||
|
return fmt.Errorf("wal: writing physical record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.currentOffset += uint64(len(rec))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync flushes the segment file to durable storage.
|
||||||
|
func (sw *SegmentWriter) Sync() error {
|
||||||
|
if err := sw.blockWriter.Flush(sw.fd); err != nil {
|
||||||
|
return fmt.Errorf("wal: flushing partial block before sync: %w", err)
|
||||||
|
}
|
||||||
|
return sw.fd.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close flushes any partial block and closes the segment file.
|
||||||
|
func (sw *SegmentWriter) Close() error {
|
||||||
|
if err := sw.blockWriter.Flush(sw.fd); err != nil {
|
||||||
|
return fmt.Errorf("wal: flushing block writer on close: %w", err)
|
||||||
|
}
|
||||||
|
return sw.fd.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemainingPayload returns the number of bytes that can still be written
|
||||||
|
// to this segment before it reaches its maximum size.
|
||||||
|
func (sw *SegmentWriter) RemainingPayload() uint64 {
|
||||||
|
if sw.currentOffset >= sw.cfg.MaxSegmentSize {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return sw.cfg.MaxSegmentSize - sw.currentOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentOffset returns the total number of bytes written to the segment file,
|
||||||
|
// including the file header.
|
||||||
|
func (sw *SegmentWriter) CurrentOffset() uint64 {
|
||||||
|
return sw.currentOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
// SegmentID returns the segment identifier.
|
||||||
|
func (sw *SegmentWriter) SegmentID() uint64 {
|
||||||
|
return sw.segmentID
|
||||||
|
}
|
||||||
|
|
||||||
|
// SegmentPath returns the full filesystem path to the segment file.
|
||||||
|
func (sw *SegmentWriter) SegmentPath() string {
|
||||||
|
return filepath.Join(sw.dir, fmt.Sprintf("segment-%d.wal", sw.segmentID))
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dailz/go-kv/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testWalConfig() *config.WalConfig {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
return &cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSegmentWriterCreation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := testWalConfig()
|
||||||
|
|
||||||
|
sw, err := NewSegmentWriter(dir, 1, 100, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentWriter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedPath := filepath.Join(dir, "segment-1.wal")
|
||||||
|
if sw.SegmentPath() != expectedPath {
|
||||||
|
t.Errorf("SegmentPath = %q, want %q", sw.SegmentPath(), expectedPath)
|
||||||
|
}
|
||||||
|
if sw.SegmentID() != 1 {
|
||||||
|
t.Errorf("SegmentID = %d, want 1", sw.SegmentID())
|
||||||
|
}
|
||||||
|
if sw.CurrentOffset() != WalFileHeaderSize {
|
||||||
|
t.Errorf("CurrentOffset = %d, want %d", sw.CurrentOffset(), WalFileHeaderSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the file exists and has correct header.
|
||||||
|
data, err := os.ReadFile(expectedPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile: %v", err)
|
||||||
|
}
|
||||||
|
if len(data) != WalFileHeaderSize {
|
||||||
|
t.Errorf("file size = %d, want %d (header only)", len(data), WalFileHeaderSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdr, err := DecodeWalHeader(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeWalHeader: %v", err)
|
||||||
|
}
|
||||||
|
if hdr.SegmentID != 1 {
|
||||||
|
t.Errorf("header SegmentID = %d, want 1", hdr.SegmentID)
|
||||||
|
}
|
||||||
|
if hdr.StartSequence != 100 {
|
||||||
|
t.Errorf("header StartSequence = %d, want 100", hdr.StartSequence)
|
||||||
|
}
|
||||||
|
if hdr.BlockSize != cfg.BlockSize {
|
||||||
|
t.Errorf("header BlockSize = %d, want %d", hdr.BlockSize, cfg.BlockSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No .tmp file should remain.
|
||||||
|
tmpPath := filepath.Join(dir, "segment-1.wal.tmp")
|
||||||
|
if _, err := os.Stat(tmpPath); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("temp file %q should not exist", tmpPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sw.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSegmentWriterAppendBatch(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := testWalConfig()
|
||||||
|
|
||||||
|
sw, err := NewSegmentWriter(dir, 42, 0, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentWriter: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { sw.Close() })
|
||||||
|
|
||||||
|
// Encode a small batch.
|
||||||
|
entries := []*WalEntry{
|
||||||
|
{OpType: OpPut, ValueKind: VKInline, Key: []byte("key1"), Value: []byte("val1")},
|
||||||
|
{OpType: OpPut, ValueKind: VKInline, Key: []byte("key2"), Value: []byte("val2")},
|
||||||
|
}
|
||||||
|
encoded, err := EncodeWalBatch(0, entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sw.AppendBatch(encoded); err != nil {
|
||||||
|
t.Fatalf("AppendBatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sw.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back the file and verify records.
|
||||||
|
data, err := os.ReadFile(sw.SegmentPath())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip file header.
|
||||||
|
body := data[WalFileHeaderSize:]
|
||||||
|
|
||||||
|
// Use FragmentCollector to reassemble.
|
||||||
|
fc := NewFragmentCollector()
|
||||||
|
offset := 0
|
||||||
|
for offset < len(body) {
|
||||||
|
// Check for trailing zeros (block padding).
|
||||||
|
if body[offset] == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, consumed, err := DecodePhysicalRecord(body[offset:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodePhysicalRecord at offset %d: %v", offset, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fc.Append(rec.Type, rec.Payload); err != nil {
|
||||||
|
t.Fatalf("FragmentCollector.Append: %v", err)
|
||||||
|
}
|
||||||
|
offset += consumed
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fc.IsComplete() {
|
||||||
|
t.Fatal("fragment collector should be complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := DecodeWalBatch(fc.BatchData())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded.EntryCount != 2 {
|
||||||
|
t.Errorf("EntryCount = %d, want 2", decoded.EntryCount)
|
||||||
|
}
|
||||||
|
if decoded.BaseSequence != 0 {
|
||||||
|
t.Errorf("BaseSequence = %d, want 0", decoded.BaseSequence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSegmentWriterMultipleBatches(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := testWalConfig()
|
||||||
|
|
||||||
|
sw, err := NewSegmentWriter(dir, 1, 0, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentWriter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
entries := []*WalEntry{
|
||||||
|
{
|
||||||
|
OpType: OpPut,
|
||||||
|
ValueKind: VKInline,
|
||||||
|
Key: []byte("key"),
|
||||||
|
Value: []byte("val"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
encoded, err := EncodeWalBatch(uint64(i), entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if err := sw.AppendBatch(encoded); err != nil {
|
||||||
|
t.Fatalf("AppendBatch %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sw.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(sw.SegmentPath())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := data[WalFileHeaderSize:]
|
||||||
|
fc := NewFragmentCollector()
|
||||||
|
batchCount := 0
|
||||||
|
offset := 0
|
||||||
|
|
||||||
|
for offset < len(body) {
|
||||||
|
if body[offset] == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, consumed, err := DecodePhysicalRecord(body[offset:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodePhysicalRecord at offset %d: %v", offset, err)
|
||||||
|
}
|
||||||
|
offset += consumed
|
||||||
|
|
||||||
|
if err := fc.Append(rec.Type, rec.Payload); err != nil {
|
||||||
|
t.Fatalf("FragmentCollector.Append: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fc.IsComplete() {
|
||||||
|
batchCount++
|
||||||
|
decoded, err := DecodeWalBatch(fc.BatchData())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeWalBatch %d: %v", batchCount, err)
|
||||||
|
}
|
||||||
|
if decoded.BaseSequence != uint64(batchCount-1) {
|
||||||
|
t.Errorf("batch %d BaseSequence = %d, want %d",
|
||||||
|
batchCount, decoded.BaseSequence, batchCount-1)
|
||||||
|
}
|
||||||
|
fc.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if batchCount != 5 {
|
||||||
|
t.Errorf("decoded %d batches, want 5", batchCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSegmentWriterRemainingPayload(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := testWalConfig()
|
||||||
|
|
||||||
|
sw, err := NewSegmentWriter(dir, 1, 0, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentWriter: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { sw.Close() })
|
||||||
|
|
||||||
|
initialRemaining := sw.RemainingPayload()
|
||||||
|
|
||||||
|
entries := []*WalEntry{
|
||||||
|
{OpType: OpPut, ValueKind: VKInline, Key: []byte("k"), Value: []byte("v")},
|
||||||
|
}
|
||||||
|
encoded, err := EncodeWalBatch(0, entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
if err := sw.AppendBatch(encoded); err != nil {
|
||||||
|
t.Fatalf("AppendBatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sw.RemainingPayload() >= initialRemaining {
|
||||||
|
t.Errorf("RemainingPayload should decrease after write, got %d >= %d",
|
||||||
|
sw.RemainingPayload(), initialRemaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSegmentWriterLargeBatch(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := testWalConfig()
|
||||||
|
|
||||||
|
sw, err := NewSegmentWriter(dir, 1, 0, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentWriter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a batch larger than one block payload (~32KB - 7 bytes).
|
||||||
|
largeValue := make([]byte, MaxWalInlineValueBytes)
|
||||||
|
for i := range largeValue {
|
||||||
|
largeValue[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []*WalEntry
|
||||||
|
for i := 0; i < 9; i++ {
|
||||||
|
entries = append(entries, &WalEntry{
|
||||||
|
OpType: OpPut,
|
||||||
|
ValueKind: VKInline,
|
||||||
|
Key: []byte(fmt.Sprintf("large-key-%d", i)),
|
||||||
|
Value: largeValue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
encoded, err := EncodeWalBatch(0, entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sw.AppendBatch(encoded); err != nil {
|
||||||
|
t.Fatalf("AppendBatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sw.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back and verify.
|
||||||
|
data, err := os.ReadFile(sw.SegmentPath())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := data[WalFileHeaderSize:]
|
||||||
|
fc := NewFragmentCollector()
|
||||||
|
offset := 0
|
||||||
|
fragCount := 0
|
||||||
|
|
||||||
|
for offset < len(body) {
|
||||||
|
if body[offset] == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, consumed, err := DecodePhysicalRecord(body[offset:])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodePhysicalRecord at offset %d: %v", offset, err)
|
||||||
|
}
|
||||||
|
offset += consumed
|
||||||
|
fragCount++
|
||||||
|
|
||||||
|
if err := fc.Append(rec.Type, rec.Payload); err != nil {
|
||||||
|
t.Fatalf("FragmentCollector.Append type=%d: %v", rec.Type, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fc.IsComplete() {
|
||||||
|
t.Fatal("fragment collector should be complete after large batch")
|
||||||
|
}
|
||||||
|
if fragCount < 2 {
|
||||||
|
t.Errorf("expected multiple fragments for large batch, got %d", fragCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := DecodeWalBatch(fc.BatchData())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeWalBatch: %v", err)
|
||||||
|
}
|
||||||
|
if decoded.EntryCount != uint32(len(entries)) {
|
||||||
|
t.Errorf("EntryCount = %d, want %d", decoded.EntryCount, len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSegmentWriterSync(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := testWalConfig()
|
||||||
|
|
||||||
|
sw, err := NewSegmentWriter(dir, 1, 0, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentWriter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sw.Sync(); err != nil {
|
||||||
|
t.Fatalf("Sync: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sw.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C6: NewSegmentWriter must return an error when
|
||||||
|
// directory fsync fails, instead of silently succeeding. Per design §3.2
|
||||||
|
// line 272, segment must NOT become active if durable-ready fails.
|
||||||
|
//
|
||||||
|
// dirFsyncFn is a package-level var; tests that override it must not use
|
||||||
|
// t.Parallel(). All wal tests run serially within the package.
|
||||||
|
func TestNewSegmentWriterDirFsyncFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
|
||||||
|
orig := dirFsyncFn
|
||||||
|
dirFsyncFn = func(string) error { return errors.New("simulated dir fsync failure") }
|
||||||
|
t.Cleanup(func() { dirFsyncFn = orig })
|
||||||
|
|
||||||
|
sw, err := NewSegmentWriter(dir, 0, 0, &cfg)
|
||||||
|
if err == nil {
|
||||||
|
if sw != nil {
|
||||||
|
sw.Close()
|
||||||
|
}
|
||||||
|
t.Fatal("NewSegmentWriter: expected error on dir fsync failure, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "fsync directory") {
|
||||||
|
t.Errorf("error should mention 'fsync directory', got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDir: %v", err)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if strings.Contains(name, "segment-0") {
|
||||||
|
t.Errorf("segment file should be cleaned up, found: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C6: ensure normal path still works after the fix.
|
||||||
|
func TestNewSegmentWriterNormalPathStillWorks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
|
||||||
|
sw, err := NewSegmentWriter(dir, 0, 0, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSegmentWriter normal path: %v", err)
|
||||||
|
}
|
||||||
|
defer sw.Close()
|
||||||
|
|
||||||
|
if _, err := os.Stat(sw.SegmentPath()); err != nil {
|
||||||
|
t.Errorf("segment file should exist: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C6: after a failed NewSegmentWriter due to dir
|
||||||
|
// fsync, retrying with fsync restored must succeed and not leak state.
|
||||||
|
func TestNewSegmentWriterRetryAfterDirFsyncFailure(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
|
||||||
|
orig := dirFsyncFn
|
||||||
|
dirFsyncFn = func(string) error { return errors.New("simulated") }
|
||||||
|
_, err := NewSegmentWriter(dir, 0, 0, &cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected first NewSegmentWriter to fail")
|
||||||
|
}
|
||||||
|
dirFsyncFn = orig
|
||||||
|
|
||||||
|
sw, err := NewSegmentWriter(dir, 0, 0, &cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("retry NewSegmentWriter: %v", err)
|
||||||
|
}
|
||||||
|
defer sw.Close()
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDir: %v", err)
|
||||||
|
}
|
||||||
|
tmpCount := 0
|
||||||
|
walCount := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.HasSuffix(e.Name(), ".tmp") {
|
||||||
|
tmpCount++
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(e.Name(), ".wal") {
|
||||||
|
walCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tmpCount != 0 {
|
||||||
|
t.Errorf("leftover .tmp files: %d", tmpCount)
|
||||||
|
}
|
||||||
|
if walCount != 1 {
|
||||||
|
t.Errorf("expected exactly 1 .wal file (from successful retry), got %d", walCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/dailz/go-kv/errkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SequenceManager manages monotonic sequence number allocation for the WAL.
|
||||||
|
// Invariant: durableSequence <= publishedSequence <= nextSequence
|
||||||
|
type SequenceManager struct {
|
||||||
|
nextSequence atomic.Uint64
|
||||||
|
publishedSequence atomic.Uint64
|
||||||
|
durableSequence atomic.Uint64
|
||||||
|
exhausted atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSequenceManager creates a SequenceManager initialised from a recovered
|
||||||
|
// sequence number. All three watermarks start at recoveredSequence.
|
||||||
|
func NewSequenceManager(recoveredSequence uint64) *SequenceManager {
|
||||||
|
sm := &SequenceManager{}
|
||||||
|
sm.nextSequence.Store(recoveredSequence)
|
||||||
|
sm.publishedSequence.Store(recoveredSequence)
|
||||||
|
sm.durableSequence.Store(recoveredSequence)
|
||||||
|
return sm
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllocateBatch atomically reserves [base, base+count-1] sequence numbers.
|
||||||
|
// Returns ErrSequenceExhausted if count is 0 or the allocation would overflow uint64.
|
||||||
|
func (sm *SequenceManager) AllocateBatch(count uint32) (baseSequence uint64, err error) {
|
||||||
|
if count == 0 {
|
||||||
|
return 0, errkit.ErrSequenceExhausted
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
if sm.exhausted.Load() {
|
||||||
|
return 0, errkit.ErrSequenceExhausted
|
||||||
|
}
|
||||||
|
base := sm.nextSequence.Load()
|
||||||
|
last := base + uint64(count) - 1
|
||||||
|
if last < base {
|
||||||
|
return 0, errkit.ErrSequenceExhausted
|
||||||
|
}
|
||||||
|
newNext := last + 1
|
||||||
|
if !sm.nextSequence.CompareAndSwap(base, newNext) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if newNext == 0 {
|
||||||
|
sm.exhausted.Store(true)
|
||||||
|
}
|
||||||
|
return base, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish advances the publishedSequence watermark to seq (only forward).
|
||||||
|
func (sm *SequenceManager) Publish(seq uint64) {
|
||||||
|
for {
|
||||||
|
current := sm.publishedSequence.Load()
|
||||||
|
if seq <= current {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sm.publishedSequence.CompareAndSwap(current, seq) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkDurable advances the durableSequence watermark to seq (only forward).
|
||||||
|
func (sm *SequenceManager) MarkDurable(seq uint64) {
|
||||||
|
for {
|
||||||
|
current := sm.durableSequence.Load()
|
||||||
|
if seq <= current {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sm.durableSequence.CompareAndSwap(current, seq) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Published returns the current publishedSequence watermark.
|
||||||
|
func (sm *SequenceManager) Published() uint64 { return sm.publishedSequence.Load() }
|
||||||
|
|
||||||
|
// Durable returns the current durableSequence watermark.
|
||||||
|
func (sm *SequenceManager) Durable() uint64 { return sm.durableSequence.Load() }
|
||||||
|
|
||||||
|
// NextSequence returns the next sequence number to be allocated.
|
||||||
|
func (sm *SequenceManager) NextSequence() uint64 { return sm.nextSequence.Load() }
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dailz/go-kv/errkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSequenceAllocation(t *testing.T) {
|
||||||
|
sm := NewSequenceManager(0)
|
||||||
|
|
||||||
|
base, err := sm.AllocateBatch(5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AllocateBatch(5): %v", err)
|
||||||
|
}
|
||||||
|
if base != 0 {
|
||||||
|
t.Fatalf("expected base=0, got %d", base)
|
||||||
|
}
|
||||||
|
|
||||||
|
base, err = sm.AllocateBatch(3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AllocateBatch(3): %v", err)
|
||||||
|
}
|
||||||
|
if base != 5 {
|
||||||
|
t.Fatalf("expected base=5, got %d", base)
|
||||||
|
}
|
||||||
|
|
||||||
|
base, err = sm.AllocateBatch(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AllocateBatch(1): %v", err)
|
||||||
|
}
|
||||||
|
if base != 8 {
|
||||||
|
t.Fatalf("expected base=8, got %d", base)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sm.NextSequence() != 9 {
|
||||||
|
t.Fatalf("expected NextSequence=9, got %d", sm.NextSequence())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSequenceOverflow(t *testing.T) {
|
||||||
|
nearMax := uint64(math.MaxUint64 - 2)
|
||||||
|
sm := NewSequenceManager(nearMax)
|
||||||
|
|
||||||
|
// Remaining: MaxUint64-2, MaxUint64-1, MaxUint64 = 3 slots.
|
||||||
|
// Asking for 5 should overflow.
|
||||||
|
_, err := sm.AllocateBatch(5)
|
||||||
|
if !errors.Is(err, errkit.ErrSequenceExhausted) {
|
||||||
|
t.Fatalf("expected ErrSequenceExhausted, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3 should still succeed.
|
||||||
|
base, err := sm.AllocateBatch(3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AllocateBatch(3): %v", err)
|
||||||
|
}
|
||||||
|
if base != nearMax {
|
||||||
|
t.Fatalf("expected base=%d, got %d", nearMax, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now any further allocation should fail.
|
||||||
|
_, err = sm.AllocateBatch(1)
|
||||||
|
if !errors.Is(err, errkit.ErrSequenceExhausted) {
|
||||||
|
t.Fatalf("expected ErrSequenceExhausted after exhaustion, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishAdvance(t *testing.T) {
|
||||||
|
sm := NewSequenceManager(0)
|
||||||
|
|
||||||
|
sm.Publish(10)
|
||||||
|
if sm.Published() != 10 {
|
||||||
|
t.Fatalf("expected Published=10, got %d", sm.Published())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publishing a lower value must not decrease the watermark.
|
||||||
|
sm.Publish(5)
|
||||||
|
if sm.Published() != 10 {
|
||||||
|
t.Fatalf("expected Published=10 (no decrease), got %d", sm.Published())
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.Publish(15)
|
||||||
|
if sm.Published() != 15 {
|
||||||
|
t.Fatalf("expected Published=15, got %d", sm.Published())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkDurable(t *testing.T) {
|
||||||
|
sm := NewSequenceManager(0)
|
||||||
|
|
||||||
|
sm.MarkDurable(8)
|
||||||
|
if sm.Durable() != 8 {
|
||||||
|
t.Fatalf("expected Durable=8, got %d", sm.Durable())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lower value must not decrease.
|
||||||
|
sm.MarkDurable(3)
|
||||||
|
if sm.Durable() != 8 {
|
||||||
|
t.Fatalf("expected Durable=8 (no decrease), got %d", sm.Durable())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroCountRejected(t *testing.T) {
|
||||||
|
sm := NewSequenceManager(0)
|
||||||
|
|
||||||
|
_, err := sm.AllocateBatch(0)
|
||||||
|
if !errors.Is(err, errkit.ErrSequenceExhausted) {
|
||||||
|
t.Fatalf("expected ErrSequenceExhausted for count=0, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentAllocation(t *testing.T) {
|
||||||
|
const goroutines = 16
|
||||||
|
const batchSize uint32 = 100
|
||||||
|
|
||||||
|
sm := NewSequenceManager(0)
|
||||||
|
|
||||||
|
var totalAllocated atomic.Uint64
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(goroutines)
|
||||||
|
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < 50; j++ {
|
||||||
|
base, err := sm.AllocateBatch(batchSize)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("AllocateBatch failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
totalAllocated.Add(uint64(batchSize))
|
||||||
|
|
||||||
|
// Verify no overlap: base must be aligned to batchSize increments
|
||||||
|
// and within valid range. The key property is no gaps.
|
||||||
|
_ = base
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
expected := uint64(goroutines) * 50 * uint64(batchSize)
|
||||||
|
if totalAllocated.Load() != expected {
|
||||||
|
t.Fatalf("expected total allocated=%d, got %d", expected, totalAllocated.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
if sm.NextSequence() != expected {
|
||||||
|
t.Fatalf("expected NextSequence=%d, got %d", expected, sm.NextSequence())
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"github.com/dailz/go-kv/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateBatchLimits checks that a batch of WAL entries satisfies all resource
|
||||||
|
// limits from the supplied configuration before sequence allocation or WAL append.
|
||||||
|
// Returns a descriptive error on violation, nil on success.
|
||||||
|
func ValidateBatchLimits(entries []*WalEntry, cfg *config.WalConfig) error {
|
||||||
|
entryCount := uint64(len(entries))
|
||||||
|
if entryCount == 0 {
|
||||||
|
return fmt.Errorf("wal: batch entry count must be > 0")
|
||||||
|
}
|
||||||
|
if entryCount > uint64(cfg.MaxBatchEntries) {
|
||||||
|
return fmt.Errorf("wal: entry count %d exceeds limit %d", entryCount, cfg.MaxBatchEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-entry validation and total encoded size calculation.
|
||||||
|
// Each entry encoded size = 1(opType) + 1(valueKind) + varint(keyLen) + varint(valLen) + keyLen + valLen
|
||||||
|
// We use MaxWalVarintBytes (5) as worst-case varint size.
|
||||||
|
var totalEncodedSize uint64
|
||||||
|
for i, e := range entries {
|
||||||
|
keyLen := uint64(len(e.Key))
|
||||||
|
if keyLen == 0 || keyLen > uint64(cfg.MaxKeyBytes) {
|
||||||
|
return fmt.Errorf("wal: entry %d: key length %d out of range [1, %d]", i, keyLen, cfg.MaxKeyBytes)
|
||||||
|
}
|
||||||
|
valLen := uint64(len(e.Value))
|
||||||
|
|
||||||
|
if e.OpType == OpPut && e.ValueKind == VKInline {
|
||||||
|
if valLen > uint64(cfg.MaxInlineValue) {
|
||||||
|
return fmt.Errorf("wal: entry %d: inline value length %d exceeds limit %d", i, valLen, cfg.MaxInlineValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// entrySize = 2 + varint(keyLen) + varint(valLen) + keyLen + valLen
|
||||||
|
// Use worst-case varint size for safety.
|
||||||
|
entrySize, err := safeAddChecked(2+MaxWalVarintBytes+MaxWalVarintBytes, keyLen)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wal: entry %d: size overflow: %w", i, err)
|
||||||
|
}
|
||||||
|
entrySize, err = safeAddChecked(entrySize, valLen)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wal: entry %d: size overflow: %w", i, err)
|
||||||
|
}
|
||||||
|
totalEncodedSize, err = safeAddChecked(totalEncodedSize, entrySize)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wal: total encoded size overflow: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// totalWithBatchHeader = WalBatchHeaderSize + totalEncodedSize
|
||||||
|
totalWithBatchHeader, err := safeAddChecked(WalBatchHeaderSize, totalEncodedSize)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wal: batch size overflow: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if totalWithBatchHeader > uint64(cfg.MaxBatchSize) {
|
||||||
|
return fmt.Errorf("wal: total batch size %d exceeds limit %d", totalWithBatchHeader, cfg.MaxBatchSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that the batch fits in a WAL segment with physical record overhead.
|
||||||
|
blockSize := uint64(cfg.BlockSize)
|
||||||
|
prHeaderSize := uint64(PhysicalRecordHeaderSize)
|
||||||
|
|
||||||
|
if blockSize <= prHeaderSize {
|
||||||
|
return fmt.Errorf("wal: block size %d must be > physical record header size %d", blockSize, prHeaderSize)
|
||||||
|
}
|
||||||
|
maxPayload := blockSize - prHeaderSize
|
||||||
|
|
||||||
|
numRecords := divCeilChecked(totalWithBatchHeader, maxPayload)
|
||||||
|
overhead, err := safeMulChecked(numRecords, prHeaderSize)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wal: physical record overhead overflow: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalWithOverhead, err := safeAddChecked(totalWithBatchHeader, overhead)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wal: total with overhead overflow: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxSegmentPayload := cfg.MaxSegmentSize - WalFileHeaderSize
|
||||||
|
if totalWithOverhead > maxSegmentPayload {
|
||||||
|
return fmt.Errorf("wal: batch with overhead %d exceeds segment payload %d", totalWithOverhead, maxSegmentPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeAddChecked returns a + b or an error if the result overflows uint64.
|
||||||
|
func safeAddChecked(a, b uint64) (uint64, error) {
|
||||||
|
if a > math.MaxUint64-b {
|
||||||
|
return 0, fmt.Errorf("uint64 overflow: %d + %d", a, b)
|
||||||
|
}
|
||||||
|
return a + b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeMulChecked returns a * b or an error if the result overflows uint64.
|
||||||
|
func safeMulChecked(a, b uint64) (uint64, error) {
|
||||||
|
if a != 0 && b > math.MaxUint64/a {
|
||||||
|
return 0, fmt.Errorf("uint64 overflow: %d * %d", a, b)
|
||||||
|
}
|
||||||
|
return a * b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// divCeilChecked returns ceil(a / b) for b > 0.
|
||||||
|
func divCeilChecked(a, b uint64) uint64 {
|
||||||
|
return (a + b - 1) / b
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dailz/go-kv/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeEntry(opType uint8, valueKind uint8, keySize, valSize int) *WalEntry {
|
||||||
|
key := make([]byte, keySize)
|
||||||
|
for i := range key {
|
||||||
|
key[i] = byte('a' + i%26)
|
||||||
|
}
|
||||||
|
val := make([]byte, valSize)
|
||||||
|
for i := range val {
|
||||||
|
val[i] = byte('x')
|
||||||
|
}
|
||||||
|
return &WalEntry{OpType: opType, ValueKind: valueKind, Key: key, Value: val}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBatchLimitsPass(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
entries := make([]*WalEntry, 100)
|
||||||
|
for i := range entries {
|
||||||
|
entries[i] = makeEntry(OpPut, VKInline, 10, 10)
|
||||||
|
}
|
||||||
|
if err := ValidateBatchLimits(entries, &cfg); err != nil {
|
||||||
|
t.Fatalf("expected nil error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateEntryCountExceeded(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
entries := make([]*WalEntry, 10001)
|
||||||
|
for i := range entries {
|
||||||
|
entries[i] = makeEntry(OpPut, VKInline, 10, 10)
|
||||||
|
}
|
||||||
|
err := ValidateBatchLimits(entries, &cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for entry count exceeded")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "entry count") {
|
||||||
|
t.Fatalf("expected error containing 'entry count', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateKeyTooBig(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
// 5KB key exceeds 4KB limit
|
||||||
|
entries := []*WalEntry{makeEntry(OpPut, VKInline, 5*1024, 10)}
|
||||||
|
err := ValidateBatchLimits(entries, &cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for key too big")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "key") {
|
||||||
|
t.Fatalf("expected error containing 'key', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateValueTooBig(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
// 5KB inline value exceeds 4KB limit
|
||||||
|
entries := []*WalEntry{makeEntry(OpPut, VKInline, 10, 5*1024)}
|
||||||
|
err := ValidateBatchLimits(entries, &cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for value too big")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "value") {
|
||||||
|
t.Fatalf("expected error containing 'value', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateEmptyBatch(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
err := ValidateBatchLimits(nil, &cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty batch")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "entry count") && !strings.Contains(err.Error(), "> 0") {
|
||||||
|
t.Fatalf("expected error about empty batch, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ValidateBatchLimits([]*WalEntry{}, &cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty batch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateTotalSizeExceeded(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
// Create entries that collectively exceed MaxBatchSize (4MB).
|
||||||
|
// Each entry with 1000-byte key + 1000-byte value ≈ 2012 bytes encoded.
|
||||||
|
// 2100 entries × ~2012 ≈ ~4.2MB > 4MB
|
||||||
|
entries := make([]*WalEntry, 2100)
|
||||||
|
for i := range entries {
|
||||||
|
entries[i] = makeEntry(OpPut, VKInline, 1000, 1000)
|
||||||
|
}
|
||||||
|
err := ValidateBatchLimits(entries, &cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for total size exceeded")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "batch size") {
|
||||||
|
t.Fatalf("expected error containing 'batch size', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+410
@@ -0,0 +1,410 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dailz/go-kv/config"
|
||||||
|
"github.com/dailz/go-kv/errkit"
|
||||||
|
"github.com/dailz/go-kv/memtable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MemTableList tracks the active memtable plus frozen memtables retained for reads.
|
||||||
|
type MemTableList struct {
|
||||||
|
active *memtable.MemTable
|
||||||
|
immutable []*memtable.MemTable
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActive returns the current writable memtable.
|
||||||
|
func (mtl *MemTableList) GetActive() *memtable.MemTable {
|
||||||
|
mtl.mu.RLock()
|
||||||
|
defer mtl.mu.RUnlock()
|
||||||
|
return mtl.active
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll returns memtables from newest to oldest for read lookup.
|
||||||
|
func (mtl *MemTableList) GetAll() []*memtable.MemTable {
|
||||||
|
mtl.mu.RLock()
|
||||||
|
defer mtl.mu.RUnlock()
|
||||||
|
|
||||||
|
out := make([]*memtable.MemTable, 0, 1+len(mtl.immutable))
|
||||||
|
if mtl.active != nil {
|
||||||
|
out = append(out, mtl.active)
|
||||||
|
}
|
||||||
|
for i := len(mtl.immutable) - 1; i >= 0; i-- {
|
||||||
|
out = append(out, mtl.immutable[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mtl *MemTableList) rotateActive(newActive *memtable.MemTable) {
|
||||||
|
mtl.mu.Lock()
|
||||||
|
defer mtl.mu.Unlock()
|
||||||
|
if mtl.active != nil {
|
||||||
|
mtl.immutable = append(mtl.immutable, mtl.active)
|
||||||
|
}
|
||||||
|
mtl.active = newActive
|
||||||
|
}
|
||||||
|
|
||||||
|
// WalWriter owns the single WAL append goroutine and group-commit pipeline.
|
||||||
|
type WalWriter struct {
|
||||||
|
cfg *config.WalConfig
|
||||||
|
dir string
|
||||||
|
queue *CommitQueue
|
||||||
|
segManager *SegmentManager
|
||||||
|
seqManager *SequenceManager
|
||||||
|
memTables *MemTableList
|
||||||
|
writeStopped atomic.Bool
|
||||||
|
submitMu sync.RWMutex // C7 fix: protects Submit vs Close critical section
|
||||||
|
done chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
closeOnce sync.Once
|
||||||
|
closeErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWalWriter creates a WAL writer and starts its single background loop.
|
||||||
|
func NewWalWriter(cfg *config.WalConfig, dir string, startSegmentID uint64, startSequence uint64) (*WalWriter, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
defaults := config.Defaults()
|
||||||
|
cfg = &defaults
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
segManager, err := NewSegmentManager(dir, startSegmentID, startSequence, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
queueCapacity := max(1, int(cfg.MaxBatchEntries))
|
||||||
|
|
||||||
|
ww := &WalWriter{
|
||||||
|
cfg: cfg,
|
||||||
|
dir: dir,
|
||||||
|
queue: NewCommitQueue(queueCapacity),
|
||||||
|
segManager: segManager,
|
||||||
|
seqManager: NewSequenceManager(startSequence),
|
||||||
|
memTables: &MemTableList{
|
||||||
|
active: memtable.NewMemTable(cfg.MemTableSize),
|
||||||
|
},
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
ww.wg.Add(1)
|
||||||
|
go ww.runLoop()
|
||||||
|
|
||||||
|
return ww, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ww *WalWriter) runLoop() {
|
||||||
|
defer ww.wg.Done()
|
||||||
|
const batchSizeThreshold = 32 * 1024
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ww.done:
|
||||||
|
ww.processRemaining()
|
||||||
|
return
|
||||||
|
case req, ok := <-ww.queue.ch:
|
||||||
|
if !ok {
|
||||||
|
ww.processRemaining()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requests := []*CommitRequest{req}
|
||||||
|
accumulatedSize := estimateRequestSize(req)
|
||||||
|
timer := time.NewTimer(ww.cfg.GroupCommitDelay)
|
||||||
|
|
||||||
|
collect:
|
||||||
|
for accumulatedSize < batchSizeThreshold {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
break collect
|
||||||
|
case req, ok := <-ww.queue.ch:
|
||||||
|
if !ok {
|
||||||
|
break collect
|
||||||
|
}
|
||||||
|
requests = append(requests, req)
|
||||||
|
accumulatedSize += estimateRequestSize(req)
|
||||||
|
case <-ww.done:
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ww.sendError(requests, errkit.ErrWriteStopped)
|
||||||
|
ww.processRemaining()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ww.processBatch(requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func estimateRequestSize(req *CommitRequest) int {
|
||||||
|
size := 0
|
||||||
|
for _, entry := range req.Entries {
|
||||||
|
size += len(entry.Key) + len(entry.Value) + 4
|
||||||
|
}
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ww *WalWriter) processRemaining() {
|
||||||
|
for {
|
||||||
|
requests := ww.queue.Collect()
|
||||||
|
if len(requests) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ww.processBatch(requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ww *WalWriter) processBatch(requests []*CommitRequest) {
|
||||||
|
if ww.writeStopped.Load() {
|
||||||
|
ww.sendError(requests, errkit.ErrWriteStopped)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, requestOffsets := flattenRequests(requests)
|
||||||
|
if err := ValidateBatchLimits(entries, ww.cfg); err != nil {
|
||||||
|
ww.sendError(requests, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ww.reserveMemTable(entries); err != nil {
|
||||||
|
ww.sendError(requests, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-validate encoding can succeed before sequence allocation.
|
||||||
|
if _, err := EncodeWalBatch(0, entries); err != nil {
|
||||||
|
ww.sendError(requests, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baseSequence, err := ww.seqManager.AllocateBatch(uint32(len(entries)))
|
||||||
|
if err != nil {
|
||||||
|
ww.sendError(requests, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastSequence := baseSequence + uint64(len(entries)) - 1
|
||||||
|
|
||||||
|
encoded, err := EncodeWalBatch(baseSequence, entries)
|
||||||
|
if err != nil {
|
||||||
|
ww.stopWithError(requests, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ww.segManager.AppendBatch(encoded, baseSequence); err != nil {
|
||||||
|
ww.stopWithError(requests, errkit.ErrCommitUnknown)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
active := ww.memTables.GetActive()
|
||||||
|
if err := writePending(active, entries, baseSequence); err != nil {
|
||||||
|
abortRange(active, baseSequence, lastSequence)
|
||||||
|
ww.stopWithError(requests, errkit.ErrCommitUnknown)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ww.segManager.Sync(); err != nil {
|
||||||
|
abortRange(active, baseSequence, lastSequence)
|
||||||
|
ww.stopWithError(requests, errkit.ErrCommitUnknown)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ww.seqManager.MarkDurable(lastSequence)
|
||||||
|
active.Publish(lastSequence)
|
||||||
|
ww.seqManager.Publish(lastSequence)
|
||||||
|
ww.sendSuccess(requests, baseSequence, requestOffsets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenRequests(requests []*CommitRequest) ([]*WalEntry, []uint64) {
|
||||||
|
var entries []*WalEntry
|
||||||
|
requestOffsets := make([]uint64, len(requests))
|
||||||
|
for i, req := range requests {
|
||||||
|
requestOffsets[i] = uint64(len(entries))
|
||||||
|
entries = append(entries, req.Entries...)
|
||||||
|
}
|
||||||
|
return entries, requestOffsets
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ww *WalWriter) reserveMemTable(entries []*WalEntry) error {
|
||||||
|
reserveEntries := make([]memtable.ReserveEntry, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
reserveEntries = append(reserveEntries, memtable.ReserveEntry{
|
||||||
|
Key: entry.Key,
|
||||||
|
Value: entry.Value,
|
||||||
|
IsDelete: entry.OpType == OpDelete,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
active := ww.memTables.GetActive()
|
||||||
|
if _, err := active.Reserve(reserveEntries); err == nil {
|
||||||
|
return nil
|
||||||
|
} else if !errors.Is(err, memtable.ErrMemTableFull) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ww.memTables.rotateActive(memtable.NewMemTable(ww.cfg.MemTableSize))
|
||||||
|
active = ww.memTables.GetActive()
|
||||||
|
_, err := active.Reserve(reserveEntries)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePending(mt *memtable.MemTable, entries []*WalEntry, baseSequence uint64) error {
|
||||||
|
for i, entry := range entries {
|
||||||
|
sequence := baseSequence + uint64(i)
|
||||||
|
switch entry.OpType {
|
||||||
|
case OpPut:
|
||||||
|
if err := mt.PutPending(entry.Key, entry.Value, sequence); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case OpDelete:
|
||||||
|
if err := mt.DeletePending(entry.Key, sequence); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return errors.New("wal: unsupported operation in committed batch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func abortRange(mt *memtable.MemTable, firstSequence uint64, lastSequence uint64) {
|
||||||
|
for seq := firstSequence; seq <= lastSequence; seq++ {
|
||||||
|
mt.Abort(seq)
|
||||||
|
if seq == ^uint64(0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ww *WalWriter) stopWithError(requests []*CommitRequest, err error) {
|
||||||
|
ww.writeStopped.Store(true)
|
||||||
|
ww.sendError(requests, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ww *WalWriter) sendError(requests []*CommitRequest, err error) {
|
||||||
|
for _, req := range requests {
|
||||||
|
req.Result <- WriteResult{Err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ww *WalWriter) sendSuccess(requests []*CommitRequest, baseSequence uint64, requestOffsets []uint64) {
|
||||||
|
for i, req := range requests {
|
||||||
|
req.Result <- WriteResult{Sequence: baseSequence + requestOffsets[i]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put stores key with value.
|
||||||
|
func (ww *WalWriter) Put(key, value []byte) error {
|
||||||
|
entry := &WalEntry{
|
||||||
|
OpType: OpPut,
|
||||||
|
ValueKind: VKInline,
|
||||||
|
Key: cloneBytes(key),
|
||||||
|
Value: cloneBytes(value),
|
||||||
|
}
|
||||||
|
|
||||||
|
// C7 fix: RLock protects check + Submit. Close holds write lock during
|
||||||
|
// writeStopped.Store + queue.Close, so Submit cannot send on a closed channel.
|
||||||
|
ww.submitMu.RLock()
|
||||||
|
if ww.writeStopped.Load() {
|
||||||
|
ww.submitMu.RUnlock()
|
||||||
|
return errkit.ErrWriteStopped
|
||||||
|
}
|
||||||
|
req := ww.queue.Submit([]*WalEntry{entry})
|
||||||
|
ww.submitMu.RUnlock()
|
||||||
|
|
||||||
|
result := <-req.Result
|
||||||
|
return result.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes key.
|
||||||
|
func (ww *WalWriter) Delete(key []byte) error {
|
||||||
|
entry := &WalEntry{
|
||||||
|
OpType: OpDelete,
|
||||||
|
ValueKind: VKNone,
|
||||||
|
Key: cloneBytes(key),
|
||||||
|
}
|
||||||
|
|
||||||
|
ww.submitMu.RLock()
|
||||||
|
if ww.writeStopped.Load() {
|
||||||
|
ww.submitMu.RUnlock()
|
||||||
|
return errkit.ErrWriteStopped
|
||||||
|
}
|
||||||
|
req := ww.queue.Submit([]*WalEntry{entry})
|
||||||
|
ww.submitMu.RUnlock()
|
||||||
|
|
||||||
|
result := <-req.Result
|
||||||
|
return result.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the newest visible value across active and immutable memtables.
|
||||||
|
func (ww *WalWriter) Get(key []byte) *memtable.GetResult {
|
||||||
|
for _, mt := range ww.memTables.GetAll() {
|
||||||
|
result := mt.Get(key)
|
||||||
|
if !result.Found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result.Value == nil {
|
||||||
|
return &memtable.GetResult{Found: false, Sequence: result.Sequence}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return &memtable.GetResult{Found: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDurableSequence returns the highest sequence durably fsynced to WAL.
|
||||||
|
func (ww *WalWriter) GetDurableSequence() uint64 {
|
||||||
|
return ww.seqManager.Durable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWriteStopped reports whether the writer is rejecting new writes.
|
||||||
|
func (ww *WalWriter) IsWriteStopped() bool {
|
||||||
|
return ww.writeStopped.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close drains queued writes, stops the writer goroutine, syncs, and closes the segment.
|
||||||
|
func (ww *WalWriter) Close() error {
|
||||||
|
ww.closeOnce.Do(func() {
|
||||||
|
// C7 fix: write lock waits for all in-flight Put/Delete Submits to
|
||||||
|
// complete. While we hold this lock, no Submit can be in progress,
|
||||||
|
// so queue.Close is safe (no send-on-closed-channel panic).
|
||||||
|
ww.submitMu.Lock()
|
||||||
|
ww.writeStopped.Store(true)
|
||||||
|
ww.queue.Close()
|
||||||
|
ww.submitMu.Unlock()
|
||||||
|
|
||||||
|
close(ww.done)
|
||||||
|
ww.wg.Wait()
|
||||||
|
if err := ww.segManager.Sync(); err != nil {
|
||||||
|
ww.closeErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ww.closeErr = ww.segManager.Close()
|
||||||
|
})
|
||||||
|
return ww.closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneBytes(src []byte) []byte {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dst := make([]byte, len(src))
|
||||||
|
copy(dst, src)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package wal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dailz/go-kv/config"
|
||||||
|
"github.com/dailz/go-kv/errkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestWalWriter(t *testing.T) *WalWriter {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cfg := config.Defaults()
|
||||||
|
ww, err := NewWalWriter(&cfg, t.TempDir(), 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWalWriter: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := ww.Close(); err != nil {
|
||||||
|
t.Fatalf("Close: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return ww
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalWriterSinglePut(t *testing.T) {
|
||||||
|
ww := newTestWalWriter(t)
|
||||||
|
|
||||||
|
if err := ww.Put([]byte("k1"), []byte("v1")); err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := ww.Get([]byte("k1"))
|
||||||
|
if !got.Found {
|
||||||
|
t.Fatal("Get(k1) not found")
|
||||||
|
}
|
||||||
|
if string(got.Value) != "v1" {
|
||||||
|
t.Fatalf("Get(k1) value = %q, want %q", got.Value, "v1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalWriterMultiplePuts(t *testing.T) {
|
||||||
|
ww := newTestWalWriter(t)
|
||||||
|
|
||||||
|
for i := range 10 {
|
||||||
|
key := fmt.Appendf(nil, "k%d", i)
|
||||||
|
value := fmt.Appendf(nil, "v%d", i)
|
||||||
|
if err := ww.Put(key, value); err != nil {
|
||||||
|
t.Fatalf("Put(%q): %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range 10 {
|
||||||
|
key := fmt.Appendf(nil, "k%d", i)
|
||||||
|
want := fmt.Sprintf("v%d", i)
|
||||||
|
got := ww.Get(key)
|
||||||
|
if !got.Found {
|
||||||
|
t.Fatalf("Get(%q) not found", key)
|
||||||
|
}
|
||||||
|
if string(got.Value) != want {
|
||||||
|
t.Fatalf("Get(%q) value = %q, want %q", key, got.Value, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalWriterGroupCommit(t *testing.T) {
|
||||||
|
ww := newTestWalWriter(t)
|
||||||
|
|
||||||
|
const writers = 5
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errCh := make(chan error, writers)
|
||||||
|
|
||||||
|
for i := range writers {
|
||||||
|
wg.Go(func() {
|
||||||
|
key := fmt.Appendf(nil, "group-k%d", i)
|
||||||
|
value := fmt.Appendf(nil, "group-v%d", i)
|
||||||
|
if err := ww.Put(key, value); err != nil {
|
||||||
|
errCh <- fmt.Errorf("put %d: %w", i, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
for err := range errCh {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range writers {
|
||||||
|
key := fmt.Appendf(nil, "group-k%d", i)
|
||||||
|
want := fmt.Sprintf("group-v%d", i)
|
||||||
|
got := ww.Get(key)
|
||||||
|
if !got.Found {
|
||||||
|
t.Fatalf("Get(%q) not found", key)
|
||||||
|
}
|
||||||
|
if string(got.Value) != want {
|
||||||
|
t.Fatalf("Get(%q) value = %q, want %q", key, got.Value, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalWriterDelete(t *testing.T) {
|
||||||
|
ww := newTestWalWriter(t)
|
||||||
|
|
||||||
|
if err := ww.Put([]byte("k"), []byte("v")); err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
if err := ww.Delete([]byte("k")); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := ww.Get([]byte("k"))
|
||||||
|
if got.Found {
|
||||||
|
t.Fatalf("Get(k) found deleted key with value %q", got.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteStoppedAfterIOError verifies: I/O failure during AppendBatch →
|
||||||
|
// ErrCommitUnknown returned, write-stopped state entered, subsequent writes
|
||||||
|
// return ErrWriteStopped.
|
||||||
|
func TestWriteStoppedAfterIOError(t *testing.T) {
|
||||||
|
cfg := config.Defaults()
|
||||||
|
ww, err := NewWalWriter(&cfg, t.TempDir(), 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWalWriter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ww.Put([]byte("ok-key"), []byte("ok-val")); err != nil {
|
||||||
|
t.Fatalf("initial Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the fd to force AppendBatch → stopWithError(ErrCommitUnknown).
|
||||||
|
if err := ww.segManager.active.fd.Close(); err != nil {
|
||||||
|
t.Fatalf("close fd: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ww.Put([]byte("fail-key"), []byte("fail-val"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error from Put after I/O failure, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, errkit.ErrCommitUnknown) {
|
||||||
|
t.Errorf("Put error = %v, want ErrCommitUnknown", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ww.IsWriteStopped() {
|
||||||
|
t.Fatal("IsWriteStopped() = false, want true after I/O error")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ww.Put([]byte("after-key"), []byte("after-val"))
|
||||||
|
if !errors.Is(err, errkit.ErrWriteStopped) {
|
||||||
|
t.Errorf("Put after write-stopped error = %v, want ErrWriteStopped", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ww.Delete([]byte("after-key"))
|
||||||
|
if !errors.Is(err, errkit.ErrWriteStopped) {
|
||||||
|
t.Errorf("Delete after write-stopped error = %v, want ErrWriteStopped", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = ww.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- C7 regression guards --------
|
||||||
|
|
||||||
|
// Regression guard for C7: concurrent Put + Close must not panic.
|
||||||
|
// Buggy code has a race window between writeStopped check and Submit's
|
||||||
|
// send on channel; Close closing the channel during that window causes
|
||||||
|
// "send on closed channel" panic.
|
||||||
|
//
|
||||||
|
// Note: this test guarantees "no panic AFTER fix", not "must panic BEFORE
|
||||||
|
// fix" — the race window is narrow (Put check → Submit, a few instructions)
|
||||||
|
// and the default queue capacity (10000) means sends rarely block. Run
|
||||||
|
// with -count=50 to increase regression detection probability.
|
||||||
|
func TestConcurrentPutCloseNoPanic(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
ww, err := NewWalWriter(&cfg, dir, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goroutines = 100
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var panicCount atomic.Int32
|
||||||
|
start := make(chan struct{})
|
||||||
|
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
panicCount.Add(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
<-start
|
||||||
|
_ = ww.Put([]byte("k"), []byte("v"))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
_ = ww.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if panicCount.Load() > 0 {
|
||||||
|
t.Fatalf("concurrent Put + Close caused %d panic(s) — C7 regression",
|
||||||
|
panicCount.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression guard for C7: same as above but for Delete.
|
||||||
|
func TestConcurrentDeleteCloseNoPanic(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := config.Defaults()
|
||||||
|
ww, err := NewWalWriter(&cfg, dir, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goroutines = 100
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var panicCount atomic.Int32
|
||||||
|
start := make(chan struct{})
|
||||||
|
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
panicCount.Add(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
<-start
|
||||||
|
_ = ww.Delete([]byte("k"))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
_ = ww.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if panicCount.Load() > 0 {
|
||||||
|
t.Fatalf("concurrent Delete + Close caused %d panic(s) — C7 regression",
|
||||||
|
panicCount.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user