openvibers/openvibe.deals Commits
Hotness snapshots stop piling up (4.2M rows, a 1.2 s prune) (#209)
Changes
nnew file mode 100644@@@ -0,0 +1,6 @@++-- phase: expand++-- The hotness prune finds old tick snapshots by this index (server/domain/hotness.js tick()). On 2026-11-02 a tick++-- re-snapshotted 39,547 offers every ten minutes, so a day of ticks was 4.2M rows (1.5 GB), and the prune's++-- NOT IN (SELECT MAX(id) … GROUP BY offer_id) took 1.2 s each run. Ticks are now kept two hours and the prune asks++-- the (offer_id, id) index whether a newer snapshot exists; this index hands it the old ticks in order.++CREATE INDEX IF NOT EXISTS deal_hotness_old_ticks ON deal_hotness_snapshots (computed_at) WHERE reason = 'tick';@@@ -22,6 +22,8 @@*/const FORMULA = 'hot@1';const HOUR = 3600 * 1000;++// How long a tick's snapshots stay (their latest per offer always stays): two hours, about twelve ticks.++const TICK_KEEP_HOURS = 2;function round(x, places) {const f = 10 ** places;@@@ -82,15 +84,21 @@ function createHotness({ store, reads }) {AND (hot_latest IS NULL OR hot_latest <> 0)`).all(t - windowDays * 24 * HOUR)).map((r) => r.id);await store.tx(async () => { for (const id of ids) await snapshot(id, 'tick', t); });// Keep the latest snapshot per offer, every snapshot a vote, merge or creation made in the last 7 days, and the-- // worker's own tick snapshots of the last day. A tick re-snapshots every active offer (about 2,700, every ten-- // minutes), so seven days of ticks were 2.68M rows (805 MB, 2026-10-23) behind reads that only use the latest,-- // and this delete took 1.4 s each time. At most 50,000 rows go per tick: a backlog drains over a few hours,-- // each delete well inside the statement timeout.++ // worker's own tick snapshots of the last two hours. A tick re-snapshots every active offer: about 2,700 on++ // 2026-10-23, 39,547 on 2026-11-02, when a day of ticks was 4.2M rows (1.5 GB) and the old delete++ // (NOT IN (SELECT MAX(id) … GROUP BY offer_id)) took 1.2 s each run. A snapshot goes only when a newer one of++ // the same offer exists, asked of the (offer_id, id) index; old ticks come in order from deal_hotness_old_ticks++ // (migration 0011). Up to 100,000 rows a tick: more than a tick writes, so a backlog drains.await db.prepare(`DELETE FROM deal_hotness_snapshots WHERE id IN (-- SELECT id FROM deal_hotness_snapshots-- WHERE computed_at < ? AND (reason = 'tick' OR computed_at < ?)-- AND id NOT IN (SELECT MAX(id) FROM deal_hotness_snapshots GROUP BY offer_id)-- LIMIT 50000)`).run(t - 24 * HOUR, t - 7 * 24 * HOUR);++ SELECT s.id FROM deal_hotness_snapshots s++ WHERE s.reason = 'tick' AND s.computed_at < ?++ AND EXISTS (SELECT 1 FROM deal_hotness_snapshots n WHERE n.offer_id = s.offer_id AND n.id > s.id)++ ORDER BY s.computed_at LIMIT 100000)`).run(t - TICK_KEEP_HOURS * HOUR);++ await db.prepare(`DELETE FROM deal_hotness_snapshots WHERE id IN (++ SELECT s.id FROM deal_hotness_snapshots s++ WHERE s.reason <> 'tick' AND s.computed_at < ?++ AND EXISTS (SELECT 1 FROM deal_hotness_snapshots n WHERE n.offer_id = s.offer_id AND n.id > s.id)++ LIMIT 20000)`).run(t - 7 * 24 * HOUR);return ids.length;}@@@ -115,18 +115,19 @@ const { formula } = require('../server/domain/hotness');assert.ok(last.hot > 0 && last.reason === 'tick');});-- await check('tick snapshots are kept a day, the others 7 days, and every offer\'s latest always', async () => {++ await check('tick snapshots are kept two hours, the others 7 days, and every offer\'s latest always', async () => {const db = t.ctx.store.db;const Y = await t.submit(poster, { url: 'https://a.example/keep', title: 'Offer Keep', price: '5', currency: 'USD' });const count = async (where, args = []) => Number((await db.prepare(`SELECT COUNT(*) AS n FROM deal_hotness_snapshots WHERE ${where}`).get(...args)).n);await t.ctx.worker.tick();const firstTick = await count("reason = 'tick'");assert.ok(firstTick >= 1);-- t.clock.advance(25 * HOUR);++ t.clock.advance(3 * HOUR);await t.ctx.worker.tick();-- // The day-old ticks went, except where one is an offer's latest; this tick's are all there.++ // The ticks older than two hours went, except where one is an offer's latest; this tick's are all there.const now = Number((await db.prepare("SELECT MAX(computed_at) AS t FROM deal_hotness_snapshots WHERE reason = 'tick'").get()).t);-- assert.strictEqual(await count("reason = 'tick' AND computed_at < ?", [now - 24 * HOUR]), 0, 'no tick older than a day');++ assert.strictEqual(await count("reason = 'tick' AND computed_at < ? AND id NOT IN (SELECT MAX(id) FROM deal_hotness_snapshots GROUP BY offer_id)", [now - 2 * HOUR]), 0, 'no superseded tick older than two hours');++ assert.ok(await count("reason = 'tick' AND computed_at = ?", [now]) >= 1, 'this tick\'s snapshots are all there');assert.ok(await count("reason = 'create' AND offer_id = ?", [Y.id]) >= 1 || await count('offer_id = ?', [Y.id]) >= 1, 'the offer keeps its snapshots');assert.ok(await count("reason <> 'tick'") >= 1, 'vote and creation snapshots of the week stay');t.clock.advance(8 * 24 * HOUR);
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+-- phase: expand
+-- The hotness prune finds old tick snapshots by this index (server/domain/hotness.js tick()). On 2026-11-02 a tick
+-- re-snapshotted 39,547 offers every ten minutes, so a day of ticks was 4.2M rows (1.5 GB), and the prune's
+-- NOT IN (SELECT MAX(id) … GROUP BY offer_id) took 1.2 s each run. Ticks are now kept two hours and the prune asks
+-- the (offer_id, id) index whether a newer snapshot exists; this index hands it the old ticks in order.
+CREATE INDEX IF NOT EXISTS deal_hotness_old_ticks ON deal_hotness_snapshots (computed_at) WHERE reason = 'tick';
@@ -22,6 +22,8 @@
@@ -22,6 +22,8 @@
*/
*/
const FORMULA = 'hot@1';
const FORMULA = 'hot@1';
const HOUR = 3600 * 1000;
const HOUR = 3600 * 1000;
+// How long a tick's snapshots stay (their latest per offer always stays): two hours, about twelve ticks.
+const TICK_KEEP_HOURS = 2;
function round(x, places) {
function round(x, places) {
const f = 10 ** places;
const f = 10 ** places;
@@ -82,15 +84,21 @@ function createHotness({ store, reads }) {
@@ -82,15 +84,21 @@ function createHotness({ store, reads }) {
AND (hot_latest IS NULL OR hot_latest <> 0)`).all(t - windowDays * 24 * HOUR)).map((r) => r.id);
AND (hot_latest IS NULL OR hot_latest <> 0)`).all(t - windowDays * 24 * HOUR)).map((r) => r.id);
await store.tx(async () => { for (const id of ids) await snapshot(id, 'tick', t); });
await store.tx(async () => { for (const id of ids) await snapshot(id, 'tick', t); });
// Keep the latest snapshot per offer, every snapshot a vote, merge or creation made in the last 7 days, and the
// Keep the latest snapshot per offer, every snapshot a vote, merge or creation made in the last 7 days, and the
- // worker's own tick snapshots of the last day. A tick re-snapshots every active offer (about 2,700, every ten
- // minutes), so seven days of ticks were 2.68M rows (805 MB, 2026-10-23) behind reads that only use the latest,
- // and this delete took 1.4 s each time. At most 50,000 rows go per tick: a backlog drains over a few hours,
- // each delete well inside the statement timeout.
+ // worker's own tick snapshots of the last two hours. A tick re-snapshots every active offer: about 2,700 on
+ // 2026-10-23, 39,547 on 2026-11-02, when a day of ticks was 4.2M rows (1.5 GB) and the old delete
+ // (NOT IN (SELECT MAX(id) … GROUP BY offer_id)) took 1.2 s each run. A snapshot goes only when a newer one of
+ // the same offer exists, asked of the (offer_id, id) index; old ticks come in order from deal_hotness_old_ticks
+ // (migration 0011). Up to 100,000 rows a tick: more than a tick writes, so a backlog drains.
await db.prepare(`DELETE FROM deal_hotness_snapshots WHERE id IN (
await db.prepare(`DELETE FROM deal_hotness_snapshots WHERE id IN (
- SELECT id FROM deal_hotness_snapshots
- WHERE computed_at < ? AND (reason = 'tick' OR computed_at < ?)
- AND id NOT IN (SELECT MAX(id) FROM deal_hotness_snapshots GROUP BY offer_id)
- LIMIT 50000)`).run(t - 24 * HOUR, t - 7 * 24 * HOUR);
+ SELECT s.id FROM deal_hotness_snapshots s
+ WHERE s.reason = 'tick' AND s.computed_at < ?
+ AND EXISTS (SELECT 1 FROM deal_hotness_snapshots n WHERE n.offer_id = s.offer_id AND n.id > s.id)
+ ORDER BY s.computed_at LIMIT 100000)`).run(t - TICK_KEEP_HOURS * HOUR);
+ await db.prepare(`DELETE FROM deal_hotness_snapshots WHERE id IN (
+ SELECT s.id FROM deal_hotness_snapshots s
+ WHERE s.reason <> 'tick' AND s.computed_at < ?
+ AND EXISTS (SELECT 1 FROM deal_hotness_snapshots n WHERE n.offer_id = s.offer_id AND n.id > s.id)
+ LIMIT 20000)`).run(t - 7 * 24 * HOUR);
return ids.length;
return ids.length;
}
}
@@ -115,18 +115,19 @@ const { formula } = require('../server/domain/hotness');
@@ -115,18 +115,19 @@ const { formula } = require('../server/domain/hotness');
assert.ok(last.hot > 0 && last.reason === 'tick');
assert.ok(last.hot > 0 && last.reason === 'tick');
});
});
- await check('tick snapshots are kept a day, the others 7 days, and every offer\'s latest always', async () => {
+ await check('tick snapshots are kept two hours, the others 7 days, and every offer\'s latest always', async () => {
const db = t.ctx.store.db;
const db = t.ctx.store.db;
const Y = await t.submit(poster, { url: 'https://a.example/keep', title: 'Offer Keep', price: '5', currency: 'USD' });
const Y = await t.submit(poster, { url: 'https://a.example/keep', title: 'Offer Keep', price: '5', currency: 'USD' });
const count = async (where, args = []) => Number((await db.prepare(`SELECT COUNT(*) AS n FROM deal_hotness_snapshots WHERE ${where}`).get(...args)).n);
const count = async (where, args = []) => Number((await db.prepare(`SELECT COUNT(*) AS n FROM deal_hotness_snapshots WHERE ${where}`).get(...args)).n);
await t.ctx.worker.tick();
await t.ctx.worker.tick();
const firstTick = await count("reason = 'tick'");
const firstTick = await count("reason = 'tick'");
assert.ok(firstTick >= 1);
assert.ok(firstTick >= 1);
- t.clock.advance(25 * HOUR);
+ t.clock.advance(3 * HOUR);
await t.ctx.worker.tick();
await t.ctx.worker.tick();
- // The day-old ticks went, except where one is an offer's latest; this tick's are all there.
+ // The ticks older than two hours went, except where one is an offer's latest; this tick's are all there.
const now = Number((await db.prepare("SELECT MAX(computed_at) AS t FROM deal_hotness_snapshots WHERE reason = 'tick'").get()).t);
const now = Number((await db.prepare("SELECT MAX(computed_at) AS t FROM deal_hotness_snapshots WHERE reason = 'tick'").get()).t);
- assert.strictEqual(await count("reason = 'tick' AND computed_at < ?", [now - 24 * HOUR]), 0, 'no tick older than a day');
+ assert.strictEqual(await count("reason = 'tick' AND computed_at < ? AND id NOT IN (SELECT MAX(id) FROM deal_hotness_snapshots GROUP BY offer_id)", [now - 2 * HOUR]), 0, 'no superseded tick older than two hours');
+ assert.ok(await count("reason = 'tick' AND computed_at = ?", [now]) >= 1, 'this tick\'s snapshots are all there');
assert.ok(await count("reason = 'create' AND offer_id = ?", [Y.id]) >= 1 || await count('offer_id = ?', [Y.id]) >= 1, 'the offer keeps its snapshots');
assert.ok(await count("reason = 'create' AND offer_id = ?", [Y.id]) >= 1 || await count('offer_id = ?', [Y.id]) >= 1, 'the offer keeps its snapshots');
assert.ok(await count("reason <> 'tick'") >= 1, 'vote and creation snapshots of the week stay');
assert.ok(await count("reason <> 'tick'") >= 1, 'vote and creation snapshots of the week stay');
t.clock.advance(8 * 24 * HOUR);
t.clock.advance(8 * 24 * HOUR);