Skip to content

Commit 33e4be3

Browse files
committed
feat: add arkilian-dlq recovery tool and integrated regression test suite to verify dead-letter queue operations.
1 parent 21a8a62 commit 33e4be3

7 files changed

Lines changed: 270 additions & 11 deletions

File tree

.github/workflows/release.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,13 @@ jobs:
3333
artifact-dir: build
3434
shared-lib: libarkilian.so
3535
static-lib: libarkilian.a
36+
dlq-bin: arkilian-dlq
3637
- os: macos-latest
3738
suffix: darwin-x64
3839
artifact-dir: build
3940
shared-lib: libarkilian.dylib
4041
static-lib: libarkilian.a
42+
dlq-bin: arkilian-dlq
4143
- os: windows-latest
4244
suffix: windows-x64
4345
# MSVC uses a multi-config generator: Release outputs land in
@@ -47,6 +49,7 @@ jobs:
4749
# Renamed in CMakeLists.txt to avoid clashing with the DLL's
4850
# import library (which is also arkilian.lib).
4951
static-lib: arkilian_static.lib
52+
dlq-bin: arkilian-dlq.exe
5053
runs-on: ${{ matrix.os }}
5154
steps:
5255
- uses: actions/checkout@v4
@@ -93,6 +96,10 @@ jobs:
9396
mkdir -p dist
9497
cp "${{ matrix.artifact-dir }}/${{ matrix.shared-lib }}" dist/ 2>/dev/null || true
9598
cp "${{ matrix.artifact-dir }}/${{ matrix.static-lib }}" dist/
99+
# Dead-letter queue recovery tool (docs/operations.md §3) — built
100+
# on every default build, shipped per-platform so operators can run
101+
# support without a toolchain (launch Checklist #5).
102+
cp "${{ matrix.artifact-dir }}/${{ matrix.dlq-bin }}" dist/ 2>/dev/null || true
96103
cp src/class.h src/hydration.h src/sha256.h dist/
97104
ls -la dist/
98105

CMakeLists.txt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@ if(ARKILIAN_BUILD_EXAMPLES)
9191
endif()
9292
endif()
9393

94+
# Dead-letter queue recovery tool (docs/operations.md §3). Built on every
95+
# default build so a CI break is caught early AND the binary is available as
96+
# a release asset (release.yml stages it). Standalone: only the SQLite
97+
# amalgamation — no libcurl, no pthread — so it builds on every target,
98+
# including minimal/Alpine containers and the toolchain-less hosts operators
99+
# run support from.
100+
add_executable(arkilian-dlq tools/arkilian-dlq.c src/deps/sqlite/sqlite3.c)
101+
target_include_directories(arkilian-dlq PRIVATE src/deps/sqlite)
102+
set_target_properties(arkilian-dlq PROPERTIES C_STANDARD 99)
103+
94104
# Node.js N-API Addon (built via cmake-js)
95105
option(ARKILIAN_BUILD_NAPI "Build Node.js N-API addon" OFF)
96106

@@ -262,6 +272,19 @@ if(ARKILIAN_BUILD_TESTS)
262272
target_compile_options(test_sha256 PRIVATE -UNDEBUG)
263273
add_test(NAME test_sha256 COMMAND test_sha256)
264274

275+
# ── DLQ tool recovery tests (launch Checklist #5) ──
276+
# Standalone (sqlite3 only — no libcurl/pthread), so it runs on the
277+
# MSYS2/MinGW leg too. ARKILIAN_DLQ_BIN points at the just-built
278+
# arkilian-dlq target via a generator expression (handles multi-config
279+
# MSVC paths correctly).
280+
add_executable(test_dlq tests/test_dlq.c src/deps/sqlite/sqlite3.c)
281+
target_include_directories(test_dlq PRIVATE src/deps/sqlite)
282+
target_link_libraries(test_dlq PRIVATE Threads::Threads)
283+
target_compile_definitions(test_dlq PRIVATE
284+
ARKILIAN_DLQ_BIN="$<TARGET_FILE:arkilian-dlq>")
285+
target_compile_options(test_dlq PRIVATE -UNDEBUG)
286+
add_test(NAME test_dlq COMMAND test_dlq)
287+
265288
# ── e2e/stress tests that need a live control plane: built but NOT
266289
# registered with ctest (they require external services). Compile
267290
# them so a build breakage is caught; run them out-of-band via

