@sproutboat/runtime 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,7 @@ Porffor.c`
27
27
  #include <string.h>
28
28
  #include <strings.h>
29
29
  #include <sys/time.h>
30
+ #include <time.h>
30
31
  #include <pthread.h>
31
32
  #include <bearssl.h>
32
33
 
@@ -65,6 +66,11 @@ extern int sqlite3_extended_errcode(sqlite3*);
65
66
  extern int sqlite3_error_offset(sqlite3*);
66
67
  extern const char* sqlite3_sql(sqlite3_stmt*);
67
68
  extern int sqlite3_total_changes(sqlite3*);
69
+ extern int sqlite3_get_autocommit(sqlite3*);
70
+ extern int sqlite3_db_status(sqlite3*, int, int*, int*, int);
71
+ extern int sqlite3_db_release_memory(sqlite3*);
72
+ extern long long sqlite3_soft_heap_limit64(long long);
73
+ extern long long sqlite3_memory_used(void);
68
74
 
69
75
  // baronunread/sproutboat#176 — defined in render.js, alongside
70
76
  // porf_native_fetch_read_value (which every inline-C block below already
@@ -79,7 +85,7 @@ extern int porf_native_fetch_read_raw_bytes(jsval value, const char** out_buf, s
79
85
  // SQLITE_STATIC: "this buffer is yours to read and outlives the statement".
80
86
  // Worth using for an object body — the alternative is sqlite copying it.
81
87
  #define SB_SQLITE_STATIC ((void*)0)
82
- #define SB_MAX_DB 16
88
+ #define SB_MAX_DB 64
83
89
 
84
90
  static sqlite3* sb_dbs[SB_MAX_DB];
85
91
  static char sb_db_names[SB_MAX_DB][256];
@@ -1173,8 +1179,9 @@ static int sb_ltx_opted(const char *path) {
1173
1179
  }
1174
1180
  // END LTX NATIVE
1175
1181
 
1176
- // One handle per path, opened once and kept for the process lifetime — the same
1177
- // lifetime the broker gives a Database. Returns an index, or -1.
1182
+ // One handle per path, kept open until the slot is needed (Durable Object SQL) or for the process lifetime
1183
+ // (everything else). Returns an index, or -1.
1184
+ // BEGIN DB TABLE NATIVE: also compiled directly by the handle-table regression (packages/runtime/test/native-db-table.ts).
1178
1185
  // Create every parent directory of path, like mkdir -p. A standalone binary
1179
1186
  // has no launcher to prepare its data directory, and sqlite3_open creates files
1180
1187
  // but never the directories above them.
@@ -1191,13 +1198,31 @@ static void sb_mkdirs(const char* path) {
1191
1198
  }
1192
1199
  }
1193
1200
 
1194
- // Durable Objects SQL (storage.sql): one database per object, under "/do-sql/". At most
1195
- // SB_DO_SQL_OPEN of them stay open; the one used longest ago is closed for the next.
1196
- #define SB_DO_SQL_OPEN 8
1201
+ // Durable Objects SQL (storage.sql): one database per object, under "/do-sql/". At most sb_do_sql_open of them
1202
+ // stay open (8 until the memory limit is known, then clamp(T / 160 KiB, 8, 48); 48 with no limit); the one used
1203
+ // longest ago, and not inside a transaction, is closed for the next. A Durable Object instance leaving memory closes
1204
+ // its handle at once (op do.sql.close).
1205
+ static int sb_do_sql_open = 8;
1197
1206
  static unsigned long sb_db_used[SB_MAX_DB];
1198
1207
  static unsigned long sb_db_tick = 0;
1208
+ static int sb_db_last = -1;
1199
1209
  static int sb_is_do_sql(const char* path) { return strstr(path, "/do-sql/") != 0; }
1200
- static void sb_db_evict(int idx);
1210
+ static int sb_db_close(int idx);
1211
+
1212
+ // SQLite page caches (plan 016 section 5 as plan 018 phase 1 delivers it). Every database starts at 256 KiB. T is the
1213
+ // target for all of them together: SB_MEMORY_LIMIT / 8 (4 MiB at 32 MiB), none without a limit. Each database may
1214
+ // grow, on evidence, to min(2 MiB, T / open databases); when a new one opens and the sizes no longer fit under T the
1215
+ // stalest ones go back to their share. SB_SQLITE_CACHE_MB, when set, is the old fixed size and turns all of this off.
1216
+ #define SB_CACHE_START_KB 256
1217
+ #define SB_CACHE_MIN_KB 64
1218
+ #define SB_CACHE_MAX_KB 2048
1219
+ static long sb_cache_target_kb = 0;
1220
+ static int sb_cache_fixed_kb = 0;
1221
+ static int sb_cache_kb[SB_MAX_DB];
1222
+ static unsigned long sb_db_calls[SB_MAX_DB];
1223
+ static long sb_db_changes0[SB_MAX_DB];
1224
+ static unsigned long sb_stat_closes, sb_stat_busy, sb_stat_ckpt_fail, sb_stat_grow, sb_stat_shrink, sb_stat_opens;
1225
+ static long sb_stat_open_us_last, sb_stat_open_us_max;
1201
1226
 
1202
1227
  // SQLite allocates 20 pages in one block for every page cache it creates, and a statement that
1203
1228
  // builds a temporary table (INSERT ... RETURNING, a sort) creates one: an 88 KiB mmap and munmap
@@ -1209,33 +1234,113 @@ static void sb_sqlite_boot(void) {
1209
1234
  if (sb_sqlite_booted) return;
1210
1235
  sb_sqlite_booted = 1;
1211
1236
  sqlite3_config(7 /* SQLITE_CONFIG_PAGECACHE */, (void*)0, 0, 0);
1237
+ const char* mb = getenv("SB_SQLITE_CACHE_MB");
1238
+ long mib = mb && *mb ? strtol(mb, 0, 10) : 0;
1239
+ if (mib >= 1 && mib <= 4096) sb_cache_fixed_kb = (int)(mib * 1024);
1240
+ const char* limit = getenv("SB_MEMORY_LIMIT");
1241
+ unsigned long long bytes = limit && *limit ? strtoull(limit, 0, 10) : 0;
1242
+ if (bytes >= 8ull << 20) {
1243
+ sb_cache_target_kb = (long)(bytes / 8 / 1024);
1244
+ long slots = sb_cache_target_kb / 160;
1245
+ sb_do_sql_open = slots < 8 ? 8 : slots > 48 ? 48 : (int)slots;
1246
+ // SQLite frees cache pages of every database before it goes over T; one statement that needs more still succeeds.
1247
+ sqlite3_soft_heap_limit64((long long)(bytes / 8));
1248
+ } else sb_do_sql_open = 48;
1249
+ }
1250
+ static int sb_open_dbs(void) {
1251
+ int n = 0;
1252
+ for (int i = 0; i < sb_db_count; i++) if (sb_dbs[i]) n++;
1253
+ return n;
1254
+ }
1255
+ // The most one database may hold now: min(2 MiB, T / open databases), and never under 64 KiB.
1256
+ static int sb_cache_share_kb(void) {
1257
+ if (!sb_cache_target_kb) return SB_CACHE_MAX_KB;
1258
+ int open = sb_open_dbs();
1259
+ long share = sb_cache_target_kb / (open ? open : 1);
1260
+ return share < SB_CACHE_MIN_KB ? SB_CACHE_MIN_KB : share > SB_CACHE_MAX_KB ? SB_CACHE_MAX_KB : (int)share;
1261
+ }
1262
+ static void sb_cache_set(int idx, int kb) {
1263
+ char pragma[48];
1264
+ snprintf(pragma, sizeof(pragma), "PRAGMA cache_size=-%d", kb);
1265
+ sqlite3_exec(sb_dbs[idx], pragma, 0, 0, 0);
1266
+ sb_cache_kb[idx] = kb;
1267
+ }
1268
+ // After a database opens: while the sizes together are over T, the stalest database above its share goes back to it.
1269
+ static void sb_cache_rebalance(int keep) {
1270
+ if (!sb_cache_target_kb || sb_cache_fixed_kb) return;
1271
+ for (;;) {
1272
+ long sum = 0;
1273
+ int share = sb_cache_share_kb(), victim = -1;
1274
+ for (int i = 0; i < sb_db_count; i++) {
1275
+ if (!sb_dbs[i]) continue;
1276
+ sum += sb_cache_kb[i];
1277
+ if (i != keep && sb_cache_kb[i] > share && (victim < 0 || sb_db_used[i] < sb_db_used[victim])) victim = i;
1278
+ }
1279
+ if (sum <= sb_cache_target_kb || victim < 0) return;
1280
+ sb_cache_set(victim, share);
1281
+ sqlite3_exec(sb_dbs[victim], "PRAGMA shrink_memory", 0, 0, 0);
1282
+ sb_stat_shrink++;
1283
+ }
1284
+ }
1285
+ // Every 64th use of a database: over 90% full and over 10% misses since the last look doubles its cache, up to its share.
1286
+ static void sb_cache_tune(int idx) {
1287
+ if (sb_cache_fixed_kb || sb_cache_kb[idx] >= SB_CACHE_MAX_KB) return;
1288
+ int used = 0, hit = 0, miss = 0, high = 0;
1289
+ sqlite3_db_status(sb_dbs[idx], 1 /* CACHE_USED */, &used, &high, 0);
1290
+ sqlite3_db_status(sb_dbs[idx], 7 /* CACHE_HIT */, &hit, &high, 1);
1291
+ sqlite3_db_status(sb_dbs[idx], 8 /* CACHE_MISS */, &miss, &high, 1);
1292
+ if ((long long)used * 10 < (long long)sb_cache_kb[idx] * 1024 * 9 || (long long)miss * 10 <= (long long)(hit + miss)) return;
1293
+ int share = sb_cache_share_kb(), next = sb_cache_kb[idx] * 2;
1294
+ if (next > share) next = share;
1295
+ if (next <= sb_cache_kb[idx]) return;
1296
+ sb_cache_set(idx, next);
1297
+ sb_stat_grow++;
1298
+ sb_cache_rebalance(idx);
1299
+ }
1300
+ static void sb_db_touch(int i) {
1301
+ sb_db_used[i] = ++sb_db_tick;
1302
+ sb_db_last = i;
1303
+ if ((++sb_db_calls[i] & 63) == 0) sb_cache_tune(i);
1304
+ }
1305
+ static long sb_now_us(void) {
1306
+ struct timespec t;
1307
+ clock_gettime(CLOCK_MONOTONIC, &t);
1308
+ return (long)t.tv_sec * 1000000L + t.tv_nsec / 1000;
1212
1309
  }
1213
1310
  static int sb_db_for(const char* path) {
1311
+ if (sb_db_last >= 0 && sb_dbs[sb_db_last] && strcmp(sb_db_names[sb_db_last], path) == 0) {
1312
+ sb_db_touch(sb_db_last);
1313
+ return sb_ltx_states[sb_db_last].failed ? -1 : sb_db_last;
1314
+ }
1214
1315
  for (int i = 0; i < sb_db_count; i++) {
1215
1316
  if (sb_dbs[i] && strcmp(sb_db_names[i], path) == 0) {
1216
- sb_db_used[i] = ++sb_db_tick;
1317
+ sb_db_touch(i);
1217
1318
  return sb_ltx_states[i].failed ? -1 : i;
1218
1319
  }
1219
1320
  }
1220
1321
  sb_sqlite_boot();
1322
+ const long t0 = sb_now_us();
1221
1323
  const int do_sql = sb_is_do_sql(path);
1222
1324
  // A name that would not fit could never be found again.
1223
1325
  if (do_sql && strlen(path) >= sizeof(sb_db_names[0])) return -1;
1224
1326
  int replicated = do_sql ? 0 : sb_ltx_opted(path), lock = -1;
1225
1327
  if (replicated < 0 || (replicated && strlen(path) >= sizeof(sb_db_names[0]))) return -1;
1226
- // A Durable Object SQL database reuses the slot of the one used longest ago once
1227
- // SB_DO_SQL_OPEN are open; any other database takes one when every slot is in use.
1228
- int slot = sb_db_count, lru = -1, open_do_sql = 0;
1328
+ // A closed slot is reused first. A Durable Object SQL database takes the slot of the one used longest ago
1329
+ // (outside a transaction) once sb_do_sql_open are open; any other database does the same when every slot is in use.
1330
+ int slot = -1, lru = -1, open_do_sql = 0;
1229
1331
  for (int i = 0; i < sb_db_count; i++) {
1230
- if (!sb_dbs[i] || !sb_is_do_sql(sb_db_names[i])) continue;
1332
+ if (!sb_dbs[i]) { if (slot < 0) slot = i; continue; }
1333
+ if (!sb_is_do_sql(sb_db_names[i])) continue;
1231
1334
  open_do_sql++;
1232
- if (lru < 0 || sb_db_used[i] < sb_db_used[lru]) lru = i;
1335
+ if (sqlite3_get_autocommit(sb_dbs[i]) && (lru < 0 || sb_db_used[i] < sb_db_used[lru])) lru = i;
1233
1336
  }
1234
- if (lru >= 0 && !replicated && ((do_sql && open_do_sql >= SB_DO_SQL_OPEN) || sb_db_count >= SB_MAX_DB)) {
1235
- sb_db_evict(lru);
1337
+ if (slot < 0 && sb_db_count < SB_MAX_DB) slot = sb_db_count;
1338
+ if (!replicated && ((do_sql && open_do_sql >= sb_do_sql_open) || slot < 0)) {
1339
+ // Every candidate inside a transaction, or none open: the new database cannot be opened now.
1340
+ if (lru < 0 || sb_db_close(lru)) return -1;
1236
1341
  slot = lru;
1237
1342
  }
1238
- if (slot >= SB_MAX_DB) return -1;
1343
+ if (slot < 0) return -1;
1239
1344
  sqlite3* db = 0;
1240
1345
  sb_mkdirs(path);
1241
1346
  if (replicated) {
@@ -1266,16 +1371,18 @@ static int sb_db_for(const char* path) {
1266
1371
  // The transport itself only reads the variable, so a host that embeds it (sproutd, the broker) stays
1267
1372
  // in shared mode unless it is set. A standalone binary sets it to 1 at startup (patch.ts, the shim's
1268
1373
  // constructor) unless the operator has set it, so SB_SQLITE_EXCLUSIVE=0 opts a standalone binary out.
1269
- // SB_SQLITE_CACHE_MB sizes each database's page cache in MiB (default 2, about -2000 KiB).
1374
+ // The page cache: see the sizing comment above; SB_SQLITE_CACHE_MB is a fixed size in MiB.
1270
1375
  {
1271
1376
  const char* excl = getenv("SB_SQLITE_EXCLUSIVE");
1272
1377
  if (excl && excl[0] == '1' && !excl[1] && !replicated) sqlite3_exec(db, "PRAGMA locking_mode=EXCLUSIVE", 0, 0, 0);
1273
- char cache[64];
1274
- const char* mb = getenv("SB_SQLITE_CACHE_MB");
1275
- long mib = mb && *mb ? strtol(mb, 0, 10) : 0;
1276
- if (mib >= 1 && mib <= 4096) snprintf(cache, sizeof(cache), "PRAGMA cache_size=-%ld", mib * 1024);
1277
- else snprintf(cache, sizeof(cache), "PRAGMA cache_size=-2000");
1278
- sqlite3_exec(db, cache, 0, 0, 0);
1378
+ }
1379
+ sb_dbs[slot] = db;
1380
+ snprintf(sb_db_names[slot], 256, "%s", path);
1381
+ if (slot >= sb_db_count) sb_db_count = slot + 1; // before the sizing below, which counts open databases
1382
+ {
1383
+ const int share = sb_cache_share_kb();
1384
+ const int kb = sb_cache_fixed_kb ? sb_cache_fixed_kb : (SB_CACHE_START_KB < share ? SB_CACHE_START_KB : share);
1385
+ sb_cache_set(slot, kb);
1279
1386
  }
1280
1387
  sqlite3_exec(db,
1281
1388
  "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000;"
@@ -1283,9 +1390,10 @@ static int sb_db_for(const char* path) {
1283
1390
  0, 0, 0);
1284
1391
  // Durable Object SQLite enforces foreign keys, as in workerd.
1285
1392
  if (do_sql) sqlite3_exec(db, "PRAGMA foreign_keys=ON", 0, 0, 0);
1393
+ sb_db_changes0[slot] = sqlite3_total_changes(db);
1394
+ sb_db_calls[slot] = 0;
1286
1395
  sb_db_used[slot] = ++sb_db_tick;
1287
- sb_dbs[slot] = db;
1288
- snprintf(sb_db_names[slot], 256, "%s", path);
1396
+ sb_db_last = slot;
1289
1397
  if (replicated && sb_ltx_start(slot, db, path, lock)) {
1290
1398
  sb_ltx_states[slot].failed = 1;
1291
1399
  fprintf(stderr, "LTX initialization failed for %s\n", path);
@@ -1293,10 +1401,12 @@ static int sb_db_for(const char* path) {
1293
1401
  if (sb_ltx_states[slot].read_fd >= 0) close(sb_ltx_states[slot].read_fd);
1294
1402
  sb_ltx_states[slot].read_fd = -1;
1295
1403
  sb_dbs[slot] = 0;
1296
- if (slot == sb_db_count) sb_db_count++;
1297
1404
  close(lock); return -1;
1298
1405
  }
1299
- if (slot == sb_db_count) sb_db_count++;
1406
+ sb_stat_opens++;
1407
+ sb_cache_rebalance(slot);
1408
+ sb_stat_open_us_last = sb_now_us() - t0;
1409
+ if (sb_stat_open_us_last > sb_stat_open_us_max) sb_stat_open_us_max = sb_stat_open_us_last;
1300
1410
  return slot;
1301
1411
  }
1302
1412
 
@@ -1308,7 +1418,7 @@ static int sb_db_for(const char* path) {
1308
1418
  // ponytail: FIFO eviction over 32 slots, linear scan, no lock (the runtime
1309
1419
  // serves one turn at a time). Move to LRU + a hash if a real app keeps >32 hot
1310
1420
  // statements per db, or if execution ever overlaps. Entries are never finalized
1311
- // on shutdown — sb_db_for never closes a handle, the process owns it for life.
1421
+ // on shutdown; sb_db_close finalizes a database's entries when its handle goes.
1312
1422
  #define SB_STMT_CACHE 32
1313
1423
  typedef struct { char* sql; sqlite3_stmt* st; } sb_cached_stmt;
1314
1424
  static sb_cached_stmt sb_stmt_cache[SB_MAX_DB][SB_STMT_CACHE];
@@ -1336,9 +1446,18 @@ static sqlite3_stmt* sb_stmt_get(int idx, sqlite3* db, const char* sql) {
1336
1446
  return st;
1337
1447
  }
1338
1448
 
1339
- // Close a Durable Object SQL database to reuse its slot: its cached statements first.
1449
+ // The one place a handle is closed: the slot LRU, a Durable Object instance leaving memory (do.sql.close), and later
1450
+ // a capturing handle (plan 013 phase D extends it at the two marked steps and removes the LRU's capture guard; it must not
1451
+ // add a second close path). The order is a contract:
1340
1452
  static unsigned char sb_quota_frozen[SB_MAX_DB];
1341
- static void sb_db_evict(int idx) {
1453
+ // Returns 0 when the handle is closed (or was not open), -1 when it is left open: a statement outlives its call
1454
+ // (SQLITE_BUSY: counted, never sqlite3_close_v2), or the connection is inside a transaction.
1455
+ static int sb_db_close(int idx) {
1456
+ sqlite3* db = sb_dbs[idx];
1457
+ if (!db) return 0;
1458
+ // A savepoint or transaction held across calls: closing would roll it back silently.
1459
+ if (!sqlite3_get_autocommit(db)) return -1;
1460
+ // 1. finalize the slot's cached statements;
1342
1461
  for (int i = 0; i < SB_STMT_CACHE; i++) {
1343
1462
  if (sb_stmt_cache[idx][i].st) sqlite3_finalize(sb_stmt_cache[idx][i].st);
1344
1463
  free(sb_stmt_cache[idx][i].sql);
@@ -1346,11 +1465,61 @@ static void sb_db_evict(int idx) {
1346
1465
  sb_stmt_cache[idx][i].sql = 0;
1347
1466
  }
1348
1467
  sb_stmt_fifo[idx] = 0;
1349
- sqlite3_close(sb_dbs[idx]);
1468
+ // 2. (phase D inserts: unhook capture, write what is pending)
1469
+ // 3. if the connection wrote since it was opened, a TRUNCATE checkpoint and its result. With synchronous=NORMAL this is
1470
+ // the fsync that makes the data durable on the local disk. A read-only open skips it; sqlite3_close below still
1471
+ // checkpoints a WAL that holds schema changes (they do not count as changes), as it does for the last connection.
1472
+ if (sqlite3_total_changes(db) != sb_db_changes0[idx]) {
1473
+ int frames = 0, done = 0;
1474
+ if (sqlite3_wal_checkpoint_v2(db, "main", 3 /* TRUNCATE */, &frames, &done)) sb_stat_ckpt_fail++;
1475
+ }
1476
+ // 4. (phase D inserts: the clean position record)
1477
+ // 5. sqlite3_close must return SQLITE_OK. SQLITE_BUSY means a leaked statement: the slot stays open.
1478
+ if (sqlite3_close(db)) {
1479
+ sb_stat_busy++;
1480
+ return -1;
1481
+ }
1482
+ // 6. only then close any raw descriptor on the same inode (capture's read_fd and lock): fcntl locks belong to the
1483
+ // process, so closing a descriptor of the file while the SQLite handle is open would drop SQLite's locks. Object
1484
+ // SQL has none; the order is written so that phase D cannot get it wrong.
1485
+ // 7. clear the slot.
1486
+ if (sb_ltx_states[idx].db) memset(&sb_ltx_states[idx], 0, sizeof(sb_ltx_states[idx]));
1350
1487
  sb_dbs[idx] = 0;
1351
1488
  sb_db_names[idx][0] = 0;
1352
1489
  sb_quota_frozen[idx] = 0;
1490
+ sb_cache_kb[idx] = 0;
1491
+ sb_db_calls[idx] = 0;
1492
+ if (sb_db_last == idx) sb_db_last = -1;
1493
+ sb_stat_closes++;
1494
+ return 0;
1353
1495
  }
1496
+ // do.sql.close: the handle of one path. 0: not open, 1: closed, -1: left open.
1497
+ static int sb_db_close_path(const char* path) {
1498
+ for (int i = 0; i < sb_db_count; i++)
1499
+ if (sb_dbs[i] && strcmp(sb_db_names[i], path) == 0) return sb_db_close(i) ? -1 : 1;
1500
+ return 0;
1501
+ }
1502
+ // Hands SQLite's cached pages back, from every open database; returns the bytes SQLite memory fell by.
1503
+ static long long sb_db_release_all(void) {
1504
+ long long before = sqlite3_memory_used();
1505
+ for (int i = 0; i < sb_db_count; i++) if (sb_dbs[i]) sqlite3_db_release_memory(sb_dbs[i]);
1506
+ return before - sqlite3_memory_used();
1507
+ }
1508
+ // The handle table's counters as one JSON line, for the host note (plan 018) and the tests.
1509
+ static int sb_db_stats(char* out, size_t cap) {
1510
+ int open = 0, open_do = 0;
1511
+ long cache_kb = 0;
1512
+ for (int i = 0; i < sb_db_count; i++) {
1513
+ if (!sb_dbs[i]) continue;
1514
+ open++;
1515
+ cache_kb += sb_cache_kb[i];
1516
+ if (sb_is_do_sql(sb_db_names[i])) open_do++;
1517
+ }
1518
+ return snprintf(out, cap,
1519
+ "{\"open\":%d,\"doOpen\":%d,\"doSlots\":%d,\"cacheKb\":%ld,\"targetKb\":%ld,\"memUsed\":%lld,\"opens\":%lu,\"closes\":%lu,\"busy\":%lu,\"ckptFail\":%lu,\"grow\":%lu,\"shrink\":%lu,\"openUsLast\":%ld,\"openUsMax\":%ld}",
1520
+ open, open_do, sb_do_sql_open, cache_kb, sb_cache_target_kb, sqlite3_memory_used(), sb_stat_opens, sb_stat_closes, sb_stat_busy, sb_stat_ckpt_fail, sb_stat_grow, sb_stat_shrink, sb_stat_open_us_last, sb_stat_open_us_max);
1521
+ }
1522
+ // END DB TABLE NATIVE
1354
1523
 
1355
1524
  // --- a growable output buffer, for building the reply JSON ------------------
1356
1525
  typedef struct { char* p; size_t len; size_t cap; } sb_buf;
@@ -2232,7 +2401,7 @@ static void sb_xfer_download_close(int handle) {
2232
2401
  // every write is refused (a same-size UPDATE or a DELETE still adds an LTX file to the
2233
2402
  // spool, which counts toward the quota). Reads and transaction control still run.
2234
2403
  #define SB_QUOTA_FULL "storage quota exceeded: writes are refused while this app's data, including its replication log, is over its quota"
2235
- // sb_quota_frozen (per database slot) is declared with sb_db_evict, which clears it.
2404
+ // sb_quota_frozen (per database slot) is declared with sb_db_close, which clears it.
2236
2405
  static int sb_db_capture_failed(const char* path) {
2237
2406
  for (int i = 0; i < sb_db_count; i++) if (strcmp(sb_db_names[i], path) == 0) return sb_ltx_states[i].failed;
2238
2407
  return 0;
@@ -2549,6 +2718,7 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
2549
2718
 
2550
2719
  // The storage quota (docs: an app's data directory is capped): bytes in regular files under
2551
2720
  // dir, symlinks not followed. Bounded by depth; an unreadable entry counts as 0.
2721
+ static long sb_dir_files; // regular files counted by the last walk (the quota scan's interval grows with it)
2552
2722
  static long long sb_dir_bytes(const char* dir, int depth) {
2553
2723
  DIR* d = opendir(dir);
2554
2724
  if (!d || depth > 16) { if (d) closedir(d); return 0; }
@@ -2560,7 +2730,7 @@ static long long sb_dir_bytes(const char* dir, int depth) {
2560
2730
  if (snprintf(path, sizeof(path), "%s/%s", dir, e->d_name) >= (int)sizeof(path)) continue;
2561
2731
  struct stat st;
2562
2732
  if (lstat(path, &st)) continue;
2563
- if (S_ISREG(st.st_mode)) total += (long long)st.st_size;
2733
+ if (S_ISREG(st.st_mode)) { total += (long long)st.st_size; sb_dir_files++; }
2564
2734
  else if (S_ISDIR(st.st_mode)) total += sb_dir_bytes(path, depth + 1);
2565
2735
  }
2566
2736
  closedir(d);
@@ -4610,7 +4780,7 @@ function __sbSqlScriptRaw(path, sql) {
4610
4780
  return res;
4611
4781
  }
4612
4782
 
4613
- // The bytes under a directory, as a decimal string (sb_dir_bytes).
4783
+ // The bytes under a directory and the number of regular files, as "BYTES FILES" (sb_dir_bytes).
4614
4784
  // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
4615
4785
  function __sbDirBytesRaw(dir) {
4616
4786
  let res = "";
@@ -4620,13 +4790,50 @@ function __sbDirBytesRaw(dir) {
4620
4790
  porf_native_fetch_read_value(dir, &__d, &__dl, &__do);
4621
4791
  char* __dir = (char*)malloc(__dl + 1); memcpy(__dir, __d, __dl); __dir[__dl] = 0;
4622
4792
  if (__do) free(__do);
4623
- char __n[32]; int __k = snprintf(__n, sizeof(__n), "%lld", sb_dir_bytes(__dir, 0));
4793
+ char __n[64]; sb_dir_files = 0; long long __b = sb_dir_bytes(__dir, 0); int __k = snprintf(__n, sizeof(__n), "%lld %ld", __b, sb_dir_files);
4624
4794
  free(__dir);
4625
4795
  res = porf_box((f64)porf_native_fetch_alloc_bytestring(__n, (size_t)__k), 195);
4626
4796
  `;
