fix(offhost): keep the bucket's last lifecycle answer through a failed read

A lifecycle read that timed out once was cached as unknown for ten minutes,
which kept every key an off-host object ever needed in the recovery kit: the
kit turned due, the admins were pushed attention, and the next good read
pushed recovered. A failed read now stands on the last answer the bucket gave,
for up to a day.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 17:23:51 +01:00
1 parent db4d5928fd
commit 2bb030b656
2 files changed
+76

No files matched your search

@@ -0,0 +1,56 @@
/**
* A lifecycle read that fails once does not undo what the bucket said an
* hour before. Read as "unknown", it kept every key an off-host object ever
* needed in the recovery kit, the kit turned due and the admins were pushed,
* and the next good read pushed "recovered".
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
LIFECYCLE_UNKNOWN,
type OffhostLifecycle,
} from "@/lib/jobs/offhost-lifecycle";
import { probeOffhostLifecycle, type S3Like } from "@/lib/jobs/offhost-backup";
const CONFIGURED: OffhostLifecycle = {
state: "configured",
expirationDays: 30,
database: { state: "configured", expirationDays: 30 },
};
const bucket = (answer: () => Promise<OffhostLifecycle>) =>
({ getLifecycle: answer }) as unknown as S3Like;
const HOUR = 3_600_000;
const T0 = Date.parse("2026-10-25T00:00:00Z");
beforeEach(() => {
vi.stubEnv("BACKUP_S3_ENDPOINT", "https://bucket.example");
vi.stubEnv("BACKUP_S3_BUCKET", "b");
vi.stubEnv("BACKUP_S3_ACCESS_KEY", "a");
vi.stubEnv("BACKUP_S3_SECRET_KEY", "s");
vi.stubEnv("BACKUP_ENCRYPTION_KEY", "e".repeat(64));
});
afterEach(() => vi.unstubAllEnvs());
describe("the bucket's lifecycle rule, read again", () => {
it("keeps the last answer the bucket gave through a failed read, for a day", async () => {
expect(
await probeOffhostLifecycle(
bucket(async () => CONFIGURED),
T0,
),
).toEqual(CONFIGURED);
const failing = bucket(async () => LIFECYCLE_UNKNOWN);
expect(await probeOffhostLifecycle(failing, T0 + HOUR)).toEqual(CONFIGURED);
const thrown = bucket(async () => {
throw new Error("ETIMEDOUT");
});
expect(await probeOffhostLifecycle(thrown, T0 + 2 * HOUR)).toEqual(
CONFIGURED,
);
// A day without a single good read is an answer of its own.
expect(await probeOffhostLifecycle(failing, T0 + 25 * HOUR)).toEqual(
LIFECYCLE_UNKNOWN,
);
});
});
+20
View File
@@ -758,6 +758,18 @@ export async function getS3Client(cfg: OffhostBackupConfig): Promise<S3Like> {
const LIFECYCLE_CACHE_MS = 10 * 60 * 1000;
let lifecycleCache: { at: number; value: OffhostLifecycle } | null = null;
/**
* The last answer the bucket actually gave (`configured` or `missing`), and
* how long it stands in for a read that failed. A probe that timed out once
* read as "unknown", which keeps every key an off-host object ever needed
* in the recovery kit: the kit turned due, the admins were pushed, and the
* next good read pushed "recovered". A rule does not change between two
* reads an hour apart; a credential that has lost the right to read it for a
* whole day does say something, and then "unknown" is the honest answer.
*/
const LAST_KNOWN_LIFECYCLE_MS = 24 * 60 * 60 * 1000;
let lastKnownLifecycle: { at: number; value: OffhostLifecycle } | null = null;
/**
* The bucket's lifecycle rule for the admin card, cached for ten minutes so a
* page render does not become a bucket call. Never throws.
@@ -782,6 +794,14 @@ export async function probeOffhostLifecycle(
} catch {
value = LIFECYCLE_UNKNOWN;
}
if (value.state !== "unknown") {
lastKnownLifecycle = { at: now, value };
} else if (
lastKnownLifecycle &&
now - lastKnownLifecycle.at < LAST_KNOWN_LIFECYCLE_MS
) {
value = lastKnownLifecycle.value;
}
if (!s3Override) lifecycleCache = { at: now, value };
return value;
}