index.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { createRequire } from "node:module";
22

33
const require = createRequire(import.meta.url);
4-
const native = require("./build/Release/arkilian");
4+
// Runtime resolution via node-gyp-build: picks the prebuilt .node for this
5+
// platform/arch from the bundled prebuilds/ dir (offline, no compiler),
6+
// falling back to the build/Release output of a source build. Lets
7+
// `npm install arkilian` succeed on toolchain-less hosts (Alpine/Lambda/
8+
// serverless) where the prebuildify-produced .node is bundled — launch
9+
// Checklist #2.
10+
const native = require("node-gyp-build")(__dirname);
511

612
const SQLITE_OK = 0;
713
const SQLITE_ROW = 100;

package.json

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
"main": "index.js",
66
"type": "module",
77
"scripts": {
8-
"install": "node-gyp configure && node-gyp build",
8+
"install": "node-gyp-build || node-gyp configure && node-gyp build",
99
"build": "node-gyp configure && node-gyp build",
1010
"clean": "node-gyp clean",
11-
"test": "node test.js"
11+
"test": "node test.js",
12+
"prebuildify": "prebuildify --napi --strip"
1213
},
1314
"keywords": [
1415
"sqlite",
@@ -26,17 +27,20 @@
2627
"url": "https://github.com/CodeDynasty-dev/birth-of-Arkilian.git"
2728
},
2829
"dependencies": {
29-
"node-addon-api": "^8.0.0"
30+
"node-addon-api": "^8.0.0",
31+
"node-gyp-build": "^4.8.4"
3032
},
3133
"devDependencies": {
32-
"node-gyp": "^10.0.0"
34+
"node-gyp": "^10.0.0",
35+
"prebuildify": "^6.1.0"
3336
},
34-
"gypfile": true,
37+
"gypfile": false,
3538
"files": [
3639
"index.js",
3740
"binding.gyp",
3841
"LICENSE",
3942
"README.md",
43+
"prebuilds/",
4044
"src/class.c",
4145
"src/class.h",
4246
"src/arkilian.cc",
@@ -53,4 +57,4 @@
5357
"bun": ">=1.0.0"
5458
},
5559
"private": false
56-
}
60+
}

tests/run_all.sh

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,22 @@ run_hydration() {
4242
rm -f test_hydration
4343
}
4444

45+
# Dead-letter queue tool + its regression suite (launch Checklist #5).
46+
# The tool is standalone (SQLite only — no libcurl), so the build is cheap
47+
# and works on toolchain-less hosts. The test invokes the binary via
48+
# system() and asserts the recovery runbook's commands.
49+
run_dlq() {
50+
echo "── arkilian-dlq (build) ──"
51+
cc -O2 -Wall -Wextra -Wpedantic -Werror tools/arkilian-dlq.c \
52+
src/deps/sqlite/sqlite3.c -Isrc/deps/sqlite -o arkilian-dlq
53+
echo "── test_dlq ──"
54+
cc -O2 -Wall -Wextra tests/test_dlq.c src/deps/sqlite/sqlite3.c \
55+
-Isrc/deps/sqlite -lpthread \
56+
-DARKILIAN_DLQ_BIN='"./arkilian-dlq"' -o test_dlq
57+
./test_dlq
58+
rm -f arkilian-dlq test_dlq
59+
}
60+
4561
run test_basic tests/test_basic.c
4662
run test_interception tests/test_interception.c
4763
run test_regressions tests/test_regressions.c
@@ -53,6 +69,7 @@ run test_virtual_tables tests/test_virtual_tables.c
5369
run test_deterministic tests/test_deterministic.c
5470
run test_hardening tests/test_hardening.c
5571
run_hydration
72+
run_dlq
5673

5774
# Benchmarks: built + run, but they assert correctness internally. Not
5875
# part of the pass/fail gate (they're too long-running for default CI).