4627
4797
  return res;
4628
4798
  }
4629
4799
 
4800
+ // Closes the handle of one database path (sb_db_close_path): 1 closed, 0 not open, -1 left open.
4801
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
4802
+ function __sbDbCloseRaw(path) {
4803
+ let status = 0;
4804
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
4805
+ Porffor.c`
4806
+ const char* __p; size_t __pl; char* __po = 0;
4807
+ porf_native_fetch_read_value(path, &__p, &__pl, &__po);
4808
+ char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
4809
+ if (__po) free(__po);
4810
+ status = (f64)sb_db_close_path(__path);
4811
+ free(__path);
4812
+ `;
4813
+ return status;
4814
+ }
4815
+
4816
+ // The handle table's counters, one JSON line (sb_db_stats).
4817
+ function __sbDbStatsRaw() {
4818
+ let res = "";
4819
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
4820
+ Porffor.c`
4821
+ char __n[512]; int __k = sb_db_stats(__n, sizeof(__n));
4822
+ res = porf_box((f64)porf_native_fetch_alloc_bytestring(__n, (size_t)__k), 195);
4823
+ `;
4824
+ return res;
4825
+ }
4826
+
4827
+ // Hands SQLite's cached pages back (sb_db_release_all); returns the bytes released.
4828
+ function __sbDbReleaseRaw() {
4829
+ let freed = 0;
4830
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
4831
+ Porffor.c`
4832
+ freed = (f64)sb_db_release_all();
4833
+ `;
4834
+ return freed;
4835
+ }
4836
+
4630
4837
  // "captured" of __sbD1DbRaw as a number: no text made in C and parsed here, on every D1 query.
