Repository navigation
feat(zero-cache): configurable analysis limit for replica statistics - #6732
YevheniiKotyrlo wants to merge 1 commit into
Conversation
|
Measurements behind the table in the description. A standalone probe against
The sample reads probe.mjs// What SQLite's statistics and plan are for a large table led by a low-cardinality column, gathered
// under an analysis limit and without one, and what each plan costs.
//
// node probe.mjs <zero-sqlite3 package folder> [rows] [runs]
//
// The table models a soft-deleted list scoped to a few groups: `deleted` is 0 on almost every row, so
// an index led by it serves the ORDER BY while matching nearly the whole table, and the `group_id`
// index is the selective one. Groups 1-10 hold only the oldest rows, as a small or closed group would,
// so an ordered walk reaches their rows last.
import {createRequire} from 'node:module';
import path from 'node:path';
const [packageFolder, rowsArgument = '1000000', runsArgument = '15'] = process.argv.slice(2);
if (packageFolder === undefined) {
throw new Error('usage: node probe.mjs <zero-sqlite3 package folder> [rows] [runs]');
}
const Database = createRequire(import.meta.url)(path.resolve(packageFolder));
const ROWS = Number(rowsArgument);
const RUNS = Number(runsArgument);
const GROUPS = 2000;
const OLD_GROUPS = 10;
const OLD_ROWS = 10000;
const SCOPES = {spread: JSON.stringify([107, 501, 902, 1303, 1804]), oldest: JSON.stringify([1, 2, 3, 4, 5])};
const QUERY = `SELECT id FROM item
WHERE deleted = 0 AND group_id IN (SELECT value FROM json_each(?))
ORDER BY created_at DESC, id DESC LIMIT 50`;
const buildDatabase = () => {
const database = new Database(':memory:');
database.exec(`
CREATE TABLE item (id INTEGER PRIMARY KEY, deleted INTEGER NOT NULL, group_id INTEGER NOT NULL, created_at INTEGER NOT NULL);
CREATE INDEX item_deleted_created ON item (deleted, created_at, id);
CREATE INDEX item_group ON item (group_id);
`);
const insert = database.prepare('INSERT INTO item VALUES (?, ?, ?, ?)');
database.transaction(() => {
for (let id = 1; id <= ROWS; id++) {
const groupId = id <= OLD_ROWS ? (id % OLD_GROUPS) + 1 : ((id * 7919) % (GROUPS - OLD_GROUPS)) + OLD_GROUPS + 1;
insert.run(id, id % 100 === 0 ? 1 : 0, groupId, id);
}
})();
return database;
};
const median = values => values.toSorted((left, right) => left - right)[Math.floor(values.length / 2)];
const measure = (database, scope) => {
const statement = database.prepare(QUERY);
statement.all(scope);
const samples = Array.from({length: RUNS}, () => {
const start = performance.now();
statement.all(scope);
return performance.now() - start;
});
return {
medianMs: Number(median(samples).toFixed(2)),
minMs: Number(Math.min(...samples).toFixed(2)),
maxMs: Number(Math.max(...samples).toFixed(2)),
};
};
const report = analysisLimit => {
const database = buildDatabase();
database.pragma(`analysis_limit = ${analysisLimit}`);
const start = performance.now();
database.exec('ANALYZE');
const analyzeMs = Number((performance.now() - start).toFixed(1));
const stat1 = database.prepare("SELECT idx, stat FROM sqlite_stat1 WHERE tbl = 'item' ORDER BY idx").all();
const plan = database
.prepare(`EXPLAIN QUERY PLAN ${QUERY}`)
.all(SCOPES.spread)
.map(row => row.detail);
const timing = Object.fromEntries(Object.entries(SCOPES).map(([name, scope]) => [name, measure(database, scope)]));
database.close();
return {analysisLimit, analyzeMs, stat1, plan, timing};
};
console.log(JSON.stringify({sqlite: new Database(':memory:').prepare('SELECT sqlite_version() AS v').get().v, rows: ROWS, runs: RUNS}));
for (const analysisLimit of [1000, 0]) {
console.log(JSON.stringify(report(analysisLimit), null, 2));
}Run with |
ZERO_REPLICA_ANALYSIS_LIMIT (default 1000, 0 reads every row) sets the
analysis_limit of the replicator's write worker, which alone gathers the
replica's statistics, in its own thread: when it opens the replica, with
ANALYZE main if the replica records no limit or another one in
_zero.statistics (replica schema v19), and after a replicated schema
change. Its optimize passes run SQLite's default mask without 0x10, which
would replace a limit of 0, or of 2000 and above, with a temporary 2000.
Every other replica connection opens read-only or closes with
{optimize: false}, and an initial sync's ChangeProcessor runs no pass.
v19 records an earlier zero-cache's statistics as NULL, kept under a
limit of 1000, and /plannerz reports the record.
zero-cache's unpublished suites that pin the replica schema version or
list a fresh replica's _zero tables see v19 and _zero.statistics,
getPragmaConfig takes the limit, and fakeReplicator and
createChangeProcessor set the default limit on their connection.
8aa5b87 to
b8df599
Compare
Problem
zero-cache gathers its replica's planner statistics from a sample, under the
analysis_limit = 1000thatgetPragmaConfigfixes, and SQLite takes eachsqlite_stat1average from the rows it sampled. So an index led by a column with few values, such as a soft-delete flag, reads as selective, and SQLite walks it instead of seeking a more selective one. On a million-row table withdeleted = 0on 99% of rows,WHERE deleted = 0 AND group_id IN (…) ORDER BY created_at DESC, id DESC LIMIT 50plans:analysis_limitsqlite_stat1of(deleted, created_at, id)1000000 1001 1 1(deleted, created_at, id), filtering every row1000000 500000 1 1(group_id)and sortsThe probe and its measurements: #6732 (comment)
Solution
ZERO_REPLICA_ANALYSIS_LIMIT(--replica-analysis-limit; default 1000, 0 reads every row) sets theanalysis_limitunder which the replicator's write worker, the replica's one writer, alone gathers its statistics, in its own thread (services/replicator/schema/statistics.ts):ANALYZE mainif_zero.statistics(replica schema v19) records no limit or another one, then records the limit; otherwise it runsPRAGMA optimizeover every table, as before. Statistics an earlier zero-cache left are recorded as NULL and kept under 1000, the limit it set, so an upgrade at the default keeps them.PRAGMA optimize.PRAGMA optimizepasses run SQLite's default mask without0x10, and the limit is recorded, becausePRAGMA optimizeswaps a limit of 0, or of 2000 and above, for a temporary one of at most 2000 under that bit, and re-analyzes an analyzed table only once its size changes tenfold.{optimize: false}, and an initial sync'sChangeProcessorruns no pass. So in a deployment without a backup, the change-streamer's poller no longer analyzes a table replicated empty and filled since; the replicator does at its next start or schema change.Tests
packages/zero-cache/src/services/replicator/schema/statistics.test.tsstarts the replicator asserver/replicator.tsdoes,setupReplicaand then the write worker, on the published harnesses. It covers:analyzeQuery, the preparation, the serving copy's source and the write worker's stop, none of which gathers statistics;/plannerzreporting the record, or none for a replica that predates it.With only the new module added to
main, every case fails but the tenfold one, whichmain's preparation already passes.Verification
statistics.test.ts25 passed ·pnpm run lintclean ·oxfmt --checkclean ·syncpack lintclean ·check-typespasses in every package; turbo'szbugs#build, which it runs first, fails on Windows onmaintoo._zero.statistics,getPragmaConfig's limit and the test harness's default limit.