mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 08:28:48 +01:00
fix: bound status-card active-hours calculation work
Reuse bounded timezone formatters and upcoming openings only within their exact searched closed ranges. Short-circuit empty windows while preserving the existing cold DST search and precise instant eligibility. Verify cost bounds, cache eviction, timezone transitions and twenty-card ordinary source delivery. Co-Authored-By: Paperclip <paperclip@paperclip.ing>
This commit is contained in:
1 parent
f201195a92
commit
6469daec07
4 files changed
+225
-13
No files matched your search
@@ -272,6 +272,53 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
await quiet(); expect(errors).toEqual([]);
|
||||
}, 30_000);
|
||||
|
||||
it("bounds active-hours conversion work across twenty cards and repeated ordinary company writes", async () => {
|
||||
vi.setSystemTime(new Date("2026-11-02T18:00:00Z"));
|
||||
const hoursPolicy = { ...policy(), activeHours: { start: "09:00", end: "17:00", timezone: "UTC" } };
|
||||
const cards = [];
|
||||
for (let i = 0; i < 20; i++) cards.push(await card("reactive", hoursPolicy));
|
||||
await start(); await quiet();
|
||||
const RealFormatter = Intl.DateTimeFormat;
|
||||
const actualParts = RealFormatter.prototype.formatToParts;
|
||||
let constructions = 0, conversions = 0;
|
||||
const constructor = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function (...args: ConstructorParameters<typeof Intl.DateTimeFormat>) {
|
||||
if (args[0] === "en-GB" && args[1]?.hourCycle === "h23") constructions++;
|
||||
return new RealFormatter(...args);
|
||||
} as unknown as typeof Intl.DateTimeFormat);
|
||||
const parts = vi.spyOn(RealFormatter.prototype, "formatToParts").mockImplementation(function (this: Intl.DateTimeFormat, date) {
|
||||
if (this.resolvedOptions().locale === "en-GB" && this.resolvedOptions().hourCycle === "h23") conversions++;
|
||||
return actualParts.call(this, date);
|
||||
});
|
||||
const target = await issueService(db).create(companyId, { title: "Closed-hours blocker", status: "blocked", priority: "medium" });
|
||||
await settled();
|
||||
expect(constructions).toBeLessThanOrEqual(1);
|
||||
expect(conversions).toBeLessThanOrEqual(1000);
|
||||
const cold = { constructions, conversions };
|
||||
constructions = 0; conversions = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await issueService(db).addComment(target.id, `Closed-hours human update ${i}`, { userId: "board-user" });
|
||||
await settled();
|
||||
}
|
||||
expect(constructions).toBe(0);
|
||||
expect(conversions).toBeGreaterThanOrEqual(200);
|
||||
expect(conversions).toBeLessThanOrEqual(800);
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
const opening = new Date("2026-11-03T09:00:00Z");
|
||||
for (const created of cards) expect((await statusCardService(db).getById(created.id)))
|
||||
.toMatchObject({ state: "paused_hours", pendingChangeCount: 1, nextEvalAt: opening });
|
||||
process.stdout.write(JSON.stringify({ measurement: "status-card company active-hours workload", cards: 20,
|
||||
sourceWrites: 11, cold, warm: { constructions, conversions } }) + "\n");
|
||||
constructor.mockRestore(); parts.mockRestore();
|
||||
await vi.advanceTimersByTimeAsync(opening.getTime() - Date.now()); await settled();
|
||||
expect(wakeup).toHaveBeenCalledTimes(20);
|
||||
for (const created of cards) {
|
||||
const generated = (await statusCardService(db).getById(created.id))!;
|
||||
expect(generated.generatingIssueId).toBeTruthy();
|
||||
await writeback(created.id, generated.generatingIssueId!);
|
||||
}
|
||||
await quiet(); expect(errors).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it("cancels archived or disabled future work and replaces an earlier interval deadline", async () => {
|
||||
await start(); let created = await card("interval"); await settled();
|
||||
const old = coordinator!.nextWakeAt();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { statusCardRefreshPolicySchema } from "@paperclipai/shared";
|
||||
import {
|
||||
chooseStatusCardUpdateKind,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../services/status-card-update-engine.js";
|
||||
|
||||
describe("status card update engine", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
const defaultPolicy = statusCardRefreshPolicySchema.parse({ mode: "interval", intervalMinutes: 15 });
|
||||
|
||||
it("retains non-terminal and terminal status transitions plus membership changes", () => {
|
||||
@@ -101,6 +102,127 @@ describe("status card update engine", () => {
|
||||
expect(nextStatusCardPolicyEligibility({ policy, now, lastChangeAt: now, completedUpdateTimes: [], tokensToday: 0 })).toBeNull();
|
||||
});
|
||||
|
||||
it("bounds empty-window formatter work before any timezone conversion", () => {
|
||||
const policy = statusCardRefreshPolicySchema.parse({ mode: "reactive", debounceSeconds: 1,
|
||||
activeHours: { start: "09:00", end: "09:00", timezone: "Pacific/Chatham" } });
|
||||
const formatter = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function () {
|
||||
throw new Error("Empty active hours must not construct a formatter");
|
||||
});
|
||||
const now = new Date("2026-07-23T14:00:00Z");
|
||||
expect(isWithinStatusCardActiveHours(policy, now)).toBe(false);
|
||||
expect(nextStatusCardPolicyEligibility({ policy, now, lastChangeAt: now, completedUpdateTimes: [], tokensToday: 0 })).toBeNull();
|
||||
expect(formatter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds formatter construction and reuses the next opening across cards and source writes", () => {
|
||||
const policy = statusCardRefreshPolicySchema.parse({ mode: "reactive", debounceSeconds: 1,
|
||||
activeHours: { start: "09:00", end: "17:00", timezone: "America/St_Johns" } });
|
||||
const RealFormatter = Intl.DateTimeFormat;
|
||||
let constructions = 0;
|
||||
vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function (...args: ConstructorParameters<typeof Intl.DateTimeFormat>) {
|
||||
if (++constructions > 1) throw new Error("Card evaluation repeatedly constructed timezone formatters");
|
||||
return new RealFormatter(...args);
|
||||
} as unknown as typeof Intl.DateTimeFormat);
|
||||
const conversions = vi.spyOn(RealFormatter.prototype, "formatToParts");
|
||||
const now = new Date("2026-07-23T22:00:00Z");
|
||||
const input = { policy, now, lastChangeAt: new Date(now.getTime() - 60_000), completedUpdateTimes: [], tokensToday: 0 };
|
||||
const started = performance.now();
|
||||
expect(nextStatusCardPolicyEligibility(input)).toEqual(new Date("2026-07-24T11:30:00Z"));
|
||||
const coldMs = performance.now() - started;
|
||||
const coldConversions = conversions.mock.calls.length;
|
||||
expect(coldConversions).toBeLessThanOrEqual(2881);
|
||||
conversions.mockClear();
|
||||
const warmStarted = performance.now();
|
||||
for (let write = 0; write < 100; write++) for (let card = 0; card < 20; card++) {
|
||||
expect(nextStatusCardPolicyEligibility({ ...input,
|
||||
policy: { ...policy, activeHours: { ...policy.activeHours! } },
|
||||
now: new Date(now.getTime() + write * 1000),
|
||||
})).toEqual(new Date("2026-07-24T11:30:00Z"));
|
||||
}
|
||||
expect(constructions).toBeLessThanOrEqual(1);
|
||||
expect(conversions.mock.calls.length).toBeLessThanOrEqual(2000);
|
||||
process.stdout.write(JSON.stringify({ measurement: "status-card active-hours cost", coldMs, coldConversions, constructions,
|
||||
warmMs: performance.now() - warmStarted, warmConversions: conversions.mock.calls.length, evaluations: 2000 }) + "\n");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["America/New_York", "02:15", "03:30", "2026-03-08T06:50:00Z", "2026-03-08T07:00:00Z"],
|
||||
["America/New_York", "02:15", "02:45", "2026-03-08T06:50:00Z", "2026-03-09T06:15:00Z"],
|
||||
["America/New_York", "02:15", "02:45", "2026-03-07T07:45:00Z", "2026-03-09T06:15:00Z"],
|
||||
["America/New_York", "01:15", "01:45", "2026-11-01T05:50:00Z", "2026-11-01T06:15:00Z"],
|
||||
["America/New_York", "23:30", "01:30", "2026-11-01T05:40:00Z", "2026-11-01T06:00:00Z"],
|
||||
["Australia/Lord_Howe", "02:15", "02:45", "2026-10-03T15:20:00Z", "2026-10-03T15:30:00Z"],
|
||||
["Australia/Lord_Howe", "01:35", "01:45", "2026-04-04T14:50:00Z", "2026-04-04T15:05:00Z"],
|
||||
["Asia/Kathmandu", "09:00", "17:00", "2026-07-23T12:00:00Z", "2026-07-24T03:15:00Z"],
|
||||
["Pacific/Apia", "00:10", "00:20", "2011-12-30T09:50:00Z", "2011-12-30T10:10:00Z"],
|
||||
])("preserves the first real opening in %s %s-%s after %s", (timezone, start, end, time, expected) => {
|
||||
const policy = statusCardRefreshPolicySchema.parse({ mode: "reactive", debounceSeconds: 1, activeHours: { start, end, timezone } });
|
||||
const now = new Date(time);
|
||||
const input = { policy, now, lastChangeAt: new Date(now.getTime() - 60_000), completedUpdateTimes: [], tokensToday: 0 };
|
||||
expect(nextStatusCardPolicyEligibility(input)).toEqual(new Date(expected));
|
||||
// A later closed instant reuses the opening, while the exact opening itself
|
||||
// remains eligible without rounding it into the next minute or day.
|
||||
expect(nextStatusCardPolicyEligibility({ ...input, now: new Date(now.getTime() + 1000) })).toEqual(new Date(expected));
|
||||
const opening = new Date(expected);
|
||||
expect(nextStatusCardPolicyEligibility({ ...input, now: opening })).toEqual(opening);
|
||||
expect(nextStatusCardPolicyEligibility({ ...input, now: new Date(opening.getTime() + 1) })).toEqual(new Date(opening.getTime() + 1));
|
||||
});
|
||||
|
||||
it("invalidates an upcoming opening for clock rollback and changed policy values", () => {
|
||||
const policy = statusCardRefreshPolicySchema.parse({ mode: "reactive", debounceSeconds: 1,
|
||||
activeHours: { start: "09:00", end: "17:00", timezone: "UTC" } });
|
||||
const calculate = (time: string, activeHours = policy.activeHours!) => {
|
||||
const now = new Date(time);
|
||||
return nextStatusCardPolicyEligibility({ policy: { ...policy, activeHours }, now,
|
||||
lastChangeAt: new Date(now.getTime() - 60_000), completedUpdateTimes: [], tokensToday: 0 });
|
||||
};
|
||||
expect(calculate("2026-07-23T18:00:00Z")).toEqual(new Date("2026-07-24T09:00:00Z"));
|
||||
expect(calculate("2026-07-23T08:00:00Z")).toEqual(new Date("2026-07-23T09:00:00Z"));
|
||||
expect(calculate("2026-07-23T17:00:00Z")).toEqual(new Date("2026-07-24T09:00:00Z"));
|
||||
expect(calculate("2026-07-23T18:00:00Z", { ...policy.activeHours!, start: "10:00" })).toEqual(new Date("2026-07-24T10:00:00Z"));
|
||||
expect(calculate("2026-07-23T18:00:00Z", { ...policy.activeHours!, end: "19:00" })).toEqual(new Date("2026-07-23T18:00:00Z"));
|
||||
expect(calculate("2026-07-23T18:00:00Z", { ...policy.activeHours!, timezone: "Asia/Tokyo" })).toEqual(new Date("2026-07-24T00:00:00Z"));
|
||||
});
|
||||
|
||||
it("checks the precise instant before a cached minute opening for historical second offsets", () => {
|
||||
const policy = statusCardRefreshPolicySchema.parse({ mode: "reactive", debounceSeconds: 1,
|
||||
activeHours: { start: "09:00", end: "10:00", timezone: "Africa/Monrovia" } });
|
||||
const now = new Date("1970-01-01T08:00:00Z");
|
||||
const input = { policy, now, lastChangeAt: new Date(now.getTime() - 60_000), completedUpdateTimes: [], tokensToday: 0 };
|
||||
expect(nextStatusCardPolicyEligibility(input)).toEqual(new Date("1970-01-01T09:45:00Z"));
|
||||
const inside = new Date("1970-01-01T09:44:45Z");
|
||||
expect(isWithinStatusCardActiveHours(policy, inside)).toBe(true);
|
||||
expect(nextStatusCardPolicyEligibility({ ...input, now: inside })).toEqual(inside);
|
||||
});
|
||||
|
||||
it("bounds formatter and upcoming-opening cache retention", () => {
|
||||
const now = new Date("2026-07-25T00:00:00Z");
|
||||
const timezonePolicy = (timezone: string) => statusCardRefreshPolicySchema.parse({ mode: "reactive", debounceSeconds: 1,
|
||||
activeHours: { start: "09:00", end: "17:00", timezone } });
|
||||
const zones = Intl.supportedValuesOf("timeZone").slice(0, 34);
|
||||
const first = timezonePolicy(zones[0]!);
|
||||
isWithinStatusCardActiveHours(first, now);
|
||||
for (const zone of zones.slice(1)) isWithinStatusCardActiveHours(timezonePolicy(zone), now);
|
||||
const RealFormatter = Intl.DateTimeFormat;
|
||||
const constructor = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function (...args: ConstructorParameters<typeof Intl.DateTimeFormat>) {
|
||||
return new RealFormatter(...args);
|
||||
} as unknown as typeof Intl.DateTimeFormat);
|
||||
isWithinStatusCardActiveHours(first, now);
|
||||
expect(constructor).toHaveBeenCalledTimes(1);
|
||||
constructor.mockRestore();
|
||||
const time = (minute: number) => `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`;
|
||||
const calculate = (minute: number) => {
|
||||
const policy = statusCardRefreshPolicySchema.parse({ mode: "reactive", debounceSeconds: 1,
|
||||
activeHours: { start: time(minute), end: time(minute + 1), timezone: "UTC" } });
|
||||
return nextStatusCardPolicyEligibility({ policy, now, lastChangeAt: new Date(now.getTime() - 60_000), completedUpdateTimes: [], tokensToday: 0 });
|
||||
};
|
||||
calculate(1);
|
||||
for (let minute = 2; minute <= 258; minute++) calculate(minute);
|
||||
const conversions = vi.spyOn(Intl.DateTimeFormat.prototype, "formatToParts");
|
||||
expect(calculate(1)).toEqual(new Date("2026-07-25T00:01:00Z"));
|
||||
expect(conversions.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("selects full rebuilds for bounded drift rules and incremental otherwise", () => {
|
||||
const base = { hasDocument: true, changeCount: 2, queryVersion: 3, lastUpdateQueryVersion: 3, incrementalCount: 2, configurationChanged: false };
|
||||
expect(chooseStatusCardUpdateKind(base)).toBe("incremental");
|
||||
|
||||
@@ -109,21 +109,36 @@ export function statusCardFingerprintHash(fingerprint: StatusCardFingerprint) {
|
||||
return createHash("sha256").update(JSON.stringify(stable)).digest("hex");
|
||||
}
|
||||
|
||||
// These caches contain only timezone/policy calculations, never card or company
|
||||
// data. Bound both maps because timezone aliases and hour pairs are user input.
|
||||
const activeHoursFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||
const upcomingActiveHours = new Map<string, { closedAt: number; opensAt: number }>();
|
||||
|
||||
function remember<K, V>(cache: Map<K, V>, key: K, value: V, limit: number): V {
|
||||
cache.delete(key);
|
||||
cache.set(key, value);
|
||||
if (cache.size > limit) cache.delete(cache.keys().next().value!);
|
||||
return value;
|
||||
}
|
||||
|
||||
function activeHoursFormatter(timezone: string) {
|
||||
const cached = activeHoursFormatters.get(timezone);
|
||||
return remember(activeHoursFormatters, timezone, cached ?? new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: timezone, hour: "2-digit", minute: "2-digit", hourCycle: "h23",
|
||||
}), 32);
|
||||
}
|
||||
|
||||
export function isWithinStatusCardActiveHours(policy: StatusCardRefreshPolicy, now: Date) {
|
||||
if (!policy.activeHours) return true;
|
||||
const parts = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: policy.activeHours.timezone,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(now);
|
||||
const hour = Number(parts.find((part) => part.type === "hour")?.value ?? 0);
|
||||
const minute = Number(parts.find((part) => part.type === "minute")?.value ?? 0);
|
||||
const current = hour * 60 + minute;
|
||||
const [startHour, startMinute] = policy.activeHours.start.split(":").map(Number);
|
||||
const [endHour, endMinute] = policy.activeHours.end.split(":").map(Number);
|
||||
const start = startHour! * 60 + startMinute!;
|
||||
const end = endHour! * 60 + endMinute!;
|
||||
if (start === end) return false;
|
||||
const parts = activeHoursFormatter(policy.activeHours.timezone).formatToParts(now);
|
||||
const hour = Number(parts.find((part) => part.type === "hour")?.value ?? 0);
|
||||
const minute = Number(parts.find((part) => part.type === "minute")?.value ?? 0);
|
||||
const current = hour * 60 + minute;
|
||||
return start <= end ? current >= start && current < end : current >= start || current < end;
|
||||
}
|
||||
|
||||
@@ -181,6 +196,8 @@ export function nextStatusCardPolicyEligibility(input: {
|
||||
tokensToday: number;
|
||||
}): Date | null {
|
||||
if (input.policy.mode === "manual") return null;
|
||||
const hours = input.policy.activeHours;
|
||||
if (hours && hours.start === hours.end) return null;
|
||||
let at = input.now.getTime();
|
||||
if (input.tokensToday >= (input.policy.dailyTokenCap ?? 100_000)) {
|
||||
const day = new Date(input.now);
|
||||
@@ -194,13 +211,25 @@ export function nextStatusCardPolicyEligibility(input: {
|
||||
.filter(time => time > at - 3_600_000).sort((a, b) => a - b);
|
||||
if (recent.length >= cap) at = Math.max(at, recent[recent.length - cap]! + 3_600_000 + 1);
|
||||
}
|
||||
if (isWithinStatusCardActiveHours(input.policy, new Date(at))) return new Date(at);
|
||||
// Check the precise instant before reusing a minute-aligned opening. Some
|
||||
// historical timezone offsets include seconds, so a window may be open now.
|
||||
if (!hours || isWithinStatusCardActiveHours(input.policy, new Date(at))) return new Date(at);
|
||||
const key = JSON.stringify([hours.timezone, hours.start, hours.end]);
|
||||
const cached = upcomingActiveHours.get(key);
|
||||
if (cached && at >= cached.closedAt && at < cached.opensAt) {
|
||||
remember(upcomingActiveHours, key, cached, 256);
|
||||
return new Date(cached.opensAt);
|
||||
}
|
||||
const closedAt = at;
|
||||
// Active-hour precision is one local minute. Walking UTC minutes also honors
|
||||
// skipped/repeated local times at daylight-saving transitions.
|
||||
const end = at + 48 * 3_600_000;
|
||||
for (at = Math.floor(at / 60_000) * 60_000 + 60_000; at <= end; at += 60_000) {
|
||||
if (isWithinStatusCardActiveHours(input.policy, new Date(at))) return new Date(at);
|
||||
if (isWithinStatusCardActiveHours(input.policy, new Date(at))) {
|
||||
remember(upcomingActiveHours, key, { closedAt, opensAt: at }, 256);
|
||||
return new Date(at);
|
||||
}
|
||||
}
|
||||
// Equal start/end describes an empty window; retain changes, awaiting config.
|
||||
// No opening inside the existing bounded search horizon. Retain changes.
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user