4631
4838
  // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
4632
4839
  function __sbD1Captured_(path) {
@@ -4972,6 +5179,9 @@ function __sbDir() {
4972
5179
  var __sbQuotaMax = -1;
4973
5180
  var __sbQuotaUsed = 0;
4974
5181
  var __sbQuotaAt = -1;
5182
+ // Milliseconds between scans of the data directory: one lstat per file, so 1 s per 1,000 files and at most 10 s (7 ms of
5183
+ // walking at 5,000 files, 20 ms at 15,000, measured). Writes can overshoot the quota by up to that long.
5184
+ var __sbQuotaEvery = 1000;
4975
5185
  function __sbQuotaLimit() {
4976
5186
  if (__sbQuotaMax < 0) {
4977
5187
  const v = Number(__sbEnv("SB_STORAGE_MAX_BYTES"));
@@ -4981,8 +5191,10 @@ function __sbQuotaLimit() {
4981
5191
  }
4982
5192
  function __sbQuotaFull() {
4983
5193
  const now = Date.now();
4984
- if (__sbQuotaAt < 0 || now - __sbQuotaAt >= 1000) {
4985
- __sbQuotaUsed = Number(__sbDirBytesRaw(__sbDir())) || 0;
5194
+ if (__sbQuotaAt < 0 || now - __sbQuotaAt >= __sbQuotaEvery) {
5195
+ const scan = String(__sbDirBytesRaw(__sbDir())).split(" ");
5196
+ __sbQuotaUsed = Number(scan[0]) || 0;
5197
+ __sbQuotaEvery = Math.min(10000, Math.max(1000, Math.ceil((Number(scan[1]) || 0) / 1000) * 1000));
4986
5198
  __sbQuotaAt = now;
4987
5199
  }
4988
5200
  return __sbQuotaUsed >= __sbQuotaLimit();
@@ -5339,10 +5551,8 @@ function __sbEmbeddedDispatch(msg) {
5339
5551
  let path = String(msg.path || "/");
5340
5552
  if (path.charAt(0) !== "/") path = "/" + path;
5341
5553
  const redirects = (bundle.manifest && bundle.manifest.redirects) || [];
5342
- for (let i = 0; i < redirects.length; i++) {
5343
- const location = __sbAssetRedirect(redirects[i], path);
5344
- if (location !== null) return { ok: true, found: false, status: redirects[i].status, location };
5345
- }
5554
+ const redirect = __sbFindAssetRedirect(redirects, path);
5555
+ if (redirect !== null) return { ok: true, found: false, status: redirect.status, location: redirect.location };
5346
5556
 
5347
5557
  let key = null;
5348
5558
  if (path.charAt(path.length - 1) === "/") {
@@ -5505,7 +5715,7 @@ function __sbEmbeddedDispatch(msg) {
5505
5715
  // run like a strict D1 query (BLOBs as hex, D1_ERROR text the shim rewords).
5506
5716
  if (op === "do.sql") {
5507
5717
  const path = __sbDOSqlPath(msg.cls, msg.file);
5508
- // Its handle may have been closed and reopened since (SB_DO_SQL_OPEN), which drops the
5718
+ // Its handle may have been closed and reopened since (the slot LRU, an eviction), which drops the
5509
5719
  // size cap: set it again.
5510
5720
  delete __sbD1Capped[path];
5511
5721
  __sbD1Quota(path);
@@ -5514,6 +5724,16 @@ function __sbEmbeddedDispatch(msg) {
5514
5724
  return { ok: true, columns: r.cols, rows: r.rows, meta: { changes: r.changes } };
5515
5725
  }
5516
5726
 
5727
+ // A Durable Object instance leaving memory closes its SQLite handle (plan 018): the last connection checkpoints, so the
5728
+ // evicted object is one clean file. A no-op when it is not open; closed: -1 means a transaction or a statement kept it open.
5729
+ if (op === "do.sql.close") {
5730
+ const path = __sbDOSqlPath(msg.cls, msg.file);
5731
+ return { ok: true, closed: __sbDbCloseRaw(path) };
5732
+ }
5733
+
5734
+ if (op === "do.stats") return { ok: true, stats: JSON.parse(__sbDbStatsRaw()) };
5735
+ if (op === "do.release") return { ok: true, freed: __sbDbReleaseRaw() };
5736
+
5517
5737
  if (op === "d1.backup") {
5518
5738
  const src = __sbD1Path(String(msg.db));
5519
5739
  // A caller-supplied name must be a plain basename; anything else falls back
@@ -6397,7 +6617,7 @@ async function __sbQueuePass(handlers, queues) {
6397
6617
  // From 2026-11-05 a consumer that runs past the 30 s lease keeps its messages: the lease
6398
6618
  // is renewed while it runs, so they are not delivered a second time or lose their ack.
6399
6619
  const beat = __sbQueuesFixed()
6400
- ? setInterval(() => {
6620
+ ? __sbSetInterval(() => {
6401
6621
  __sbSql(store, "UPDATE mq SET visible_at=? WHERE queue=? AND claim_token=? AND visible_at>?", [Date.now() + 30000, name, claim, Date.now()]);
6402
6622
  }, 10000)
6403
6623
  : null;
@@ -6407,7 +6627,7 @@ async function __sbQueuePass(handlers, queues) {
6407
6627
  if (result && __sbIsFn(result.then)) result = await result;
6408
6628
  if (__sbTasks.length) await __sbDrainWaitUntil(__sbTasks);
6409
6629
  } finally {
6410
- if (beat !== null) clearInterval(beat);
6630
+ if (beat !== null) __sbClearInterval(beat);
6411
6631
  }
6412
6632
  __sbQueueSettle(store, name, claim, result);
6413
6633
  }
@@ -6520,6 +6740,9 @@ async function __sbAlarmPass() {
6520
6740
  __sbSql(store, "ROLLBACK", []);
6521
6741
  throw error;
6522
6742
  }
6743
+ // From the eviction date the claimed batch runs at once (each alarm through its own object's gate; one alarm() per object at a
6744
+ // time is the gate and the single row), the claim is renewed while it runs, and the row's attempts reach alarm().
6745
+ if (__sbDOEvictOn()) return __sbAlarmRunBatch(store, claim, due.rows);
6523
6746
  for (let i = 0; i < due.rows.length; i++) {
6524
6747
  const cls = due.rows[i][0];
6525
6748
  const id = due.rows[i][1];
@@ -6558,6 +6781,46 @@ async function __sbAlarmPass() {
6558
6781
  }
6559
6782
  }
6560
6783
 
6784
+ async function __sbAlarmRunBatch(store, claim, rows) {
6785
+ const beat = __sbSetInterval(() => {
6786
+ __sbSql(store, "UPDATE do_alarm_delivery SET expires=? WHERE token=? AND expires>?", [Date.now() + 30000, claim, Date.now()]);
6787
+ }, 10000);
6788
+ try {
6789
+ const runs = [];
6790
+ for (let i = 0; i < rows.length; i++) runs.push(__sbAlarmDeliver(store, claim, rows[i]));
6791
+ for (let i = 0; i < runs.length; i++) await runs[i];
6792
+ } finally {
6793
+ __sbClearInterval(beat);
6794
+ }
6795
+ }
6796
+
6797
+ // One claimed alarm: construct the object if it is evicted (its constructor runs before alarm()), run alarm() through its gate, and
6798
+ // settle the delivery row. A call that could not get a slot within the wait is not a failed attempt: the row is released uncounted.
6799
+ async function __sbAlarmDeliver(store, claim, row) {
6800
+ const cls = row[0];
6801
+ const id = row[1];
6802
+ try {
6803
+ await __sbDORun(cls, id, undefined, (instance) => (__sbIsFn(instance.alarm) ? __sbDOAlarmCall(instance, cls, id, row[2], row[3]) : undefined), true);
6804
+ __sbSql(store, "DELETE FROM do_alarm_delivery WHERE cls=? AND id=? AND token=? AND expires>?", [cls, id, claim, Date.now()]);
6805
+ } catch (error) {
6806
+ if (error && error.overloaded === true) {
6807
+ __sbSql(store, "UPDATE do_alarm_delivery SET expires=?, attempts=attempts-1 WHERE cls=? AND id=? AND token=? AND expires>?", [
6808
+ Date.now() + 1000,
6809
+ cls,
6810
+ id,
6811
+ claim,
6812
+ Date.now(),
6813
+ ]);
6814
+ return;
6815
+ }
6816
+ const scheduled = __sbSql(store, "SELECT 1 FROM do_alarm WHERE cls=? AND id=?", [cls, id]);
6817
+ if (scheduled.rows.length)
6818
+ __sbSql(store, "DELETE FROM do_alarm_delivery WHERE cls=? AND id=? AND token=? AND expires>?", [cls, id, claim, Date.now()]);
6819
+ else
6820
+ __sbSql(store, "UPDATE do_alarm_delivery SET expires=? WHERE cls=? AND id=? AND token=? AND expires>?", [Date.now() + 5000, cls, id, claim, Date.now()]);
6821
+ }
6822
+ }
6823
+
6561
6824
  // B22: on a host that delivers triggers (sproutd), what this worker consumes. Set by
6562
6825
  // __sbStartLocalTriggers; null when the host does not drive this worker's triggers.
6563
6826
  var __sbLocal = null;
@@ -6644,7 +6907,7 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
6644
6907
  }
6645
6908
 
6646
6909
  if (scheduled) {
6647
- setInterval(async function () {
6910
+ __sbSetInterval(async function () {
6648
6911
  const now = new Date();
6649
6912
  const stamp =
6650
6913
  now.getUTCFullYear() +
@@ -6684,7 +6947,7 @@ globalThis.__sbStartLocalTriggers = function (handlers, bindings) {
6684
6947
  }, 15000);
6685
6948
  }
6686
6949
 
6687
- if (queues.length > 0) setInterval(() => __sbQueuePass(handlers, queues), 500);
6950
+ if (queues.length > 0) __sbSetInterval(() => __sbQueuePass(handlers, queues), 500);
6688
6951
 
6689
- if (dos.length > 0) setInterval(() => __sbAlarmPass(), 500);
6952
+ if (dos.length > 0) __sbSetInterval(() => __sbAlarmPass(), 500);
6690
6953
  };
@@ -0,0 +1,28 @@
1
+ /** What a `_redirects` matcher must answer, from Cloudflare's rules engine: [file text, request path, [status, location] or null]. */
2
+ export type RedirectCase = [text: string, path: string, want: [status: number, location: string] | null];
3
+
4
+ export const REDIRECT_CASES: RedirectCase[] = [
5
+ ["/compare /compare/ 308", "/compare", [308, "/compare/"]],
6
+ ["/compare /compare/ 308", "/compare/", null],
7
+ ["/compare/ /compare 308", "/compare/", [308, "/compare"]],
8
+ ["/compare/ /compare 308", "/compare", null],
9
+ ["/user/:id /profile/:id", "/user/42", [302, "/profile/42"]],
10
+ ["/user/:id /profile/:id", "/user/42/", null],
11
+ ["/user/:id /profile/:id", "/user/", null],
12
+ ["/user/:id /profile/:id", "/user", null],
13
+ ["/blog/* /posts/:splat 301", "/blog/", [301, "/posts/"]],
14
+ ["/blog/* /posts/:splat 301", "/blog", null],
15
+ ["/blog/* /posts/:splat 301", "/blog/a/b/", [301, "/posts/a/b/"]],
16
+ ["/a/*/c /x/:splat", "/a/b/b2/c", [302, "/x/b/b2"]],
17
+ ["/a/*/c /x/:splat", "/a/c", null],
18
+ ["/:lang/docs /docs/:lang 301", "/fr/docs", [301, "/docs/fr"]],
19
+ ["/:lang/docs /docs/:lang 301", "/fr/docs/", null],
20
+ ["/files/:name.html /pages/:name", "/files/about.html", [302, "/pages/about"]],
21
+ ["/:a-:b /x/:b/:a", "/one-two", [302, "/x/two/one"]],
22
+ ["/:a/:a /x", "/p/p", null],
23
+ ["/*/*/ /x", "/a/b/", null],
24
+ ["/a:1 /b", "/a:1", [302, "/b"]],
25
+ ["/blog/* /dynamic/:splat\n/blog/old /static\n/blog/old /static-2", "/blog/old", [302, "/static"]],
26
+ ["/blog/* /dynamic/:splat\n/blog/old /static", "/blog/new", [302, "/dynamic/new"]],
27
+ ["/x/:a /first\n/x/:b /second", "/x/1", [302, "/first"]],
28
+ ];