mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
* docs(sync): add super sync server perf plan * perf(sync): implement supersync server perf phases * fix(sync): bracket auth cache invalidation * fix(sync): avoid empty replay state stringify * fix(sync): harden supersync batch uploads * fix super sync review findings * fix(sync): guard payload bytes backfill rollout * perf(sync): speed up payload_bytes backfill and index its scan Raise the backfill batch size (DEFAULT 5->500, MAX 25->1000) so a 100M-row operations table backfills in minutes rather than tens of hours. Add a CONCURRENTLY partial index on (user_id, id) WHERE payload_bytes = 0: it drains to empty post-backfill so the boot-time backfill self-check and the BOOL_OR quota probe stop doing a full sequential scan to prove absence, and it makes the backfill's per-user keyset paging a true index seek. Wire the new concurrent-index migration into both deploy scripts' P3018 recovery path. Add migration-SQL guard tests for the ADD COLUMN (metadata-only fast path) and the new partial index. * fix(sync): bound auth cache invalidation map and bracket every delete The auth verification cache's invalidationVersions map grew one entry per lifetime-invalidated user with no eviction (unbounded heap on a long-lived single replica). Cap it at the same 10k LRU bound as the entries map, re-inserting the just-invalidated user at the MRU tail so the CAS race protection still holds for the only window that matters (one DB round trip). Bracket the passkey/magic-link registration cleanup deletes with pre+post invalidate to match the documented convention, and invalidate on verifyEmail so a freshly-verified user isn't denied for up to the cache TTL. * perf(sync): skip the redundant exact replay-state measurement The delta accounting is a proven over-estimate of the serialized state size, so when the running bound stays within the cap the true size is too and the final exact JSON.stringify is provably redundant. Skip it in that case (still measure-and-throw whenever the bound does not prove safety). This collapses the common small/incremental replay back to zero expensive full stringifications, matching the old per-op loop instead of regressing it. Name the entity-key JSON overhead constant and document that assertReplayStateSize's return value is load-bearing. * refactor(sync): split processOperationBatch into pipeline stages Extract the 297-line batch upload method into a thin orchestrator plus six named single-responsibility stage helpers (validate+clamp, intra- batch dedupe, classify existing duplicates, conflict-detect, reserve seq + insert, full-state clock). Behavior-preserving: every stage writes terminal rejections into the shared results array by index and the two empty-set guards short-circuit exactly as before. Also share the timestamp clamp, the duplicate-op SELECT, and the merged full-state clock persistence between the batch and legacy paths so they cannot silently diverge. * test(sync): pin batch error-code divergence and aggregate-once Strengthen the intra-batch duplicate test to assert same-id / different-content yields DUPLICATE_OPERATION (deliberate divergence from the legacy INVALID_OP_ID), and document the divergence in the plan. Replace the single-full-state aggregate test with two full-state ops + a spy asserting _aggregatePriorVectorClock runs exactly once and last-write-wins — the old test could not catch a per-op-aggregate regression. Add a makeOp fixture factory. Correct the plan's overstated replay-stringification numbers. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: johannesjo <1456265+johannesjo@users.noreply.github.com>
157 lines
4.5 KiB
TypeScript
157 lines
4.5 KiB
TypeScript
import { Prisma, PrismaClient } from '@prisma/client';
|
|
import { computeOpStorageBytes } from '../src/sync/sync.const';
|
|
|
|
// One backfill iteration is 2 round trips (findMany + UPDATE ... FROM (VALUES ...))
|
|
// per BATCH_SIZE rows. The UPDATE is N primary-key lookups joined to a small VALUES
|
|
// list, so it only takes short per-row locks and never a table lock; VALUES lists of
|
|
// a few thousand short tuples are cheap. Keeping these tiny made a 100M-row backfill
|
|
// take tens of hours, which in turn keeps the slow octet_length() quota fallback and
|
|
// the boot-time backfill self-check on the un-indexed scan path far longer than
|
|
// necessary. Size for throughput; the MAX cap still bounds the VALUES string so a
|
|
// fat-fingered override cannot OOM the Node process.
|
|
const DEFAULT_BATCH_SIZE = 500;
|
|
const MAX_BATCH_SIZE = 1000;
|
|
const USER_PAGE_SIZE = 1000;
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
const parseBatchSize = (): number => {
|
|
const raw = process.env.PAYLOAD_BYTES_MIGRATION_BATCH_SIZE;
|
|
if (!raw) return DEFAULT_BATCH_SIZE;
|
|
|
|
const parsed = Number.parseInt(raw, 10);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
throw new Error(
|
|
`Invalid PAYLOAD_BYTES_MIGRATION_BATCH_SIZE: ${raw}. Must be a positive integer.`,
|
|
);
|
|
}
|
|
return Math.min(parsed, MAX_BATCH_SIZE);
|
|
};
|
|
|
|
const fetchUserIdsWithUnbackfilledRows = async (
|
|
afterUserId: number | undefined,
|
|
): Promise<number[]> => {
|
|
const rows = await prisma.$queryRaw<Array<{ user_id: number }>>`
|
|
SELECT DISTINCT user_id
|
|
FROM operations
|
|
WHERE payload_bytes = 0
|
|
${afterUserId === undefined ? Prisma.empty : Prisma.sql`AND user_id > ${afterUserId}`}
|
|
ORDER BY user_id ASC
|
|
LIMIT ${USER_PAGE_SIZE}
|
|
`;
|
|
|
|
return rows.map((row) => row.user_id);
|
|
};
|
|
|
|
const updatePayloadBytesBatch = async (
|
|
updates: Array<{ id: string; bytes: number }>,
|
|
): Promise<void> => {
|
|
if (updates.length === 0) return;
|
|
|
|
const values = Prisma.join(
|
|
updates.map(
|
|
(update) => Prisma.sql`(${update.id}::text, ${BigInt(update.bytes)}::bigint)`,
|
|
),
|
|
);
|
|
|
|
await prisma.$executeRaw`
|
|
UPDATE operations
|
|
SET payload_bytes = v.bytes
|
|
FROM (VALUES ${values}) AS v(id, bytes)
|
|
WHERE operations.id = v.id
|
|
`;
|
|
};
|
|
|
|
const backfillUser = async (userId: number, batchSize: number): Promise<number> => {
|
|
let updated = 0;
|
|
let lastId: string | undefined;
|
|
|
|
for (;;) {
|
|
const rows = await prisma.operation.findMany({
|
|
where: {
|
|
userId,
|
|
payloadBytes: BigInt(0),
|
|
...(lastId ? { id: { gt: lastId } } : {}),
|
|
},
|
|
orderBy: { id: 'asc' },
|
|
take: batchSize,
|
|
select: {
|
|
id: true,
|
|
payload: true,
|
|
vectorClock: true,
|
|
},
|
|
});
|
|
|
|
if (rows.length === 0) break;
|
|
|
|
await updatePayloadBytesBatch(
|
|
rows.map((row) => ({
|
|
id: row.id,
|
|
bytes: computeOpStorageBytes({
|
|
payload: row.payload,
|
|
vectorClock: row.vectorClock,
|
|
}).bytes,
|
|
})),
|
|
);
|
|
|
|
updated += rows.length;
|
|
lastId = rows[rows.length - 1].id;
|
|
console.log(
|
|
`Updated ${updated} operation payload byte counters for user ${userId}...`,
|
|
);
|
|
}
|
|
|
|
return updated;
|
|
};
|
|
|
|
const reconcileUserStorageUsage = async (userId: number): Promise<void> => {
|
|
await prisma.$executeRaw`
|
|
UPDATE users
|
|
SET storage_used_bytes = usage.total_bytes
|
|
FROM (
|
|
SELECT
|
|
${userId}::integer AS user_id,
|
|
(
|
|
SELECT COALESCE(SUM(payload_bytes), 0)
|
|
FROM operations
|
|
WHERE user_id = ${userId}
|
|
) +
|
|
COALESCE((
|
|
SELECT octet_length(snapshot_data)::bigint
|
|
FROM user_sync_state
|
|
WHERE user_id = ${userId}
|
|
), 0) AS total_bytes
|
|
) AS usage
|
|
WHERE users.id = usage.user_id
|
|
`;
|
|
};
|
|
|
|
const run = async (): Promise<void> => {
|
|
const batchSize = parseBatchSize();
|
|
let updated = 0;
|
|
let lastUserId: number | undefined;
|
|
|
|
for (;;) {
|
|
const userIds = await fetchUserIdsWithUnbackfilledRows(lastUserId);
|
|
if (userIds.length === 0) break;
|
|
|
|
for (const userId of userIds) {
|
|
updated += await backfillUser(userId, batchSize);
|
|
await reconcileUserStorageUsage(userId);
|
|
lastUserId = userId;
|
|
}
|
|
console.log(`Updated ${updated} operation payload byte counters total...`);
|
|
}
|
|
|
|
console.log(`Payload byte migration complete. Updated ${updated} operations.`);
|
|
};
|
|
|
|
run()
|
|
.catch((err: unknown) => {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
console.error(`Payload byte migration failed: ${message}`);
|
|
process.exitCode = 1;
|
|
})
|
|
.finally(async () => {
|
|
await prisma.$disconnect();
|
|
});
|