Files
vnidrop/services/diagnostics-api/src/storage.ts

168 lines
4.4 KiB
TypeScript

import type { NormalizedBugPayload } from "./input";
export type DiagnosticsEnv = Cloudflare.Env & {
INGEST_KEY?: string;
};
export interface StoreResult {
id: string;
duplicate: boolean;
stored: number;
}
export async function storeBug(
payload: NormalizedBugPayload,
env: DiagnosticsEnv,
): Promise<StoreResult> {
const database = env.DB.withSession("first-primary");
const existing = await database
.prepare("SELECT id FROM bugs WHERE id = ?")
.bind(payload.id)
.first<{ id: string }>();
if (existing) return { id: payload.id, duplicate: true, stored: 0 };
const logsKey = payload.logs ? `bugs/${payload.id}/${crypto.randomUUID()}/logs.txt` : null;
if (logsKey) {
await env.BLOBS.put(logsKey, payload.logs, {
httpMetadata: { contentType: "text/plain; charset=utf-8" },
customMetadata: { installId: payload.installId },
});
}
try {
const result = await database
.prepare(
`INSERT INTO bugs (
id, received_at, occurred_at, install_id, app_version, platform,
what_happened, expected, steps, contact, logs_r2_key,
device_json, status, schema_version
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?)
ON CONFLICT(id) DO NOTHING`,
)
.bind(
payload.id,
Date.now(),
payload.occurredAt,
payload.installId,
payload.appVersion,
payload.platform,
payload.whatHappened,
payload.expected,
payload.steps,
payload.contact,
logsKey,
JSON.stringify(payload.device),
payload.schemaVersion,
)
.run();
const duplicate = result.meta.changes === 0;
if (duplicate && logsKey) {
await deleteAttemptBlob(env, logsKey);
}
return { id: payload.id, duplicate, stored: duplicate ? 0 : 1 };
} catch (error) {
if (logsKey) {
await deleteAttemptBlob(env, logsKey);
}
throw error;
}
}
export async function runRetention(env: DiagnosticsEnv): Promise<void> {
const retentionDays = boundedPositiveInt(env.RETENTION_DAYS, 90, 1, 3_650);
const cutoff = Date.now() - retentionDays * 86_400_000;
// Eight passes plus the backlog check stay well within D1's 50 queries per invocation.
for (let pass = 0; pass < 8; pass += 1) {
const hasFullBatch = await runRetentionPass(env, cutoff);
if (!hasFullBatch) return;
}
const bugs = await env.DB.prepare(
"SELECT COUNT(*) AS count FROM bugs WHERE received_at < ?",
)
.bind(cutoff)
.first<{ count: number }>();
console.warn(
JSON.stringify({
message: "diagnostics retention reached its per-run pass limit",
cutoff,
backlog: {
bugs: bugs?.count ?? 0,
},
}),
);
}
async function runRetentionPass(env: DiagnosticsEnv, cutoff: number): Promise<boolean> {
const reportBatchSize = 900;
const bugs = await expiredBlobRows(env.DB, "bugs", "logs_r2_key", cutoff, reportBatchSize);
const blobKeys = bugs
.map((row) => row.blobKey)
.filter((key): key is string => key !== null);
for (let offset = 0; offset < blobKeys.length; offset += 1_000) {
await env.BLOBS.delete(blobKeys.slice(offset, offset + 1_000));
}
if (bugs.length > 0) await deleteRowsById(env.DB, "bugs", bugs).run();
return bugs.length === reportBatchSize;
}
interface ExpiredBlobRow {
id: string;
blobKey: string | null;
}
async function expiredBlobRows(
database: D1Database,
table: "bugs",
column: "logs_r2_key",
cutoff: number,
batchSize: number,
): Promise<ExpiredBlobRow[]> {
const result = await database
.prepare(
`SELECT id, ${column} AS blobKey
FROM ${table}
WHERE received_at < ?
ORDER BY received_at
LIMIT ?`,
)
.bind(cutoff, batchSize)
.all<ExpiredBlobRow>();
return result.results;
}
function deleteRowsById(
database: D1Database,
table: "bugs",
rows: ExpiredBlobRow[],
): D1PreparedStatement {
return database
.prepare(`DELETE FROM ${table} WHERE id IN (SELECT value FROM json_each(?))`)
.bind(JSON.stringify(rows.map((row) => row.id)));
}
async function deleteAttemptBlob(env: DiagnosticsEnv, key: string): Promise<void> {
try {
await env.BLOBS.delete(key);
} catch (error) {
console.error(
JSON.stringify({
message: "failed to remove uncommitted diagnostics blob",
error: error instanceof Error ? error.message : String(error),
}),
);
}
}
function boundedPositiveInt(
raw: string | undefined,
fallback: number,
minimum: number,
maximum: number,
): number {
const parsed = Number(raw);
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) return fallback;
return parsed;
}