tests/test_dlq.c

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Arkilian DLQ Tool Regression Tests — 5,000-business launch verification
2+
// (Checklist #5: "Confirm tools/arkilian-dlq compiles cleanly across target
3+
// platforms and that the recovery runbook is complete for tier-1 support".)
4+
//
5+
// Drives the `arkilian-dlq` CLI via system() against a crafted outbox and
6+
// asserts the on-disk state transitions. Regression-protects the
7+
// `--replay --id N` path which was broken at launch verification with
8+
// "replay prepare failed: near \"AND\": syntax error" (a `FROM ... AND id`
9+
// construction). Idempotency, single-row, and full-replay paths are all
10+
// covered so the recovery runbook's commands are provably correct.
11+
//
12+
// Cross-platform: uses only system() + sqlite3 (no BSD sockets), so it
13+
// runs on the MSYS2/MinGW CI leg alongside the rest of the suite.
14+
//
15+
// Compile (manual, macOS/Linux):
16+
// cc -O2 -Wall -Wextra tests/test_dlq.c src/deps/sqlite/sqlite3.c \
17+
// -Isrc/deps/sqlite -DARKILIAN_DLQ_BIN='"./arkilian-dlq"' -o test_dlq
18+
// Assumes ./arkilian-dlq is already built next to the test.
19+
20+
#include "sqlite3.h"
21+
#include <assert.h>
22+
#include <stdio.h>
23+
#include <stdlib.h>
24+
#include <string.h>
25+
26+
#ifndef ARKILIAN_DLQ_BIN
27+
#define ARKILIAN_DLQ_BIN "./arkilian-dlq"
28+
#endif
29+
30+
static const char *DB = "test_dlq_data.db";
31+
32+
static void cleanup_db(void) {
33+
remove(DB);
34+
char s[64];
35+
snprintf(s, sizeof s, "%s-wal", DB); remove(s);
36+
snprintf(s, sizeof s, "%s-shm", DB); remove(s);
37+
snprintf(s, sizeof s, "%s-journal", DB); remove(s);
38+
}
39+
40+
// Invoke the dlq binary; returns the process exit status (0 = success).
41+
static int run_dlq(const char *args) {
42+
char cmd[4096];
43+
snprintf(cmd, sizeof cmd, "\"%s\" \"%s\" %s", ARKILIAN_DLQ_BIN, DB, args);
44+
// On POSIX system() returns a wait status whose exit code is WEXITSTATUS;
45+
// on Windows it returns the exit code directly. A successful tool run
46+
// exits 0 in both, which is all we assert on.
47+
return system(cmd);
48+
}
49+
50+
static sqlite3 *open_ro(void) {
51+
sqlite3 *h = NULL;
52+
sqlite3_open_v2(DB, &h, SQLITE_OPEN_READONLY, NULL);
53+
return h;
54+
}
55+
56+
static long long count_in(const char *table) {
57+
sqlite3 *h = open_ro();
58+
if (!h) return -1;
59+
sqlite3_stmt *st = NULL;
60+
long long n = -1;
61+
char sql[128];
62+
snprintf(sql, sizeof sql, "SELECT COUNT(*) FROM %s", table);
63+
if (sqlite3_prepare_v2(h, sql, -1, &st, NULL) == SQLITE_OK &&
64+
sqlite3_step(st) == SQLITE_ROW) n = sqlite3_column_int64(st, 0);
65+
sqlite3_finalize(st);
66+
sqlite3_close(h);
67+
return n;
68+
}
69+
70+
static int attempts_of(sqlite3_int64 id) {
71+
sqlite3 *h = open_ro();
72+
if (!h) return -1;
73+
sqlite3_stmt *st = NULL;
74+
int n = -1;
75+
if (sqlite3_prepare_v2(h, "SELECT attempts FROM _pending_backup WHERE id=?",
76+
-1, &st, NULL) == SQLITE_OK) {
77+
sqlite3_bind_int64(st, 1, id);
78+
if (sqlite3_step(st) == SQLITE_ROW) n = sqlite3_column_int(st, 0);
79+
}
80+
sqlite3_finalize(st);
81+
sqlite3_close(h);
82+
return n;
83+
}
84+
85+
static void seed(void) {
86+
cleanup_db();
87+
sqlite3 *h = NULL;
88+
int rc = sqlite3_open_v2(DB, &h,
89+
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, NULL);
90+
assert(rc == SQLITE_OK && h);
91+
char *err = NULL;
92+
sqlite3_exec(h,
93+
"CREATE TABLE _pending_backup(id INTEGER PRIMARY KEY,payload TEXT,"
94+
"attempts INTEGER,created_at INTEGER,last_attempt_at INTEGER);"
95+
"CREATE TABLE _dead_backup(id INTEGER PRIMARY KEY,payload TEXT,"
96+
"attempts INTEGER,failed_reason TEXT,created_at INTEGER,dead_lettered_at INTEGER);",
97+
0, 0, &err);
98+
assert(!err);
99+
// 4 dead rows (100,101,102,200). id 200 is ALSO already in _pending,
100+
// exercising the idempotency / "NOT IN (SELECT id FROM _pending_backup)"
101+
// branch of the replay query.
102+
sqlite3_exec(h,
103+
"INSERT INTO _dead_backup(id,payload,attempts,failed_reason,created_at,dead_lettered_at) VALUES"
104+
"(100,'PA',10,'max attempts exceeded',0,0),"
105+
"(101,'PB',10,'max attempts exceeded',0,0),"
106+
"(102,'PC',10,'max attempts exceeded',0,0),"
107+
"(200,'PD',5,'max attempts exceeded',0,0);"
108+
"INSERT INTO _pending_backup(id,payload,attempts,created_at) VALUES(200,'PD',0,0);",
109+
0, 0, &err);
110+
assert(!err);
111+
sqlite3_close(h);
112+
}
113+
114+
static int tests_run = 0, tests_passed = 0;
115+
#define RUN(fn) do { tests_run++; printf(" [%02d] %-52s ", tests_run, #fn); fflush(stdout); \
116+
fn(); tests_passed++; printf("PASS\n"); } while (0)
117+
118+
static void test_count_reports_dead(void) {
119+
seed();
120+
assert(run_dlq("--count") == 0);
121+
assert(count_in("_dead_backup") == 4);
122+
}
123+
124+
static void test_list_runs_clean(void) {
125+
seed();
126+
assert(run_dlq("--list --limit 2") == 0);
127+
assert(count_in("_dead_backup") == 4);
128+
assert(count_in("_pending_backup") == 1);
129+
}
130+
131+
static void test_dry_run_no_mutation(void) {
132+
seed();
133+
assert(run_dlq("--replay --dry-run") == 0);
134+
assert(count_in("_dead_backup") == 4); // unchanged
135+
assert(count_in("_pending_backup") == 1); // unchanged
136+
}
137+
138+
static void test_replay_single_id(void) {
139+
// THE regression: `--replay --id N` used to fail with
140+
// "replay prepare failed: near \"AND\": syntax error".
141+
seed();
142+
assert(run_dlq("--replay --id 101") == 0);
143+
assert(count_in("_dead_backup") == 3); // 101 removed
144+
assert(count_in("_pending_backup") == 2); // 101 added (200 still there)
145+
assert(attempts_of(101) == 0); // attempts reset on re-queue
146+
}
147+
148+
static void test_replay_id_already_pending(void) {
149+
seed();
150+
assert(run_dlq("--replay --id 200") == 0);
151+
assert(count_in("_dead_backup") == 3); // 200 removed from dead
152+
assert(count_in("_pending_backup") == 1); // not duplicated (INSERT OR IGNORE)
153+
assert(attempts_of(200) == 0);
154+
}
155+
156+
static void test_replay_id_nonexistent(void) {
157+
seed();
158+
assert(run_dlq("--replay --id 99999") == 0);
159+
assert(count_in("_dead_backup") == 4);
160+
assert(count_in("_pending_backup") == 1);
161+
}
162+
163+
static void test_replay_all(void) {
164+
seed();
165+
assert(run_dlq("--replay") == 0);
166+
assert(count_in("_dead_backup") == 0); // all drained
167+
assert(count_in("_pending_backup") == 4); // 100,101,102,200
168+
assert(attempts_of(100) == 0);
169+
assert(attempts_of(101) == 0);
170+
assert(attempts_of(102) == 0);
171+
}
172+
173+
static void test_replay_idempotent_second(void) {
174+
seed();
175+
assert(run_dlq("--replay") == 0);
176+
assert(run_dlq("--replay") == 0); // second run: nothing left to replay
177+
assert(count_in("_dead_backup") == 0);
178+
assert(count_in("_pending_backup") == 4);
179+
}
180+
181+
int main(void) {
182+
printf("=== Arkilian DLQ Tool Tests ===\n\n");
183+
RUN(test_count_reports_dead);
184+
RUN(test_list_runs_clean);
185+
RUN(test_dry_run_no_mutation);
186+
RUN(test_replay_single_id);
187+
RUN(test_replay_id_already_pending);
188+
RUN(test_replay_id_nonexistent);
189+
RUN(test_replay_all);
190+
RUN(test_replay_idempotent_second);
191+
cleanup_db();
192+
printf("\n=== Results: %d/%d passed ===\n", tests_passed, tests_run);
193+
return (tests_passed == tests_run) ? 0 : 1;
194+
}

