openvibers/openvibe.deals Commits

Hotness snapshots stop piling up (4.2M rows, a 1.2 s prune) (#209)

OP OpenVibers committed 11 days ago · af7e1f78cb · 3 files, +27 −12

A tick re-snapshots every active offer: 39,547 every ten minutes on 11-02 (2,700 when the day-long tick retention
was set), 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 a run, the slowest statement on ov_deals by total time. Ticks are now kept two hours (their latest per
offer always stays); the prune asks the (offer_id, id) index whether a newer snapshot exists and takes old ticks in
order from a partial index on computed_at (migration 0011), 100,000 a tick, more than a tick writes.

Changes

ddiff --git a/migrations/0011_hotness_tick_index.sql b/migrations/0011_hotness_tick_index.sql
nnew file mode 100644
iindex 0000000..8aabd11
---- /dev/null
++++ b/migrations/0011_hotness_tick_index.sql
@@@ -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';
ddiff --git a/server/domain/hotness.js b/server/domain/hotness.js
iindex 8bb13fb..35f77b5 100644
---- a/server/domain/hotness.js
++++ b/server/domain/hotness.js
@@@ -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;
}
ddiff --git a/test/hotness.test.js b/test/hotness.test.js
iindex 122d976..ec6fcfe 100644
---- a/test/hotness.test.js
++++ b/test/hotness.test.js
@@@ -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);