|
| 1 | +// Arkilian Destination Backpressure Tests — 5,000-business launch |
| 2 | +// verification (Checklist #1: "If the backend returns 429/503 under load, |
| 3 | +// verify that client nodes gracefully buffer up to ARKILIAN_MAX_QUEUE_DEPTH |
| 4 | +// without dropping local database performance".) |
| 5 | +// |
| 6 | +// A mock HTTP server that always returns 503 Service Unavailable stands in |
| 7 | +// for an overwhelmed control plane. The test proves: |
| 8 | +// |
| 9 | +// 1. Every application write succeeds (spec §0: backup never breaks the |
| 10 | +// app) — even while the flush thread is retrying against the 503. |
| 11 | +// 2. Captured rows accumulate in _pending_backup (nothing is deleted on |
| 12 | +// a non-2xx response — at-least-once delivery, no silent data loss). |
| 13 | +// 3. Attempts climb but rows are NOT dead-lettered while the outage is |
| 14 | +// transient — they stay queued and retry with exponential backoff. |
| 15 | +// 4. The outbox cap (ARKILIAN_MAX_QUEUE_DEPTH) is respected: capture |
| 16 | +// pauses at the cap, but application writes keep succeeding. |
| 17 | +// 5. db_backup_is_healthy() == 0 (red) during the outage — the failure |
| 18 | +// is visible to monitoring, not silent. |
| 19 | +// |
| 20 | +// POSIX-only: uses BSD sockets for the mock server (same pattern as |
| 21 | +// test_kill_resilience.c). Not compiled on MinGW (no <sys/socket.h>). |
| 22 | +// |
| 23 | +// Compile (macOS/Linux): |
| 24 | +// cc tests/test_dst_backpressure.c src/class.c src/deps/sqlite/sqlite3.c \ |
| 25 | +// -Isrc -Isrc/deps/sqlite -lcurl -lpthread -o test_dst_backpressure |
| 26 | + |
| 27 | +#ifndef _POSIX_C_SOURCE |
| 28 | +#define _POSIX_C_SOURCE 200809L |
| 29 | +#endif |
| 30 | +#ifdef __APPLE__ |
| 31 | +#ifndef _DARWIN_C_SOURCE |
| 32 | +#define _DARWIN_C_SOURCE |
| 33 | +#endif |
| 34 | +#endif |
| 35 | +#ifndef _DEFAULT_SOURCE |
| 36 | +#define _DEFAULT_SOURCE |
| 37 | +#endif |
| 38 | + |
| 39 | +#include "class.h" |
| 40 | +#include <assert.h> |
| 41 | +#include <stdio.h> |
| 42 | +#include <stdlib.h> |
| 43 | +#include <string.h> |
| 44 | +#include <unistd.h> |
| 45 | +#include <pthread.h> |
| 46 | +#include <arpa/inet.h> |
| 47 | +#include <sys/socket.h> |
| 48 | +#include <netinet/in.h> |
| 49 | + |
| 50 | +static int tests_run = 0; |
| 51 | +static int tests_passed = 0; |
| 52 | + |
| 53 | +#define RUN_TEST(fn) \ |
| 54 | + do { \ |
| 55 | + tests_run++; \ |
| 56 | + printf(" [%02d] %-52s ", tests_run, #fn); \ |
| 57 | + fflush(stdout); \ |
| 58 | + fn(); \ |
| 59 | + tests_passed++; \ |
| 60 | + printf("PASS\n"); \ |
| 61 | + } while (0) |
| 62 | + |
| 63 | +static void cleanup(const char *path) { |
| 64 | + remove(path); |
| 65 | + char side[256]; |
| 66 | + snprintf(side, sizeof(side), "%s-wal", path); remove(side); |
| 67 | + snprintf(side, sizeof(side), "%s-shm", path); remove(side); |
| 68 | + snprintf(side, sizeof(side), "%s-journal", path); remove(side); |
| 69 | +} |
| 70 | + |
| 71 | +// ── Mock 503 destination ──────────────────────────────────────────── |
| 72 | +// Always returns 503 Service Unavailable, simulating an overwhelmed |
| 73 | +// control-plane ingestion layer. Records request count so the test can |
| 74 | +// verify the flush thread IS retrying (not silently dead). |
| 75 | + |
| 76 | +typedef struct { |
| 77 | + int listen_fd; |
| 78 | + int port; |
| 79 | + pthread_t thread; |
| 80 | + volatile int stop; |
| 81 | + volatile int requests; |
| 82 | + volatile int return_503; // 1 = 503, 0 = 200 (for flip-to-healthy test) |
| 83 | +} mock_503_server; |
| 84 | + |
| 85 | +static void *mock_503_run(void *arg) { |
| 86 | + mock_503_server *s = (mock_503_server *)arg; |
| 87 | + for (;;) { |
| 88 | + int fd = accept(s->listen_fd, NULL, NULL); |
| 89 | + if (fd < 0) break; |
| 90 | +#ifdef SO_NOSIGPIPE |
| 91 | + int on = 1; |
| 92 | + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); |
| 93 | +#endif |
| 94 | + char buf[16384]; |
| 95 | + ssize_t n = recv(fd, buf, sizeof(buf) - 1, 0); |
| 96 | + if (s->stop) { close(fd); break; } |
| 97 | + if (n > 0) { |
| 98 | + buf[n] = '\0'; |
| 99 | + // Drain the full request body (Content-Length) |
| 100 | + long body_len = 0; |
| 101 | + char *cl = strstr(buf, "Content-Length:"); |
| 102 | + if (cl) body_len = atol(cl + 15); |
| 103 | + char *hdr_end = strstr(buf, "\r\n\r\n"); |
| 104 | + long have = hdr_end ? n - (hdr_end + 4 - buf) : 0; |
| 105 | + while (have < body_len) { |
| 106 | + n = recv(fd, buf, sizeof(buf) - 1, 0); |
| 107 | + if (n <= 0) break; |
| 108 | + have += n; |
| 109 | + } |
| 110 | + s->requests++; |
| 111 | + const char *resp; |
| 112 | + if (s->return_503) { |
| 113 | + resp = "HTTP/1.1 503 Service Unavailable\r\n" |
| 114 | + "Content-Length: 0\r\n" |
| 115 | + "Connection: close\r\n" |
| 116 | + "Retry-After: 1\r\n" |
| 117 | + "\r\n"; |
| 118 | + } else { |
| 119 | + resp = "HTTP/1.1 200 OK\r\n" |
| 120 | + "Content-Length: 2\r\n" |
| 121 | + "Connection: close\r\n" |
| 122 | + "\r\nOK"; |
| 123 | + } |
| 124 | + send(fd, resp, strlen(resp), 0); |
| 125 | + } |
| 126 | + close(fd); |
| 127 | + } |
| 128 | + return NULL; |
| 129 | +} |
| 130 | + |
| 131 | +static int mock_503_start(mock_503_server *s) { |
| 132 | + memset(s, 0, sizeof(*s)); |
| 133 | + s->return_503 = 1; |
| 134 | + s->listen_fd = socket(AF_INET, SOCK_STREAM, 0); |
| 135 | + if (s->listen_fd < 0) return -1; |
| 136 | + int one = 1; |
| 137 | + setsockopt(s->listen_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); |
| 138 | + struct sockaddr_in addr; |
| 139 | + memset(&addr, 0, sizeof(addr)); |
| 140 | + addr.sin_family = AF_INET; |
| 141 | + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); |
| 142 | + addr.sin_port = 0; |
| 143 | + if (bind(s->listen_fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) return -1; |
| 144 | + socklen_t alen = sizeof(addr); |
| 145 | + if (getsockname(s->listen_fd, (struct sockaddr *)&addr, &alen) != 0) return -1; |
| 146 | + s->port = ntohs(addr.sin_port); |
| 147 | + if (listen(s->listen_fd, 64) != 0) return -1; |
| 148 | + if (pthread_create(&s->thread, NULL, mock_503_run, s) != 0) return -1; |
| 149 | + return 0; |
| 150 | +} |
| 151 | + |
| 152 | +static void mock_503_stop(mock_503_server *s) { |
| 153 | + s->stop = 1; |
| 154 | + // Kick the accept() loop |
| 155 | + int fd = socket(AF_INET, SOCK_STREAM, 0); |
| 156 | + if (fd >= 0) { |
| 157 | + struct sockaddr_in addr; |
| 158 | + memset(&addr, 0, sizeof(addr)); |
| 159 | + addr.sin_family = AF_INET; |
| 160 | + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); |
| 161 | + addr.sin_port = htons((unsigned short)s->port); |
| 162 | + connect(fd, (struct sockaddr *)&addr, sizeof(addr)); |
| 163 | + close(fd); |
| 164 | + } |
| 165 | + pthread_join(s->thread, NULL); |
| 166 | + close(s->listen_fd); |
| 167 | +} |
| 168 | + |
| 169 | +// ── Tests ─────────────────────────────────────────────────────────── |
| 170 | + |
| 171 | +// 1. Application writes survive a 503-spewing destination. Every INSERT |
| 172 | +// returns SQLITE_OK; rows accumulate in _pending_backup (none deleted |
| 173 | +// on a non-2xx); the flush thread IS retrying (requests > 0). |
| 174 | +static void test_writes_survive_503_backpressure(void) { |
| 175 | + const char *db_path = "test_bp_503.db"; |
| 176 | + cleanup(db_path); |
| 177 | + |
| 178 | + mock_503_server srv; |
| 179 | + assert(mock_503_start(&srv) == 0); |
| 180 | + |
| 181 | + char url[128]; |
| 182 | + snprintf(url, sizeof(url), "http://127.0.0.1:%d/push", srv.port); |
| 183 | + setenv("ARKILIAN_ENABLE_BACKUP", "1", 1); |
| 184 | + setenv("ARKILIAN_API_KEY", "test-key", 1); |
| 185 | + setenv("ARKILIAN_SKIP_STARTUP_AUTH", "1", 1); |
| 186 | + setenv("ARKILIAN_CONTROL_URL", url, 1); |
| 187 | + setenv("ARKILIAN_BACKUP_INTERVAL", "3600", 1); |
| 188 | + setenv("ARKILIAN_MAX_ATTEMPTS", "100", 1); // don't dead-letter during test |
| 189 | + |
| 190 | + arkilian *db = NULL; |
| 191 | + assert(db_init(&db, db_path) == 0); |
| 192 | + assert(db_exec(db, "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)") == SQLITE_OK); |
| 193 | + |
| 194 | + // Write 20 rows while the destination returns 503. |
| 195 | + for (int i = 0; i < 20; i++) { |
| 196 | + char sql[64]; |
| 197 | + snprintf(sql, sizeof(sql), "INSERT INTO t (v) VALUES ('row%d')", i); |
| 198 | + assert(db_exec(db, sql) == SQLITE_OK); |
| 199 | + } |
| 200 | + |
| 201 | + // Let the flush thread retry for a bit. |
| 202 | + sleep(2); |
| 203 | + |
| 204 | + // Every application write succeeded (spec §0: backup never breaks the app). |
| 205 | + db_prepare(db, "SELECT COUNT(*) FROM t"); |
| 206 | + assert(db_step(db) == SQLITE_ROW); |
| 207 | + assert(db_column_int(db, 0) == 20); |
| 208 | + db_finalize(db); |
| 209 | + |
| 210 | + // The flush thread IS retrying against the 503 — it didn't silently die. |
| 211 | + assert(srv.requests > 0); |
| 212 | + |
| 213 | + // Rows are NOT deleted on a non-2xx: they accumulate in _pending_backup. |
| 214 | + // (1 DDL capture + 20 inserts = 21 rows minimum; none shipped.) |
| 215 | + int depth = db_backup_queue_depth(db); |
| 216 | + assert(depth >= 21); |
| 217 | + |
| 218 | + // Health is red — the outage is visible to monitoring, not silent. |
| 219 | + assert(db_backup_is_healthy(db) == 0); |
| 220 | + |
| 221 | + db_close(db); |
| 222 | + mock_503_stop(&srv); |
| 223 | + cleanup(db_path); |
| 224 | + setenv("ARKILIAN_MAX_ATTEMPTS", "3", 1); // restore for other tests |
| 225 | +} |
| 226 | + |
| 227 | +// 2. The outbox cap (ARKILIAN_MAX_QUEUE_DEPTH) is respected under 503 |
| 228 | +// backpressure: capture pauses at the cap, but application writes keep |
| 229 | +// succeeding. No data is lost — the uncaptured writes are in the table; |
| 230 | +// the cap only prevents the outbox from growing without bound. |
| 231 | +static void test_outbox_cap_respected_under_503(void) { |
| 232 | + const char *db_path = "test_bp_cap.db"; |
| 233 | + cleanup(db_path); |
| 234 | + |
| 235 | + mock_503_server srv; |
| 236 | + assert(mock_503_start(&srv) == 0); |
| 237 | + |
| 238 | + char url[128]; |
| 239 | + snprintf(url, sizeof(url), "http://127.0.0.1:%d/push", srv.port); |
| 240 | + setenv("ARKILIAN_ENABLE_BACKUP", "1", 1); |
| 241 | + setenv("ARKILIAN_API_KEY", "test-key", 1); |
| 242 | + setenv("ARKILIAN_SKIP_STARTUP_AUTH", "1", 1); |
| 243 | + setenv("ARKILIAN_CONTROL_URL", url, 1); |
| 244 | + setenv("ARKILIAN_BACKUP_INTERVAL", "3600", 1); |
| 245 | + setenv("ARKILIAN_MAX_ATTEMPTS", "100", 1); |
| 246 | + setenv("ARKILIAN_MAX_QUEUE_DEPTH", "10", 1); // tight cap |
| 247 | + |
| 248 | + arkilian *db = NULL; |
| 249 | + assert(db_init(&db, db_path) == 0); |
| 250 | + assert(db_exec(db, "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)") == SQLITE_OK); |
| 251 | + |
| 252 | + // Write well past the cap. Every INSERT must still succeed. |
| 253 | + for (int i = 0; i < 50; i++) { |
| 254 | + char sql[64]; |
| 255 | + snprintf(sql, sizeof(sql), "INSERT INTO t (v) VALUES ('row%d')", i); |
| 256 | + assert(db_exec(db, sql) == SQLITE_OK); |
| 257 | + } |
| 258 | + |
| 259 | + // All 50 application writes succeeded (spec §0). |
| 260 | + db_prepare(db, "SELECT COUNT(*) FROM t"); |
| 261 | + assert(db_step(db) == SQLITE_ROW); |
| 262 | + assert(db_column_int(db, 0) == 50); |
| 263 | + db_finalize(db); |
| 264 | + |
| 265 | + // The outbox is hard-capped at 10. |
| 266 | + int depth = db_backup_queue_depth(db); |
| 267 | + assert(depth >= 1 && depth <= 10); |
| 268 | + |
| 269 | + // Health is red at cap. |
| 270 | + assert(db_backup_is_healthy(db) == 0); |
| 271 | + |
| 272 | + db_close(db); |
| 273 | + mock_503_stop(&srv); |
| 274 | + cleanup(db_path); |
| 275 | + setenv("ARKILIAN_MAX_QUEUE_DEPTH", "100000", 1); // restore default |
| 276 | + setenv("ARKILIAN_MAX_ATTEMPTS", "3", 1); |
| 277 | +} |
| 278 | + |
| 279 | +// 3. When the destination recovers (flips from 503 to 200), the backlog |
| 280 | +// drains. This proves the client is a self-healing buffer: no restart, |
| 281 | +// no operator intervention — the queue clears when the backend is back. |
| 282 | +static void test_backlog_drains_on_recovery(void) { |
| 283 | + const char *db_path = "test_bp_recover.db"; |
| 284 | + cleanup(db_path); |
| 285 | + |
| 286 | + mock_503_server srv; |
| 287 | + assert(mock_503_start(&srv) == 0); |
| 288 | + |
| 289 | + char url[128]; |
| 290 | + snprintf(url, sizeof(url), "http://127.0.0.1:%d/push", srv.port); |
| 291 | + setenv("ARKILIAN_ENABLE_BACKUP", "1", 1); |
| 292 | + setenv("ARKILIAN_API_KEY", "test-key", 1); |
| 293 | + setenv("ARKILIAN_SKIP_STARTUP_AUTH", "1", 1); |
| 294 | + setenv("ARKILIAN_CONTROL_URL", url, 1); |
| 295 | + setenv("ARKILIAN_BACKUP_INTERVAL", "3600", 1); |
| 296 | + setenv("ARKILIAN_MAX_ATTEMPTS", "100", 1); |
| 297 | + |
| 298 | + arkilian *db = NULL; |
| 299 | + assert(db_init(&db, db_path) == 0); |
| 300 | + assert(db_exec(db, "CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)") == SQLITE_OK); |
| 301 | + |
| 302 | + // Write 10 rows under 503 backpressure. |
| 303 | + for (int i = 0; i < 10; i++) { |
| 304 | + char sql[64]; |
| 305 | + snprintf(sql, sizeof(sql), "INSERT INTO t (v) VALUES ('row%d')", i); |
| 306 | + assert(db_exec(db, sql) == SQLITE_OK); |
| 307 | + } |
| 308 | + sleep(1); // let the flush thread retry against 503 |
| 309 | + |
| 310 | + // Queue has accumulated (nothing shipped yet). |
| 311 | + int depth_before = db_backup_queue_depth(db); |
| 312 | + assert(depth_before >= 11); // 1 DDL + 10 inserts |
| 313 | + |
| 314 | + // Destination recovers: flip the server from 503 to 200. |
| 315 | + srv.return_503 = 0; |
| 316 | + db_wal_flush(db); // wake the flush thread immediately |
| 317 | + |
| 318 | + // Wait for the backlog to drain (at most ~15s with 100-row cap + backoff). |
| 319 | + int waited = 0; |
| 320 | + while (db_backup_queue_depth(db) > 0 && waited < 15000) { |
| 321 | + usleep(200 * 1000); |
| 322 | + waited += 200; |
| 323 | + } |
| 324 | + assert(db_backup_queue_depth(db) == 0); |
| 325 | + |
| 326 | + // All rows were delivered (the mock server received them all). |
| 327 | + assert(srv.requests > 10); |
| 328 | + |
| 329 | + db_close(db); |
| 330 | + mock_503_stop(&srv); |
| 331 | + cleanup(db_path); |
| 332 | + setenv("ARKILIAN_MAX_ATTEMPTS", "3", 1); |
| 333 | +} |
| 334 | + |
| 335 | +// ── Main ──────────────────────────────────────────────────────────── |
| 336 | + |
| 337 | +int main(void) { |
| 338 | + signal(SIGPIPE, SIG_IGN); |
| 339 | + setenv("ARKILIAN_API_KEY", "test-key", 1); |
| 340 | + setenv("ARKILIAN_SKIP_STARTUP_AUTH", "1", 1); |
| 341 | + setenv("ARKILIAN_MAX_ATTEMPTS", "3", 1); |
| 342 | + |
| 343 | + printf("=== Arkilian Destination Backpressure Tests ===\n\n"); |
| 344 | + RUN_TEST(test_writes_survive_503_backpressure); |
| 345 | + RUN_TEST(test_outbox_cap_respected_under_503); |
| 346 | + RUN_TEST(test_backlog_drains_on_recovery); |
| 347 | + printf("\n=== Results: %d/%d passed ===\n", tests_passed, tests_run); |
| 348 | + return (tests_passed == tests_run) ? 0 : 1; |
| 349 | +} |
0 commit comments