perf(medications): start the rrule walk just before the window

between() iterates from DTSTART, so every compliance read walked a daily
schedule from the day the medication was created: about 4 ms per rate,
growing with the medication's age, and enough under a loaded test run to
push a compliance property past its timeout. DAILY and WEEKLY rules repeat
with a fixed period, so the start moves forward by whole periods to just
before the window. A property test holds the occurrences in the window
equal to the walk from the real anchor.
This commit is contained in:
Marc-André Bombeck committed 2026-11-03 20:23:31 +01:00
1 parent 540f4ff84f
commit 381726f471
2 files changed
+156 -8

No files matched your search

@@ -0,0 +1,99 @@
/**
* Moving an RRULE's start forward to just before the window must not move a
* single occurrence inside it. The reference is the rule iterated from its
* real anchor, exactly as the engine did before the start was aligned.
*/
import fc from "fast-check";
import { RRule } from "rrule";
import { describe, expect, it } from "vitest";
import { alignedRruleStart } from "@/lib/medications/scheduling/recurrence";
import { assertProperty } from "../../../../../tests/property/fast-check";
const DAY_MS = 24 * 60 * 60 * 1000;
const BYDAY = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] as const;
const dayArb = fc
.integer({ min: 0, max: 3 * 365 })
.map((n) => new Date(Date.UTC(2024, 0, 1) + n * DAY_MS));
const ruleArb = fc
.record({
freq: fc.constantFrom("DAILY", "WEEKLY", "MONTHLY"),
interval: fc.integer({ min: 1, max: 4 }),
byday: fc.option(
fc.uniqueArray(fc.constantFrom(...BYDAY), { minLength: 1, maxLength: 7 }),
),
byhour: fc.option(
fc.uniqueArray(fc.integer({ min: 0, max: 23 }), {
minLength: 1,
maxLength: 3,
}),
),
wkst: fc.option(fc.constantFrom("MO", "SU")),
count: fc.option(fc.integer({ min: 1, max: 40 })),
})
.map(({ freq, interval, byday, byhour, wkst, count }) =>
[
`FREQ=${freq}`,
interval > 1 ? `INTERVAL=${interval}` : null,
byday ? `BYDAY=${byday.join(",")}` : null,
byhour ? `BYHOUR=${byhour.join(",")}` : null,
wkst ? `WKST=${wkst}` : null,
count ? `COUNT=${count}` : null,
]
.filter(Boolean)
.join(";"),
);
function basic(d: Date): string {
return d
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}/, "");
}
function occurrences(rule: string, start: Date, after: Date, before: Date) {
return RRule.fromString(`DTSTART:${basic(start)}\nRRULE:${rule}`)
.between(after, before, true)
.map((d) => d.toISOString());
}
describe("alignedRruleStart", () => {
it("leaves every occurrence in the window where it was", () => {
assertProperty(
fc.property(
ruleArb,
dayArb,
fc.integer({ min: -30, max: 2 * 365 }),
fc.integer({ min: 1, max: 120 }),
(rule, anchor, offsetDays, spanDays) => {
const after = new Date(anchor.getTime() + offsetDays * DAY_MS);
const before = new Date(after.getTime() + spanDays * DAY_MS);
const aligned = alignedRruleStart(rule, anchor, after);
expect(aligned.getTime()).toBeGreaterThanOrEqual(anchor.getTime());
expect(occurrences(rule, aligned, after, before)).toEqual(
occurrences(rule, anchor, after, before),
);
},
),
300,
);
});
it("moves a daily or weekly start close to the window", () => {
const anchor = new Date(Date.UTC(2024, 5, 1));
const after = new Date(Date.UTC(2026, 2, 1));
for (const rule of ["FREQ=DAILY", "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,TH"]) {
const aligned = alignedRruleStart(rule, anchor, after);
expect(after.getTime() - aligned.getTime()).toBeLessThanOrEqual(
15 * DAY_MS,
);
}
expect(alignedRruleStart("FREQ=DAILY;COUNT=5", anchor, after)).toEqual(
anchor,
);
expect(alignedRruleStart("FREQ=MONTHLY", anchor, after)).toEqual(anchor);
});
});
+57 -8
View File
@@ -787,7 +787,17 @@ function expandRrule(
// to end — the anchor's day is read from its UTC fields and only then is
// the HH:mm materialised in the user's zone. Reading the anchor in the
// user's zone instead turned Monday 00:00Z into Sunday evening west of UTC.
const dtstartLine = `DTSTART:${formatUtcBasic(anchorCivilDay(ctx))}`;
// Walk a generously padded day-anchor window so a per-day timesOfDay
// expansion still hits the requested [from, to] after the time-of-day
// is applied: a calendar day spans instants up to 14 h either side of
// its UTC midnight, so widen the window by 2 days on each side.
const padded = 2 * DAY_MS;
const after = new Date(from.getTime() - padded);
const before = new Date(to.getTime() + padded);
const dtstartLine = `DTSTART:${formatUtcBasic(
alignedRruleStart(rruleStr, anchorCivilDay(ctx), after),
)}`;
// Skip the engine-side UNTIL suffix when the user's RRULE already
// bounds the recurrence with COUNT or UNTIL — RFC 5545 forbids both
// (and any two-UNTIL collision), and RRule.fromString throws on the
@@ -808,13 +818,6 @@ function expandRrule(
return [];
}
// Walk a generously padded day-anchor window so a per-day timesOfDay
// expansion still hits the requested [from, to] after the time-of-day
// is applied: a calendar day spans instants up to 14 h either side of
// its UTC midnight, so widen the window by 2 days on each side.
const padded = 2 * DAY_MS;
const after = new Date(from.getTime() - padded);
const before = new Date(to.getTime() + padded);
// An RRULE with BYHOUR (the measurement-reminder protocols) emits several
// anchors per day; the times of day are expanded per calendar day, so
// collapse the anchors to their days first.
@@ -837,6 +840,52 @@ function expandRrule(
return slots.sort((a, b) => a.at.getTime() - b.at.getTime());
}
/**
* The DTSTART to hand the rrule library for a window that opens at `after`.
*
* `between()` iterates from DTSTART, not from the window, so a schedule
* anchored at a medication created two years ago walked some 700 days on
* every call, and the compliance read calls it many times over: about 4 ms
* per rate, growing with the age of the medication. A DAILY or WEEKLY rule
* repeats with a fixed period of `INTERVAL` days or weeks, so moving the
* start forward by whole periods to just before the window leaves every
* occurrence inside it where it was: the weekday, the week parity of an
* every-other-week rule and the BYHOUR expansion all ride on the period.
* A rule with COUNT keeps its anchor (the count runs from the first
* occurrence), and so does any other frequency, whose period is not a fixed
* number of days. The day before `after` is the latest safe start, because
* a start the rule does not match is not an occurrence, and anything
* between it and `after` is outside the window anyway.
*/
export function alignedRruleStart(
rruleStr: string,
anchor: Date,
after: Date,
): Date {
let options: ReturnType<typeof RRule.parseString>;
try {
options = RRule.parseString(rruleStr);
} catch {
return anchor;
}
if (options.count != null) return anchor;
const interval = options.interval ?? 1;
if (!Number.isInteger(interval) || interval < 1) return anchor;
const periodDays =
options.freq === RRule.DAILY
? interval
: options.freq === RRule.WEEKLY
? 7 * interval
: null;
if (periodDays === null) return anchor;
const periodMs = periodDays * DAY_MS;
const periods = Math.floor(
(after.getTime() - DAY_MS - anchor.getTime()) / periodMs,
);
if (periods <= 0) return anchor;
return new Date(anchor.getTime() + periods * periodMs);
}
// ────────────────────────────────────────────────────────────────────
// Dispatch — legacy fallback
// ────────────────────────────────────────────────────────────────────