tools/arkilian-dlq.c

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,17 @@ static int cmd_replay(const char *path, long long only_id, int dry_run) {
124124
return 1;
125125
}
126126

127-
const char *where = only_id > 0 ? "AND id = ?" : "";
127+
// Two filter fragments: the replay SELECT has no WHERE clause, so it
128+
// needs the "WHERE" form; count_sql and cleanup_sql already have a
129+
// WHERE clause, so they take the "AND" form. Using "AND" for the replay
130+
// SELECT produced "FROM _dead_backup AND id = ?" — a SQL syntax error
131+
// that broke `--replay --id N` entirely (regression caught at launch
132+
// verification). Verified before/after with the test_dlq suite.
133+
const char *where_replay = only_id > 0 ? " WHERE id = ?" : "";
134+
const char *where_extra = only_id > 0 ? " AND id = ?" : "";
128135
char *count_sql = sqlite3_mprintf(
129136
"SELECT COUNT(*) FROM _dead_backup "
130-
"WHERE id NOT IN (SELECT id FROM _pending_backup) %s", where);
137+
"WHERE id NOT IN (SELECT id FROM _pending_backup) %s", where_extra);
131138

132139
if (dry_run) {
133140
sqlite3_stmt *cst = NULL;
@@ -136,6 +143,7 @@ static int cmd_replay(const char *path, long long only_id, int dry_run) {
136143
if (rc == SQLITE_OK && only_id > 0) sqlite3_bind_int64(cst, 1, only_id);
137144
if (rc == SQLITE_OK && sqlite3_step(cst) == SQLITE_ROW) would = sqlite3_column_int(cst, 0);
138145
sqlite3_finalize(cst);
146+
sqlite3_free(count_sql);
139147
printf("%d row(s) would be replayed\n", would);
140148
sqlite3_close(db);
141149
return 0;
@@ -144,11 +152,11 @@ static int cmd_replay(const char *path, long long only_id, int dry_run) {
144152

145153
char *replay_sql = sqlite3_mprintf(
146154
"INSERT OR IGNORE INTO _pending_backup (id, payload, attempts, created_at) "
147-
"SELECT id, payload, 0, created_at FROM _dead_backup %s", where);
155+
"SELECT id, payload, 0, created_at FROM _dead_backup%s", where_replay);
148156
char *cleanup_sql = sqlite3_mprintf(
149157
"DELETE FROM _dead_backup WHERE id IN ("
150158
" SELECT id FROM _pending_backup WHERE id IN (SELECT id FROM _dead_backup)) %s",
151-
where);
159+
where_extra);
152160

153161
sqlite3_stmt *st = NULL;
154162
int rc;

0 commit comments

Comments
 (0)