fix(crypto): walk every encrypted column in bounded pages

Key rotation and the rotation status read every column that was not
flagged as a blob with one `findMany` over the whole table.
`Measurement.notesEncrypted` and `MoodEntry.noteEncrypted` are such
columns, so rotation loaded every measurement of the instance: 1.25
million rows on one large account (#1031), and the rotation status page
did the same on every view.

Every column is now walked with the id-cursor pages the blob columns
already used: 25 rows a page for blobs, 5 000 for everything else. The
maintenance script's own per-column walks page the same way. On the seeded
account with 1 460 notes to rotate, peak heap fell from 272 MB to 153 MB,
and it no longer grows with the table.

`scanned` now counts the rows that hold ciphertext for every column, as
it already did for the blob columns, rather than every row of the table
for some columns and only the ciphertext rows for others. The runbook
says so.
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 02:05:19 +02:00
1 parent 7071fa91af
commit 0a54a43799
4 files changed
+154 -104

No files matched your search

+60 -25
View File
@@ -108,6 +108,49 @@ function shouldRotate(value: string | null): boolean {
return id !== getActiveKeyId();
}
/** The part of a Prisma delegate the per-column walks below need. */
interface PagedDelegate<V> {
findMany: (args: {
select: Record<string, true>;
orderBy: { id: "asc" };
take: number;
cursor?: { id: string };
skip?: number;
}) => Promise<Array<Record<string, unknown>>>;
update: (args: {
where: { id: string };
data: Record<string, V>;
}) => Promise<unknown>;
}
/** Rows per page when walking a column. */
const PAGE_SIZE = 5_000;
/**
* Every row of one column, `id` and value only, a page at a time in id order.
* The walks used to read a column in one `findMany`, which for
* `Measurement.notesEncrypted` is every measurement of the instance: 1.25
* million rows on one large account (#1031).
*/
async function* pagedRows(
delegate: PagedDelegate<unknown>,
field: string,
): AsyncGenerator<Record<string, unknown>> {
let cursor: string | null = null;
for (;;) {
const rows = await delegate.findMany({
select: { id: true, [field]: true },
orderBy: { id: "asc" },
take: PAGE_SIZE,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
if (rows.length === 0) return;
yield* rows;
if (rows.length < PAGE_SIZE) return;
cursor = rows[rows.length - 1]!.id as string;
}
}
/**
* Rotate one `String` ciphertext column on a Prisma model. `delegate` is the
* `prisma.<model>` accessor; `field` is the column. Reads only `id` + the
@@ -116,31 +159,28 @@ function shouldRotate(value: string | null): boolean {
async function rotateStringColumn(
table: string,
field: string,
delegate: {
findMany: (args: {
select: Record<string, true>;
}) => Promise<Array<Record<string, unknown>>>;
update: (args: {
where: { id: string };
data: Record<string, string>;
}) => Promise<unknown>;
},
delegate: PagedDelegate<string>,
): Promise<RotationResult> {
const rows = await delegate.findMany({ select: { id: true, [field]: true } });
const result: RotationResult = {
table,
field,
scanned: rows.length,
scanned: 0,
rotated: 0,
errors: 0,
dropped: 0,
};
for (const row of rows) {
for await (const row of pagedRows(
delegate as PagedDelegate<unknown>,
field,
)) {
const v = row[field] as string | null;
// `scanned` counts rows that hold ciphertext, as the in-app rotation does.
if (!v) continue;
result.scanned++;
if (!shouldRotate(v)) continue;
const id = row.id as string;
try {
const re = encrypt(decrypt(v as string));
const re = encrypt(decrypt(v));
await delegate.update({ where: { id }, data: { [field]: re } });
result.rotated++;
} catch (err) {
@@ -159,28 +199,23 @@ async function rotateStringColumn(
async function rotateBytesColumn(
table: string,
field: string,
delegate: {
findMany: (args: {
select: Record<string, true>;
}) => Promise<Array<Record<string, unknown>>>;
update: (args: {
where: { id: string };
data: Record<string, Uint8Array>;
}) => Promise<unknown>;
},
delegate: PagedDelegate<Uint8Array>,
): Promise<RotationResult> {
const rows = await delegate.findMany({ select: { id: true, [field]: true } });
const result: RotationResult = {
table,
field,
scanned: rows.length,
scanned: 0,
rotated: 0,
errors: 0,
dropped: 0,
};
for (const row of rows) {
for await (const row of pagedRows(
delegate as PagedDelegate<unknown>,
field,
)) {
const buf = row[field] as Uint8Array | null;
if (!buf || buf.byteLength === 0) continue;
result.scanned++;
const asString = Buffer.from(buf).toString("utf8");
if (!shouldRotate(asString)) continue;
const id = row.id as string;