mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user