openvibers/openvibe.live Commits

Drop the legacy chat tables with foreign-key enforcement off: the implicit per-row delete over chat_messages' self-reference kept the release from becoming ready (#35)

OP OpenVibers committed 5 Oct 2026 · ad5a611142 · 2 files, +35 −5

Live#34 merged but its deploy never became ready (twice; ovhost rolled back to 1d49484e both times, data intact). Cause: Live runs SQLite with foreign_keys ON, so a table drop first deletes every row, and chat_messages references itself (reply_to_id ON DELETE SET NULL, no index): a full scan per deleted row, quadratic, minutes of CPU at boot (the failed release used 3 min CPU over 3 min wall).

- server/db/migrations.js: run() honours a per-migration foreignKeys: false (pragma off outside the transaction, back on in finally); 007_drop_chat_tables sets it, so the drop only frees pages.
- test/chat-tables-dropped.test.js: 30,000 self-referencing rows drop in under 3 s and foreign keys are back on afterwards.

ov test test/chat-tables-dropped.test.js test/migrations.test.js test/destructive-migrations.test.js: all pass.

Built in the main session (Opus).

Changes

ddiff --git a/server/db/migrations.js b/server/db/migrations.js
iindex a799b666..7b1a0379 100644
---- a/server/db/migrations.js
++++ b/server/db/migrations.js
@@@ -137,6 +137,11 @@ const MIGRATIONS = [
},
{
id: '007_drop_chat_tables',
++ // With foreign_keys ON, a table drop first runs an implicit DELETE of every row, and chat_messages' unindexed
++ // self-reference (reply_to_id ON DELETE SET NULL) makes that a full scan per deleted row: quadratic, minutes
++ // of CPU on production's table (the first deploy of this migration never became ready). Dropping with
++ // enforcement off just frees the pages; nothing outside the twelve references them.
++ foreignKeys: false,
// T3 final step (2026-10-05). OpenVibe.Chat owns these twelve tables and has been their only
// writer since the 2026-09-23 02:03 UTC cutover (it imported them from Live with ids kept);
// Live's readers and writers are gone (#28-#32 and the release that carries this migration,
@@@ -256,11 +261,19 @@ function run(db, list = MIGRATIONS) {
try {
let adopted = false;
let deferred = false;
-- db.transaction(() => {
-- if (m.adopt && m.adopt(db)) { adopted = true; }
-- else if (m.up(db) === DEFER) { deferred = true; return; }
-- record.run(m.id, adopted ? 'adopted' : 'applied', Date.now() - started);
-- })();
++ // `foreignKeys: false` runs the migration with enforcement off (SQLite only changes the pragma outside a
++ // transaction): for drops of large tables, whose implicit per-row DELETE would otherwise run first.
++ const fkOff = m.foreignKeys === false;
++ if (fkOff) db.pragma('foreign_keys = OFF');
++ try {
++ db.transaction(() => {
++ if (m.adopt && m.adopt(db)) { adopted = true; }
++ else if (m.up(db) === DEFER) { deferred = true; return; }
++ record.run(m.id, adopted ? 'adopted' : 'applied', Date.now() - started);
++ })();
++ } finally {
++ if (fkOff) db.pragma('foreign_keys = ON');
++ }
failures.delete(m.id);
const outcome = deferred ? 'deferred' : adopted ? 'adopted' : 'applied';
results.push({ id: m.id, outcome });
ddiff --git a/test/chat-tables-dropped.test.js b/test/chat-tables-dropped.test.js
iindex 076cc6d8..39c2aa52 100644
---- a/test/chat-tables-dropped.test.js
++++ b/test/chat-tables-dropped.test.js
@@@ -108,5 +108,22 @@ const res = migrations.run(mem, migrations.MIGRATIONS.filter((m) => m.id === DRO
assert.strictEqual(res[0].outcome, 'adopted', 'a fresh database adopts the drop');
mem.close();
++// 5. Fast on a large table: chat_messages references itself (reply_to_id ON DELETE SET NULL, no index), so a drop
++// with foreign_keys ON scans the table once per row. The migration runs with enforcement off, and turns it back on.
++const big = new Database(':memory:');
++big.pragma('foreign_keys = ON');
++big.exec(`CREATE TABLE chat_messages (id INTEGER PRIMARY KEY AUTOINCREMENT, message TEXT NOT NULL,
++ reply_to_id INTEGER REFERENCES chat_messages(id) ON DELETE SET NULL)`);
++const ins = big.prepare('INSERT INTO chat_messages (message, reply_to_id) VALUES (?, ?)');
++big.transaction(() => { for (let i = 1; i <= 30000; i++) ins.run('line', i > 1 ? i - 1 : null); })();
++const t0 = Date.now();
++const out = migrations.run(big, migrations.MIGRATIONS.filter((m) => m.id === DROP_ID));
++const took = Date.now() - t0;
++assert.strictEqual(out[0].outcome, 'applied', JSON.stringify(out));
++assert.ok(took < 3000, `30,000 self-referencing rows drop in well under the boot budget (took ${took} ms)`);
++assert.strictEqual(big.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE name = 'chat_messages'").get().n, 0);
++assert.strictEqual(big.pragma('foreign_keys', { simple: true }), 1, 'foreign keys are back on after the migration');
++big.close();
++
console.log(`chat tables dropped: no server/ code names the ${TABLES.length} outside ${DROP_ID}; fresh schema none; a boot drops legacy copies and adopts a fresh database`);
process.exit(0);