@oneuptime/common 12.0.29 → 12.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/Index.ts +11 -0
- package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
- package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
- package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
- package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
- package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
- package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
- package/Server/API/OnCallCalendarAPI.ts +2194 -0
- package/Server/EnvironmentConfig.ts +138 -0
- package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
- package/Server/Services/Index.ts +12 -0
- package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
- package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
- package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
- package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
- package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
- package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
- package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
- package/Server/Services/TeamMemberService.ts +489 -1
- package/Server/Services/TeamService.ts +50 -0
- package/Server/Services/UserNotificationSettingService.ts +44 -1
- package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
- package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
- package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
- package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
- package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
- package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
- package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
- package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
- package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
- package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
- package/Server/Utils/Response.ts +134 -0
- package/Server/Utils/StartServer.ts +10 -0
- package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
- package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
- package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
- package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
- package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
- package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
- package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
- package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
- package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
- package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
- package/Tests/Server/API/Helpers.ts +8 -0
- package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
- package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
- package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
- package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +369 -0
- package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
- package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
- package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
- package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
- package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
- package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
- package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
- package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
- package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
- package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
- package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
- package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
- package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
- package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
- package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
- package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
- package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
- package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
- package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
- package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
- package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
- package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
- package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
- package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
- package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
- package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
- package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
- package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
- package/Types/Calendar/ICalendar.ts +442 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
- package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
- package/Types/OnCallDutyPolicy/Layer.ts +190 -35
- package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
- package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
- package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
- package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
- package/build/dist/Models/DatabaseModels/Index.js +11 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
- package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
- package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
- package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
- package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
- package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
- package/build/dist/Server/EnvironmentConfig.js +102 -0
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
- package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
- package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
- package/build/dist/Server/Services/Index.js +12 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
- package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
- package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
- package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
- package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
- package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/TeamMemberService.js +396 -1
- package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
- package/build/dist/Server/Services/TeamService.js +43 -0
- package/build/dist/Server/Services/TeamService.js.map +1 -1
- package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
- package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
- package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
- package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
- package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
- package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
- package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
- package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
- package/build/dist/Server/Utils/Response.js +97 -0
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +9 -1
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
- package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
- package/build/dist/Types/Calendar/ICalendar.js +290 -0
- package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js +2 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
- package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
- package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
- package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
- package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
- package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
- package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
- package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
- package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1589 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
7
|
+
jest,
|
|
8
|
+
} from "@jest/globals";
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* The caches behind the on-call calendar feeds.
|
|
12
|
+
*
|
|
13
|
+
* What matters here is not that a value comes back after being put in -- it
|
|
14
|
+
* is the invalidation rules (a purge for one user must not reach another
|
|
15
|
+
* user's feed, a purge for one schedule must reach every feed rendered from
|
|
16
|
+
* it), the stale-while-error tier surviving the purges that the fresh tiers do
|
|
17
|
+
* not, the behaviour when Redis is gone (everything keeps working, nothing
|
|
18
|
+
* throws, purges still take effect locally), and the per-process render cap.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
jest.mock("../../../Server/Infrastructure/Redis", () => {
|
|
22
|
+
return {
|
|
23
|
+
__esModule: true,
|
|
24
|
+
default: {
|
|
25
|
+
getClient: jest.fn(),
|
|
26
|
+
isConnected: jest.fn(),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
jest.mock("../../../Server/Utils/Logger", () => {
|
|
32
|
+
return {
|
|
33
|
+
__esModule: true,
|
|
34
|
+
default: {
|
|
35
|
+
debug: jest.fn(),
|
|
36
|
+
info: jest.fn(),
|
|
37
|
+
warn: jest.fn(),
|
|
38
|
+
error: jest.fn(),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
import Redis from "../../../Server/Infrastructure/Redis";
|
|
44
|
+
import logger from "../../../Server/Utils/Logger";
|
|
45
|
+
import OnCallCalendarFeedCache, {
|
|
46
|
+
CachedCalendarBody,
|
|
47
|
+
DEFAULT_RENDER_CONCURRENCY,
|
|
48
|
+
LAST_GOOD_TTL_SECONDS,
|
|
49
|
+
MAX_CACHED_BODY_BYTES,
|
|
50
|
+
MAX_CACHED_LAST_GOOD_BYTES,
|
|
51
|
+
MAX_CACHED_SEGMENT_BYTES,
|
|
52
|
+
MEMORY_MAX_BYTES,
|
|
53
|
+
ON_CALL_CALENDAR_FEED_CACHE_NAMESPACE,
|
|
54
|
+
OnCallCalendarFeedCacheScope,
|
|
55
|
+
} from "../../../Server/Infrastructure/OnCallCalendarFeedCache";
|
|
56
|
+
|
|
57
|
+
type MockedFn = ReturnType<typeof jest.fn>;
|
|
58
|
+
|
|
59
|
+
const getClientMock: MockedFn = Redis.getClient as unknown as MockedFn;
|
|
60
|
+
const isConnectedMock: MockedFn = Redis.isConnected as unknown as MockedFn;
|
|
61
|
+
const loggerWarnMock: MockedFn = logger.warn as unknown as MockedFn;
|
|
62
|
+
const loggerErrorMock: MockedFn = logger.error as unknown as MockedFn;
|
|
63
|
+
|
|
64
|
+
/*
|
|
65
|
+
* A fake ioredis client that stores strings with a TTL, the only surface
|
|
66
|
+
* GlobalCache.getString / setString use. `failWrites` / `failReads` simulate a
|
|
67
|
+
* half-broken connection (client present, commands throwing), which is a
|
|
68
|
+
* different failure from "not connected" and must be handled the same way.
|
|
69
|
+
*/
|
|
70
|
+
class FakeRedis {
|
|
71
|
+
public store: Map<string, { value: string; ttl: number }> = new Map();
|
|
72
|
+
public failReads: boolean = false;
|
|
73
|
+
public failWrites: boolean = false;
|
|
74
|
+
public getCalls: number = 0;
|
|
75
|
+
public setCalls: number = 0;
|
|
76
|
+
|
|
77
|
+
public async get(key: string): Promise<string | null> {
|
|
78
|
+
this.getCalls++;
|
|
79
|
+
|
|
80
|
+
if (this.failReads) {
|
|
81
|
+
throw new Error("read failed");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return this.store.get(key)?.value ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public async set(
|
|
88
|
+
key: string,
|
|
89
|
+
value: string,
|
|
90
|
+
_mode: string,
|
|
91
|
+
ttl: number,
|
|
92
|
+
): Promise<string> {
|
|
93
|
+
this.setCalls++;
|
|
94
|
+
|
|
95
|
+
if (this.failWrites) {
|
|
96
|
+
throw new Error("write failed");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this.store.set(key, { value, ttl });
|
|
100
|
+
|
|
101
|
+
return "OK";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public keys(): Array<string> {
|
|
105
|
+
return Array.from(this.store.keys());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public keysWithPrefix(prefix: string): Array<string> {
|
|
109
|
+
return this.keys().filter((key: string) => {
|
|
110
|
+
return key.startsWith(
|
|
111
|
+
`${ON_CALL_CALENDAR_FEED_CACHE_NAMESPACE}-${prefix}`,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const PROJECT_A: string = "11111111-1111-4111-8111-111111111111";
|
|
118
|
+
const PROJECT_B: string = "22222222-2222-4222-8222-222222222222";
|
|
119
|
+
const USER_1: string = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
120
|
+
const USER_2: string = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
|
121
|
+
const SCHEDULE_X: string = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
|
122
|
+
const SCHEDULE_Y: string = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
|
|
123
|
+
|
|
124
|
+
function body(text: string): CachedCalendarBody {
|
|
125
|
+
return {
|
|
126
|
+
body: `BEGIN:VCALENDAR\r\n${text}\r\nEND:VCALENDAR\r\n`,
|
|
127
|
+
etag: `"${text}"`,
|
|
128
|
+
lastModified: new Date("2026-08-30T10:11:12.000Z"),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
describe("OnCallCalendarFeedCache", () => {
|
|
133
|
+
let redis: FakeRedis;
|
|
134
|
+
|
|
135
|
+
beforeEach(() => {
|
|
136
|
+
jest.clearAllMocks();
|
|
137
|
+
redis = new FakeRedis();
|
|
138
|
+
getClientMock.mockReturnValue(redis);
|
|
139
|
+
isConnectedMock.mockReturnValue(true);
|
|
140
|
+
OnCallCalendarFeedCache.clearInProcessState();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
afterEach(() => {
|
|
144
|
+
OnCallCalendarFeedCache.clearInProcessState();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// -- Schedule segments -------------------------------------------------
|
|
148
|
+
|
|
149
|
+
describe("getOrRenderScheduleSegments", () => {
|
|
150
|
+
const render: () => Promise<
|
|
151
|
+
Array<{ start: string; user: string }>
|
|
152
|
+
> = async () => {
|
|
153
|
+
return [{ start: "2026-09-01T00:00:00.000Z", user: USER_1 }];
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
it("renders on a miss and returns the rendered value", async () => {
|
|
157
|
+
const renderFn: MockedFn = jest.fn(render);
|
|
158
|
+
|
|
159
|
+
const result: unknown =
|
|
160
|
+
await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
161
|
+
scheduleId: SCHEDULE_X,
|
|
162
|
+
key: "v1:day1:2:90",
|
|
163
|
+
ttlSeconds: 3600,
|
|
164
|
+
render: renderFn as unknown as () => Promise<unknown>,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(result).toEqual(await render());
|
|
168
|
+
expect(renderFn).toHaveBeenCalledTimes(1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("serves the second read from Redis without rendering again", async () => {
|
|
172
|
+
const renderFn: MockedFn = jest.fn(render);
|
|
173
|
+
|
|
174
|
+
const read: () => Promise<unknown> = () => {
|
|
175
|
+
return OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
176
|
+
scheduleId: SCHEDULE_X,
|
|
177
|
+
key: "v1:day1:2:90",
|
|
178
|
+
ttlSeconds: 3600,
|
|
179
|
+
render: renderFn as unknown as () => Promise<unknown>,
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const first: unknown = await read();
|
|
184
|
+
const second: unknown = await read();
|
|
185
|
+
|
|
186
|
+
expect(second).toEqual(first);
|
|
187
|
+
expect(renderFn).toHaveBeenCalledTimes(1);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("stores the segments under the namespace with the requested TTL", async () => {
|
|
191
|
+
await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
192
|
+
scheduleId: SCHEDULE_X,
|
|
193
|
+
key: "v1",
|
|
194
|
+
ttlSeconds: 3600,
|
|
195
|
+
render,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const keys: Array<string> = redis.keysWithPrefix("seg:");
|
|
199
|
+
|
|
200
|
+
expect(keys).toHaveLength(1);
|
|
201
|
+
expect(redis.store.get(keys[0]!)?.ttl).toBe(3600);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("keys on the caller's key, so a different window is a different entry", async () => {
|
|
205
|
+
const renderFn: MockedFn = jest.fn(render);
|
|
206
|
+
|
|
207
|
+
for (const key of ["v1:day1", "v1:day2", "v2:day1"]) {
|
|
208
|
+
await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
209
|
+
scheduleId: SCHEDULE_X,
|
|
210
|
+
key,
|
|
211
|
+
ttlSeconds: 3600,
|
|
212
|
+
render: renderFn as unknown as () => Promise<unknown>,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
expect(renderFn).toHaveBeenCalledTimes(3);
|
|
217
|
+
expect(redis.keysWithPrefix("seg:")).toHaveLength(3);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("keys on the schedule, so two schedules with the same key do not collide", async () => {
|
|
221
|
+
const first: unknown =
|
|
222
|
+
await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
223
|
+
scheduleId: SCHEDULE_X,
|
|
224
|
+
key: "same",
|
|
225
|
+
ttlSeconds: 3600,
|
|
226
|
+
render: async () => {
|
|
227
|
+
return "x";
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
const second: unknown =
|
|
231
|
+
await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
232
|
+
scheduleId: SCHEDULE_Y,
|
|
233
|
+
key: "same",
|
|
234
|
+
ttlSeconds: 3600,
|
|
235
|
+
render: async () => {
|
|
236
|
+
return "y";
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(first).toBe("x");
|
|
241
|
+
expect(second).toBe("y");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("never puts the raw key or schedule id into the Redis key", async () => {
|
|
245
|
+
await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
246
|
+
scheduleId: SCHEDULE_X,
|
|
247
|
+
key: "secret-ish:v1",
|
|
248
|
+
ttlSeconds: 3600,
|
|
249
|
+
render,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
for (const key of redis.keysWithPrefix("seg:")) {
|
|
253
|
+
expect(key).not.toContain("secret-ish");
|
|
254
|
+
expect(key).not.toContain(SCHEDULE_X);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("re-renders after purgeForSchedule for that schedule", async () => {
|
|
259
|
+
const renderFn: MockedFn = jest.fn(render);
|
|
260
|
+
|
|
261
|
+
const read: () => Promise<unknown> = () => {
|
|
262
|
+
return OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
263
|
+
scheduleId: SCHEDULE_X,
|
|
264
|
+
key: "v1",
|
|
265
|
+
ttlSeconds: 3600,
|
|
266
|
+
render: renderFn as unknown as () => Promise<unknown>,
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
await read();
|
|
271
|
+
await OnCallCalendarFeedCache.purgeForSchedule(SCHEDULE_X);
|
|
272
|
+
await read();
|
|
273
|
+
|
|
274
|
+
expect(renderFn).toHaveBeenCalledTimes(2);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("is not purged by purgeForSchedule on a different schedule", async () => {
|
|
278
|
+
const renderFn: MockedFn = jest.fn(render);
|
|
279
|
+
|
|
280
|
+
const read: () => Promise<unknown> = () => {
|
|
281
|
+
return OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
282
|
+
scheduleId: SCHEDULE_X,
|
|
283
|
+
key: "v1",
|
|
284
|
+
ttlSeconds: 3600,
|
|
285
|
+
render: renderFn as unknown as () => Promise<unknown>,
|
|
286
|
+
});
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
await read();
|
|
290
|
+
await OnCallCalendarFeedCache.purgeForSchedule(SCHEDULE_Y);
|
|
291
|
+
await read();
|
|
292
|
+
|
|
293
|
+
expect(renderFn).toHaveBeenCalledTimes(1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("does not cache and propagates the error when the render throws", async () => {
|
|
297
|
+
const failing: () => Promise<unknown> = async () => {
|
|
298
|
+
throw new Error("iteration cap");
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
await expect(
|
|
302
|
+
OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
303
|
+
scheduleId: SCHEDULE_X,
|
|
304
|
+
key: "v1",
|
|
305
|
+
ttlSeconds: 3600,
|
|
306
|
+
render: failing,
|
|
307
|
+
}),
|
|
308
|
+
).rejects.toThrow("iteration cap");
|
|
309
|
+
|
|
310
|
+
expect(redis.keysWithPrefix("seg:")).toHaveLength(0);
|
|
311
|
+
|
|
312
|
+
/* And a later successful render is not blocked by the failed one. */
|
|
313
|
+
const renderFn: MockedFn = jest.fn(render);
|
|
314
|
+
|
|
315
|
+
await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
316
|
+
scheduleId: SCHEDULE_X,
|
|
317
|
+
key: "v1",
|
|
318
|
+
ttlSeconds: 3600,
|
|
319
|
+
render: renderFn as unknown as () => Promise<unknown>,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
expect(renderFn).toHaveBeenCalledTimes(1);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("treats a corrupt cached entry as a miss and re-renders", async () => {
|
|
326
|
+
await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
327
|
+
scheduleId: SCHEDULE_X,
|
|
328
|
+
key: "v1",
|
|
329
|
+
ttlSeconds: 3600,
|
|
330
|
+
render,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const [key] = redis.keysWithPrefix("seg:");
|
|
334
|
+
redis.store.set(key!, { value: "{not json", ttl: 3600 });
|
|
335
|
+
|
|
336
|
+
const renderFn: MockedFn = jest.fn(render);
|
|
337
|
+
|
|
338
|
+
const result: unknown =
|
|
339
|
+
await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
340
|
+
scheduleId: SCHEDULE_X,
|
|
341
|
+
key: "v1",
|
|
342
|
+
ttlSeconds: 3600,
|
|
343
|
+
render: renderFn as unknown as () => Promise<unknown>,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(result).toEqual(await render());
|
|
347
|
+
expect(renderFn).toHaveBeenCalledTimes(1);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("shares one in-flight render between concurrent misses for the same key", async () => {
|
|
351
|
+
let resolveRender: (value: unknown) => void = () => {};
|
|
352
|
+
let renderCalls: number = 0;
|
|
353
|
+
|
|
354
|
+
const slowRender: () => Promise<unknown> = () => {
|
|
355
|
+
renderCalls++;
|
|
356
|
+
return new Promise<unknown>((resolve: (value: unknown) => void) => {
|
|
357
|
+
resolveRender = resolve;
|
|
358
|
+
});
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const read: () => Promise<unknown> = () => {
|
|
362
|
+
return OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
363
|
+
scheduleId: SCHEDULE_X,
|
|
364
|
+
key: "v1",
|
|
365
|
+
ttlSeconds: 3600,
|
|
366
|
+
render: slowRender,
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const first: Promise<unknown> = read();
|
|
371
|
+
const second: Promise<unknown> = read();
|
|
372
|
+
const third: Promise<unknown> = read();
|
|
373
|
+
|
|
374
|
+
/* Let both reads get past their cache miss and register the in-flight. */
|
|
375
|
+
await new Promise<void>((resolve: () => void) => {
|
|
376
|
+
setTimeout(resolve, 0);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
resolveRender(["shared"]);
|
|
380
|
+
|
|
381
|
+
expect(await first).toEqual(["shared"]);
|
|
382
|
+
expect(await second).toEqual(["shared"]);
|
|
383
|
+
expect(await third).toEqual(["shared"]);
|
|
384
|
+
expect(renderCalls).toBe(1);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("hands back plain JSON, so a Date in the rendered value comes back as a string", async () => {
|
|
388
|
+
const withDate: () => Promise<{ at: Date }> = async () => {
|
|
389
|
+
return { at: new Date("2026-09-01T00:00:00.000Z") };
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
393
|
+
scheduleId: SCHEDULE_X,
|
|
394
|
+
key: "v1",
|
|
395
|
+
ttlSeconds: 3600,
|
|
396
|
+
render: withDate,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const cached: { at: unknown } =
|
|
400
|
+
await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
401
|
+
scheduleId: SCHEDULE_X,
|
|
402
|
+
key: "v1",
|
|
403
|
+
ttlSeconds: 3600,
|
|
404
|
+
render: withDate,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
expect(cached.at).toBe("2026-09-01T00:00:00.000Z");
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// -- Batched schedule segments -----------------------------------------
|
|
412
|
+
|
|
413
|
+
/*
|
|
414
|
+
* A feed render reads EVERY schedule it depends on. Reading them one at a
|
|
415
|
+
* time meant one resolver call per schedule -- seven queries each, the same
|
|
416
|
+
* user and project rows re-read every time -- so the batch exists to make
|
|
417
|
+
* one call for every schedule that missed.
|
|
418
|
+
*/
|
|
419
|
+
describe("getOrRenderScheduleSegmentsBatch", () => {
|
|
420
|
+
function segmentsFor(ids: Array<string>): Map<string, { id: string }> {
|
|
421
|
+
const out: Map<string, { id: string }> = new Map();
|
|
422
|
+
|
|
423
|
+
for (const id of ids) {
|
|
424
|
+
out.set(id, { id });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
it("renders every miss in ONE call and caches each under its own key", async () => {
|
|
431
|
+
const renderMissing: MockedFn = jest.fn(
|
|
432
|
+
async (missing: Array<{ scheduleId: string; key: string }>) => {
|
|
433
|
+
return segmentsFor(
|
|
434
|
+
missing.map((entry: { scheduleId: string }) => {
|
|
435
|
+
return entry.scheduleId;
|
|
436
|
+
}),
|
|
437
|
+
);
|
|
438
|
+
},
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
const first: Map<string, { id: string }> =
|
|
442
|
+
(await OnCallCalendarFeedCache.getOrRenderScheduleSegmentsBatch({
|
|
443
|
+
entries: [
|
|
444
|
+
{ scheduleId: SCHEDULE_X, key: "v1" },
|
|
445
|
+
{ scheduleId: SCHEDULE_Y, key: "v1" },
|
|
446
|
+
],
|
|
447
|
+
ttlSeconds: 3600,
|
|
448
|
+
renderMissing: renderMissing as never,
|
|
449
|
+
})) as Map<string, { id: string }>;
|
|
450
|
+
|
|
451
|
+
expect(renderMissing).toHaveBeenCalledTimes(1);
|
|
452
|
+
expect(
|
|
453
|
+
(renderMissing.mock.calls[0]?.[0] as Array<{ scheduleId: string }>).map(
|
|
454
|
+
(entry: { scheduleId: string }) => {
|
|
455
|
+
return entry.scheduleId;
|
|
456
|
+
},
|
|
457
|
+
),
|
|
458
|
+
).toEqual([SCHEDULE_X, SCHEDULE_Y]);
|
|
459
|
+
expect(first.get(SCHEDULE_X)).toEqual({ id: SCHEDULE_X });
|
|
460
|
+
expect(first.get(SCHEDULE_Y)).toEqual({ id: SCHEDULE_Y });
|
|
461
|
+
expect(redis.keysWithPrefix("seg:")).toHaveLength(2);
|
|
462
|
+
|
|
463
|
+
/* Both are hits now, so renderMissing is not called again at all. */
|
|
464
|
+
const second: Map<string, { id: string }> =
|
|
465
|
+
(await OnCallCalendarFeedCache.getOrRenderScheduleSegmentsBatch({
|
|
466
|
+
entries: [
|
|
467
|
+
{ scheduleId: SCHEDULE_X, key: "v1" },
|
|
468
|
+
{ scheduleId: SCHEDULE_Y, key: "v1" },
|
|
469
|
+
],
|
|
470
|
+
ttlSeconds: 3600,
|
|
471
|
+
renderMissing: renderMissing as never,
|
|
472
|
+
})) as Map<string, { id: string }>;
|
|
473
|
+
|
|
474
|
+
expect(renderMissing).toHaveBeenCalledTimes(1);
|
|
475
|
+
expect(second.get(SCHEDULE_Y)).toEqual({ id: SCHEDULE_Y });
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("asks only for the schedules that missed", async () => {
|
|
479
|
+
const renderMissing: MockedFn = jest.fn(
|
|
480
|
+
async (missing: Array<{ scheduleId: string; key: string }>) => {
|
|
481
|
+
return segmentsFor(
|
|
482
|
+
missing.map((entry: { scheduleId: string }) => {
|
|
483
|
+
return entry.scheduleId;
|
|
484
|
+
}),
|
|
485
|
+
);
|
|
486
|
+
},
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
await OnCallCalendarFeedCache.getOrRenderScheduleSegmentsBatch({
|
|
490
|
+
entries: [{ scheduleId: SCHEDULE_X, key: "v1" }],
|
|
491
|
+
ttlSeconds: 3600,
|
|
492
|
+
renderMissing: renderMissing as never,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
await OnCallCalendarFeedCache.getOrRenderScheduleSegmentsBatch({
|
|
496
|
+
entries: [
|
|
497
|
+
{ scheduleId: SCHEDULE_X, key: "v1" },
|
|
498
|
+
{ scheduleId: SCHEDULE_Y, key: "v1" },
|
|
499
|
+
],
|
|
500
|
+
ttlSeconds: 3600,
|
|
501
|
+
renderMissing: renderMissing as never,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
expect(
|
|
505
|
+
(renderMissing.mock.calls[1]?.[0] as Array<{ scheduleId: string }>).map(
|
|
506
|
+
(entry: { scheduleId: string }) => {
|
|
507
|
+
return entry.scheduleId;
|
|
508
|
+
},
|
|
509
|
+
),
|
|
510
|
+
).toEqual([SCHEDULE_Y]);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("a repeated schedule id is one read and one render", async () => {
|
|
514
|
+
const renderMissing: MockedFn = jest.fn(
|
|
515
|
+
async (missing: Array<{ scheduleId: string }>) => {
|
|
516
|
+
return segmentsFor(
|
|
517
|
+
missing.map((entry: { scheduleId: string }) => {
|
|
518
|
+
return entry.scheduleId;
|
|
519
|
+
}),
|
|
520
|
+
);
|
|
521
|
+
},
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
await OnCallCalendarFeedCache.getOrRenderScheduleSegmentsBatch({
|
|
525
|
+
entries: [
|
|
526
|
+
{ scheduleId: SCHEDULE_X, key: "v1" },
|
|
527
|
+
{ scheduleId: SCHEDULE_X, key: "v1" },
|
|
528
|
+
],
|
|
529
|
+
ttlSeconds: 3600,
|
|
530
|
+
renderMissing: renderMissing as never,
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
expect(renderMissing.mock.calls[0]?.[0] as Array<unknown>).toHaveLength(
|
|
534
|
+
1,
|
|
535
|
+
);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("joins a render another caller already has in flight", async () => {
|
|
539
|
+
let release: (value: unknown) => void = () => {};
|
|
540
|
+
let calls: number = 0;
|
|
541
|
+
|
|
542
|
+
const slow: MockedFn = jest.fn(
|
|
543
|
+
async (missing: Array<{ scheduleId: string }>) => {
|
|
544
|
+
calls++;
|
|
545
|
+
|
|
546
|
+
await new Promise<unknown>((resolve: (value: unknown) => void) => {
|
|
547
|
+
release = resolve;
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
return segmentsFor(
|
|
551
|
+
missing.map((entry: { scheduleId: string }) => {
|
|
552
|
+
return entry.scheduleId;
|
|
553
|
+
}),
|
|
554
|
+
);
|
|
555
|
+
},
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
const read: () => Promise<unknown> = () => {
|
|
559
|
+
return OnCallCalendarFeedCache.getOrRenderScheduleSegmentsBatch({
|
|
560
|
+
entries: [{ scheduleId: SCHEDULE_X, key: "v1" }],
|
|
561
|
+
ttlSeconds: 3600,
|
|
562
|
+
renderMissing: slow as never,
|
|
563
|
+
});
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const a: Promise<unknown> = read();
|
|
567
|
+
const b: Promise<unknown> = read();
|
|
568
|
+
|
|
569
|
+
await new Promise<void>((resolve: () => void) => {
|
|
570
|
+
setTimeout(resolve, 0);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
release(undefined);
|
|
574
|
+
|
|
575
|
+
expect(
|
|
576
|
+
((await a) as Map<string, { id: string }>).get(SCHEDULE_X),
|
|
577
|
+
).toEqual({ id: SCHEDULE_X });
|
|
578
|
+
expect(
|
|
579
|
+
((await b) as Map<string, { id: string }>).get(SCHEDULE_X),
|
|
580
|
+
).toEqual({ id: SCHEDULE_X });
|
|
581
|
+
expect(calls).toBe(1);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("propagates a failure to every caller and caches nothing", async () => {
|
|
585
|
+
const failing: MockedFn = jest.fn(async () => {
|
|
586
|
+
throw new Error("resolver exploded");
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
await expect(
|
|
590
|
+
OnCallCalendarFeedCache.getOrRenderScheduleSegmentsBatch({
|
|
591
|
+
entries: [
|
|
592
|
+
{ scheduleId: SCHEDULE_X, key: "v1" },
|
|
593
|
+
{ scheduleId: SCHEDULE_Y, key: "v1" },
|
|
594
|
+
],
|
|
595
|
+
ttlSeconds: 3600,
|
|
596
|
+
renderMissing: failing as never,
|
|
597
|
+
}),
|
|
598
|
+
).rejects.toThrow("resolver exploded");
|
|
599
|
+
|
|
600
|
+
expect(redis.keysWithPrefix("seg:")).toHaveLength(0);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
/*
|
|
604
|
+
* A schedule's segments carry every user's shifts over the window. An
|
|
605
|
+
* hourly rotation over the widest window is megabytes, and the compose
|
|
606
|
+
* Redis has no maxmemory: the value is returned to the caller, it is just
|
|
607
|
+
* not stored.
|
|
608
|
+
*/
|
|
609
|
+
it("returns but does not store a segment over the size cap", async () => {
|
|
610
|
+
const huge: MockedFn = jest.fn(async () => {
|
|
611
|
+
return new Map<string, { blob: string }>([
|
|
612
|
+
[SCHEDULE_X, { blob: "x".repeat(MAX_CACHED_SEGMENT_BYTES + 1) }],
|
|
613
|
+
]);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
const result: Map<string, { blob: string }> =
|
|
617
|
+
(await OnCallCalendarFeedCache.getOrRenderScheduleSegmentsBatch({
|
|
618
|
+
entries: [{ scheduleId: SCHEDULE_X, key: "v1" }],
|
|
619
|
+
ttlSeconds: 3600,
|
|
620
|
+
renderMissing: huge as never,
|
|
621
|
+
})) as Map<string, { blob: string }>;
|
|
622
|
+
|
|
623
|
+
expect(result.get(SCHEDULE_X)?.blob).toHaveLength(
|
|
624
|
+
MAX_CACHED_SEGMENT_BYTES + 1,
|
|
625
|
+
);
|
|
626
|
+
expect(redis.keysWithPrefix("seg:")).toHaveLength(0);
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// -- Body cache ---------------------------------------------------------
|
|
631
|
+
|
|
632
|
+
describe("body cache", () => {
|
|
633
|
+
const scope: OnCallCalendarFeedCacheScope = {
|
|
634
|
+
projectId: PROJECT_A,
|
|
635
|
+
userId: USER_1,
|
|
636
|
+
scheduleIds: [SCHEDULE_X, SCHEDULE_Y],
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
it("returns null on a miss", async () => {
|
|
640
|
+
expect(
|
|
641
|
+
await OnCallCalendarFeedCache.getBody({ key: "tokenhash:day1", scope }),
|
|
642
|
+
).toBeNull();
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("round-trips a body with its ETag and Last-Modified as a Date", async () => {
|
|
646
|
+
const stored: boolean = await OnCallCalendarFeedCache.setBody({
|
|
647
|
+
key: "tokenhash:day1",
|
|
648
|
+
scope,
|
|
649
|
+
value: body("personal"),
|
|
650
|
+
ttlSeconds: 300,
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
expect(stored).toBe(true);
|
|
654
|
+
|
|
655
|
+
const cached: CachedCalendarBody | null =
|
|
656
|
+
await OnCallCalendarFeedCache.getBody({ key: "tokenhash:day1", scope });
|
|
657
|
+
|
|
658
|
+
expect(cached).not.toBeNull();
|
|
659
|
+
expect(cached?.body).toBe(body("personal").body);
|
|
660
|
+
expect(cached?.etag).toBe('"personal"');
|
|
661
|
+
expect(cached?.lastModified).toBeInstanceOf(Date);
|
|
662
|
+
expect(cached?.lastModified.toISOString()).toBe(
|
|
663
|
+
"2026-08-30T10:11:12.000Z",
|
|
664
|
+
);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it("stores the body with the requested TTL", async () => {
|
|
668
|
+
await OnCallCalendarFeedCache.setBody({
|
|
669
|
+
key: "k",
|
|
670
|
+
scope,
|
|
671
|
+
value: body("x"),
|
|
672
|
+
ttlSeconds: 300,
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
const keys: Array<string> = redis.keysWithPrefix("body:");
|
|
676
|
+
|
|
677
|
+
expect(keys).toHaveLength(1);
|
|
678
|
+
expect(redis.store.get(keys[0]!)?.ttl).toBe(300);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it("never puts the caller's key (which carries the token hash) into the Redis key", async () => {
|
|
682
|
+
await OnCallCalendarFeedCache.setBody({
|
|
683
|
+
key: "deadbeefcafe:day1",
|
|
684
|
+
scope,
|
|
685
|
+
value: body("x"),
|
|
686
|
+
ttlSeconds: 300,
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
for (const key of redis.keysWithPrefix("body:")) {
|
|
690
|
+
expect(key).not.toContain("deadbeefcafe");
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it("keeps different keys apart", async () => {
|
|
695
|
+
await OnCallCalendarFeedCache.setBody({
|
|
696
|
+
key: "one",
|
|
697
|
+
scope,
|
|
698
|
+
value: body("one"),
|
|
699
|
+
ttlSeconds: 300,
|
|
700
|
+
});
|
|
701
|
+
await OnCallCalendarFeedCache.setBody({
|
|
702
|
+
key: "two",
|
|
703
|
+
scope,
|
|
704
|
+
value: body("two"),
|
|
705
|
+
ttlSeconds: 300,
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
expect(
|
|
709
|
+
(await OnCallCalendarFeedCache.getBody({ key: "one", scope }))?.etag,
|
|
710
|
+
).toBe('"one"');
|
|
711
|
+
expect(
|
|
712
|
+
(await OnCallCalendarFeedCache.getBody({ key: "two", scope }))?.etag,
|
|
713
|
+
).toBe('"two"');
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it("refuses to cache a body over the size cap, without throwing", async () => {
|
|
717
|
+
const huge: CachedCalendarBody = {
|
|
718
|
+
...body("huge"),
|
|
719
|
+
body: "x".repeat(MAX_CACHED_BODY_BYTES + 1),
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
const stored: boolean = await OnCallCalendarFeedCache.setBody({
|
|
723
|
+
key: "k",
|
|
724
|
+
scope,
|
|
725
|
+
value: huge,
|
|
726
|
+
ttlSeconds: 300,
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
expect(stored).toBe(false);
|
|
730
|
+
expect(redis.keysWithPrefix("body:")).toHaveLength(0);
|
|
731
|
+
expect(await OnCallCalendarFeedCache.getBody({ key: "k", scope })).toBe(
|
|
732
|
+
null,
|
|
733
|
+
);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it("measures the cap in bytes, not characters", async () => {
|
|
737
|
+
/* Two-byte characters: half the cap in characters is over it in bytes. */
|
|
738
|
+
const twoByte: CachedCalendarBody = {
|
|
739
|
+
...body("bytes"),
|
|
740
|
+
body: "Å".repeat(MAX_CACHED_BODY_BYTES / 2 + 1),
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
expect(
|
|
744
|
+
await OnCallCalendarFeedCache.setBody({
|
|
745
|
+
key: "k",
|
|
746
|
+
scope,
|
|
747
|
+
value: twoByte,
|
|
748
|
+
ttlSeconds: 300,
|
|
749
|
+
}),
|
|
750
|
+
).toBe(false);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it("caches a body exactly at the cap", async () => {
|
|
754
|
+
const atCap: CachedCalendarBody = {
|
|
755
|
+
...body("cap"),
|
|
756
|
+
body: "x".repeat(MAX_CACHED_BODY_BYTES),
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
expect(
|
|
760
|
+
await OnCallCalendarFeedCache.setBody({
|
|
761
|
+
key: "k",
|
|
762
|
+
scope,
|
|
763
|
+
value: atCap,
|
|
764
|
+
ttlSeconds: 300,
|
|
765
|
+
}),
|
|
766
|
+
).toBe(true);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("treats a corrupt cached body as a miss", async () => {
|
|
770
|
+
await OnCallCalendarFeedCache.setBody({
|
|
771
|
+
key: "k",
|
|
772
|
+
scope,
|
|
773
|
+
value: body("x"),
|
|
774
|
+
ttlSeconds: 300,
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
const [key] = redis.keysWithPrefix("body:");
|
|
778
|
+
|
|
779
|
+
for (const corrupt of [
|
|
780
|
+
"{not json",
|
|
781
|
+
JSON.stringify({ body: "x" }),
|
|
782
|
+
JSON.stringify({ body: "x", etag: 1, lastModified: "2026-01-01" }),
|
|
783
|
+
JSON.stringify({ body: "x", etag: "e", lastModified: "not a date" }),
|
|
784
|
+
JSON.stringify([]),
|
|
785
|
+
"null",
|
|
786
|
+
]) {
|
|
787
|
+
redis.store.set(key!, { value: corrupt, ttl: 300 });
|
|
788
|
+
|
|
789
|
+
expect(
|
|
790
|
+
await OnCallCalendarFeedCache.getBody({ key: "k", scope }),
|
|
791
|
+
).toBeNull();
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it("orders schedule ids canonically, so the same set in a different order is one entry", async () => {
|
|
796
|
+
await OnCallCalendarFeedCache.setBody({
|
|
797
|
+
key: "k",
|
|
798
|
+
scope: {
|
|
799
|
+
projectId: PROJECT_A,
|
|
800
|
+
userId: USER_1,
|
|
801
|
+
scheduleIds: [SCHEDULE_X, SCHEDULE_Y, SCHEDULE_X],
|
|
802
|
+
},
|
|
803
|
+
value: body("x"),
|
|
804
|
+
ttlSeconds: 300,
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
const cached: CachedCalendarBody | null =
|
|
808
|
+
await OnCallCalendarFeedCache.getBody({
|
|
809
|
+
key: "k",
|
|
810
|
+
scope: {
|
|
811
|
+
projectId: PROJECT_A,
|
|
812
|
+
userId: USER_1,
|
|
813
|
+
scheduleIds: [SCHEDULE_Y, SCHEDULE_X],
|
|
814
|
+
},
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
expect(cached?.etag).toBe('"x"');
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
// -- Purges -------------------------------------------------------------
|
|
822
|
+
|
|
823
|
+
describe("purges", () => {
|
|
824
|
+
const personalFeedOfUser1: OnCallCalendarFeedCacheScope = {
|
|
825
|
+
projectId: PROJECT_A,
|
|
826
|
+
userId: USER_1,
|
|
827
|
+
scheduleIds: [SCHEDULE_X],
|
|
828
|
+
};
|
|
829
|
+
const personalFeedOfUser2: OnCallCalendarFeedCacheScope = {
|
|
830
|
+
projectId: PROJECT_A,
|
|
831
|
+
userId: USER_2,
|
|
832
|
+
scheduleIds: [SCHEDULE_X, SCHEDULE_Y],
|
|
833
|
+
};
|
|
834
|
+
const scheduleFeedY: OnCallCalendarFeedCacheScope = {
|
|
835
|
+
projectId: PROJECT_A,
|
|
836
|
+
scheduleIds: [SCHEDULE_Y],
|
|
837
|
+
};
|
|
838
|
+
const projectFeedA: OnCallCalendarFeedCacheScope = {
|
|
839
|
+
projectId: PROJECT_A,
|
|
840
|
+
scheduleIds: [SCHEDULE_X, SCHEDULE_Y],
|
|
841
|
+
};
|
|
842
|
+
const projectFeedB: OnCallCalendarFeedCacheScope = {
|
|
843
|
+
projectId: PROJECT_B,
|
|
844
|
+
scheduleIds: [],
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
const allFeeds: Array<{
|
|
848
|
+
name: string;
|
|
849
|
+
key: string;
|
|
850
|
+
scope: OnCallCalendarFeedCacheScope;
|
|
851
|
+
}> = [
|
|
852
|
+
{ name: "user1", key: "user1-token", scope: personalFeedOfUser1 },
|
|
853
|
+
{ name: "user2", key: "user2-token", scope: personalFeedOfUser2 },
|
|
854
|
+
{ name: "scheduleY", key: "scheduleY-token", scope: scheduleFeedY },
|
|
855
|
+
{ name: "projectA", key: "projectA-token", scope: projectFeedA },
|
|
856
|
+
{ name: "projectB", key: "projectB-token", scope: projectFeedB },
|
|
857
|
+
];
|
|
858
|
+
|
|
859
|
+
async function fillAll(): Promise<void> {
|
|
860
|
+
for (const feed of allFeeds) {
|
|
861
|
+
await OnCallCalendarFeedCache.setBody({
|
|
862
|
+
key: feed.key,
|
|
863
|
+
scope: feed.scope,
|
|
864
|
+
value: body(feed.name),
|
|
865
|
+
ttlSeconds: 300,
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function survivors(): Promise<Array<string>> {
|
|
871
|
+
const alive: Array<string> = [];
|
|
872
|
+
|
|
873
|
+
for (const feed of allFeeds) {
|
|
874
|
+
const cached: CachedCalendarBody | null =
|
|
875
|
+
await OnCallCalendarFeedCache.getBody({
|
|
876
|
+
key: feed.key,
|
|
877
|
+
scope: feed.scope,
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
if (cached) {
|
|
881
|
+
alive.push(feed.name);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
return alive;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
it("purgeForUser drops that user's feed in that project and nothing else", async () => {
|
|
889
|
+
await fillAll();
|
|
890
|
+
|
|
891
|
+
await OnCallCalendarFeedCache.purgeForUser(PROJECT_A, USER_1);
|
|
892
|
+
|
|
893
|
+
expect(await survivors()).toEqual([
|
|
894
|
+
"user2",
|
|
895
|
+
"scheduleY",
|
|
896
|
+
"projectA",
|
|
897
|
+
"projectB",
|
|
898
|
+
]);
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
it("purgeForUser is scoped to the project", async () => {
|
|
902
|
+
await fillAll();
|
|
903
|
+
|
|
904
|
+
await OnCallCalendarFeedCache.purgeForUser(PROJECT_B, USER_1);
|
|
905
|
+
|
|
906
|
+
expect(await survivors()).toEqual([
|
|
907
|
+
"user1",
|
|
908
|
+
"user2",
|
|
909
|
+
"scheduleY",
|
|
910
|
+
"projectA",
|
|
911
|
+
"projectB",
|
|
912
|
+
]);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
it("purgeForSchedule drops every feed rendered from that schedule", async () => {
|
|
916
|
+
await fillAll();
|
|
917
|
+
|
|
918
|
+
await OnCallCalendarFeedCache.purgeForSchedule(SCHEDULE_Y);
|
|
919
|
+
|
|
920
|
+
/* user1 does not depend on Y; projectB is another project. */
|
|
921
|
+
expect(await survivors()).toEqual(["user1", "projectB"]);
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it("purgeForSchedule on a schedule nobody depends on drops nothing", async () => {
|
|
925
|
+
await fillAll();
|
|
926
|
+
|
|
927
|
+
await OnCallCalendarFeedCache.purgeForSchedule(
|
|
928
|
+
"eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
|
929
|
+
);
|
|
930
|
+
|
|
931
|
+
expect(await survivors()).toHaveLength(allFeeds.length);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it("purgeForProject drops every feed in the project and no other project's", async () => {
|
|
935
|
+
await fillAll();
|
|
936
|
+
|
|
937
|
+
await OnCallCalendarFeedCache.purgeForProject(PROJECT_A);
|
|
938
|
+
|
|
939
|
+
expect(await survivors()).toEqual(["projectB"]);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it("a purged feed can be re-cached and read again", async () => {
|
|
943
|
+
await fillAll();
|
|
944
|
+
await OnCallCalendarFeedCache.purgeForUser(PROJECT_A, USER_1);
|
|
945
|
+
|
|
946
|
+
await OnCallCalendarFeedCache.setBody({
|
|
947
|
+
key: "user1-token",
|
|
948
|
+
scope: personalFeedOfUser1,
|
|
949
|
+
value: body("user1-v2"),
|
|
950
|
+
ttlSeconds: 300,
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
expect(
|
|
954
|
+
(
|
|
955
|
+
await OnCallCalendarFeedCache.getBody({
|
|
956
|
+
key: "user1-token",
|
|
957
|
+
scope: personalFeedOfUser1,
|
|
958
|
+
})
|
|
959
|
+
)?.etag,
|
|
960
|
+
).toBe('"user1-v2"');
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
it("two purges in a row do not resurrect the first generation", async () => {
|
|
964
|
+
await fillAll();
|
|
965
|
+
|
|
966
|
+
await OnCallCalendarFeedCache.purgeForUser(PROJECT_A, USER_1);
|
|
967
|
+
await OnCallCalendarFeedCache.purgeForUser(PROJECT_A, USER_1);
|
|
968
|
+
|
|
969
|
+
expect(await survivors()).not.toContain("user1");
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
it("writes the generation with a TTL, so a deleted scope does not leak a key forever", async () => {
|
|
973
|
+
await OnCallCalendarFeedCache.purgeForSchedule(SCHEDULE_X);
|
|
974
|
+
|
|
975
|
+
const keys: Array<string> = redis.keysWithPrefix("gen:schedule:");
|
|
976
|
+
|
|
977
|
+
expect(keys).toHaveLength(1);
|
|
978
|
+
expect(redis.store.get(keys[0]!)?.ttl).toBeGreaterThan(
|
|
979
|
+
LAST_GOOD_TTL_SECONDS,
|
|
980
|
+
);
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
it("purges are visible to another process reading the same Redis", async () => {
|
|
984
|
+
/*
|
|
985
|
+
* The in-process generation memo must never mask a purge another
|
|
986
|
+
* process made: simulate that by writing the generation straight into
|
|
987
|
+
* Redis, bypassing this process's memory.
|
|
988
|
+
*/
|
|
989
|
+
await fillAll();
|
|
990
|
+
|
|
991
|
+
const [genKey] = redis.keysWithPrefix("gen:");
|
|
992
|
+
expect(genKey).toBeUndefined();
|
|
993
|
+
|
|
994
|
+
await OnCallCalendarFeedCache.setBody({
|
|
995
|
+
key: "user1-token",
|
|
996
|
+
scope: personalFeedOfUser1,
|
|
997
|
+
value: body("user1"),
|
|
998
|
+
ttlSeconds: 300,
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
redis.store.set(
|
|
1002
|
+
`${ON_CALL_CALENDAR_FEED_CACHE_NAMESPACE}-gen:user:${PROJECT_A}:${USER_1}`,
|
|
1003
|
+
{ value: "other-process-bump", ttl: 100 },
|
|
1004
|
+
);
|
|
1005
|
+
|
|
1006
|
+
expect(
|
|
1007
|
+
await OnCallCalendarFeedCache.getBody({
|
|
1008
|
+
key: "user1-token",
|
|
1009
|
+
scope: personalFeedOfUser1,
|
|
1010
|
+
}),
|
|
1011
|
+
).toBeNull();
|
|
1012
|
+
});
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
// -- Last-good ----------------------------------------------------------
|
|
1016
|
+
|
|
1017
|
+
describe("last-good", () => {
|
|
1018
|
+
it("returns null when nothing was ever stored", async () => {
|
|
1019
|
+
expect(await OnCallCalendarFeedCache.getLastGood("k")).toBeNull();
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
it("round-trips a body", async () => {
|
|
1023
|
+
expect(await OnCallCalendarFeedCache.setLastGood("k", body("good"))).toBe(
|
|
1024
|
+
true,
|
|
1025
|
+
);
|
|
1026
|
+
|
|
1027
|
+
const cached: CachedCalendarBody | null =
|
|
1028
|
+
await OnCallCalendarFeedCache.getLastGood("k");
|
|
1029
|
+
|
|
1030
|
+
expect(cached?.etag).toBe('"good"');
|
|
1031
|
+
expect(cached?.lastModified.toISOString()).toBe(
|
|
1032
|
+
"2026-08-30T10:11:12.000Z",
|
|
1033
|
+
);
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it("is kept for a day", async () => {
|
|
1037
|
+
await OnCallCalendarFeedCache.setLastGood("k", body("good"));
|
|
1038
|
+
|
|
1039
|
+
const keys: Array<string> = redis.keysWithPrefix("lastgood:");
|
|
1040
|
+
|
|
1041
|
+
expect(keys).toHaveLength(1);
|
|
1042
|
+
expect(redis.store.get(keys[0]!)?.ttl).toBe(LAST_GOOD_TTL_SECONDS);
|
|
1043
|
+
expect(LAST_GOOD_TTL_SECONDS).toBe(24 * 60 * 60);
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
it("is stored apart from the fresh body under the same key", async () => {
|
|
1047
|
+
const scope: OnCallCalendarFeedCacheScope = { projectId: PROJECT_A };
|
|
1048
|
+
|
|
1049
|
+
await OnCallCalendarFeedCache.setBody({
|
|
1050
|
+
key: "k",
|
|
1051
|
+
scope,
|
|
1052
|
+
value: body("fresh"),
|
|
1053
|
+
ttlSeconds: 300,
|
|
1054
|
+
});
|
|
1055
|
+
await OnCallCalendarFeedCache.setLastGood("k", body("good"));
|
|
1056
|
+
|
|
1057
|
+
expect(
|
|
1058
|
+
(await OnCallCalendarFeedCache.getBody({ key: "k", scope }))?.etag,
|
|
1059
|
+
).toBe('"fresh"');
|
|
1060
|
+
expect((await OnCallCalendarFeedCache.getLastGood("k"))?.etag).toBe(
|
|
1061
|
+
'"good"',
|
|
1062
|
+
);
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
/*
|
|
1066
|
+
* The stale-while-error tier exists for the moment a render FAILS, and a
|
|
1067
|
+
* hook purge is one of the things that precedes a render. Its key embeds
|
|
1068
|
+
* the feed's own key (token hash + candidate set), so nothing a purge
|
|
1069
|
+
* protects against can reach it.
|
|
1070
|
+
*/
|
|
1071
|
+
it("survives every kind of purge", async () => {
|
|
1072
|
+
await OnCallCalendarFeedCache.setLastGood("k", body("good"));
|
|
1073
|
+
|
|
1074
|
+
await OnCallCalendarFeedCache.purgeForUser(PROJECT_A, USER_1);
|
|
1075
|
+
await OnCallCalendarFeedCache.purgeForSchedule(SCHEDULE_X);
|
|
1076
|
+
await OnCallCalendarFeedCache.purgeForProject(PROJECT_A);
|
|
1077
|
+
|
|
1078
|
+
expect((await OnCallCalendarFeedCache.getLastGood("k"))?.etag).toBe(
|
|
1079
|
+
'"good"',
|
|
1080
|
+
);
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
it("refuses a body over its own, larger size cap", async () => {
|
|
1084
|
+
expect(
|
|
1085
|
+
await OnCallCalendarFeedCache.setLastGood("k", {
|
|
1086
|
+
...body("huge"),
|
|
1087
|
+
body: "x".repeat(MAX_CACHED_LAST_GOOD_BYTES + 1),
|
|
1088
|
+
}),
|
|
1089
|
+
).toBe(false);
|
|
1090
|
+
expect(await OnCallCalendarFeedCache.getLastGood("k")).toBeNull();
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
/*
|
|
1094
|
+
* Regression: the last-good tier used to share the body cache's 2 MiB
|
|
1095
|
+
* cap. A VEVENT is roughly 0.9 KB, so that cap is crossed at about 2,200
|
|
1096
|
+
* events -- less than half of MAX_EVENTS -- and the feeds that cross it
|
|
1097
|
+
* are the expensive ones, the ones most likely to trip the render cap and
|
|
1098
|
+
* need a stale body. Refusing to keep one meant those feeds answered 503
|
|
1099
|
+
* instead, which is exactly what this tier exists to prevent.
|
|
1100
|
+
*/
|
|
1101
|
+
it("keeps a body that the body cache is too small for", async () => {
|
|
1102
|
+
const big: string = "x".repeat(MAX_CACHED_BODY_BYTES + 1);
|
|
1103
|
+
|
|
1104
|
+
expect(MAX_CACHED_LAST_GOOD_BYTES).toBeGreaterThan(MAX_CACHED_BODY_BYTES);
|
|
1105
|
+
|
|
1106
|
+
expect(
|
|
1107
|
+
await OnCallCalendarFeedCache.setBody({
|
|
1108
|
+
key: "k",
|
|
1109
|
+
scope: { projectId: PROJECT_A },
|
|
1110
|
+
value: { ...body("big"), body: big },
|
|
1111
|
+
ttlSeconds: 300,
|
|
1112
|
+
}),
|
|
1113
|
+
).toBe(false);
|
|
1114
|
+
|
|
1115
|
+
expect(
|
|
1116
|
+
await OnCallCalendarFeedCache.setLastGood("k", {
|
|
1117
|
+
...body("big"),
|
|
1118
|
+
body: big,
|
|
1119
|
+
}),
|
|
1120
|
+
).toBe(true);
|
|
1121
|
+
|
|
1122
|
+
expect((await OnCallCalendarFeedCache.getLastGood("k"))?.body).toBe(big);
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
it("never puts the caller's key into the Redis key", async () => {
|
|
1126
|
+
await OnCallCalendarFeedCache.setLastGood("deadbeefcafe", body("x"));
|
|
1127
|
+
|
|
1128
|
+
for (const key of redis.keysWithPrefix("lastgood:")) {
|
|
1129
|
+
expect(key).not.toContain("deadbeefcafe");
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
// -- Redis down ---------------------------------------------------------
|
|
1135
|
+
|
|
1136
|
+
describe("when Redis is unavailable", () => {
|
|
1137
|
+
const scope: OnCallCalendarFeedCacheScope = {
|
|
1138
|
+
projectId: PROJECT_A,
|
|
1139
|
+
userId: USER_1,
|
|
1140
|
+
scheduleIds: [SCHEDULE_X],
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
const scenarios: Array<{ name: string; arrange: () => void }> = [
|
|
1144
|
+
{
|
|
1145
|
+
name: "not connected",
|
|
1146
|
+
arrange: (): void => {
|
|
1147
|
+
isConnectedMock.mockReturnValue(false);
|
|
1148
|
+
},
|
|
1149
|
+
},
|
|
1150
|
+
{
|
|
1151
|
+
name: "no client",
|
|
1152
|
+
arrange: (): void => {
|
|
1153
|
+
getClientMock.mockReturnValue(null);
|
|
1154
|
+
},
|
|
1155
|
+
},
|
|
1156
|
+
{
|
|
1157
|
+
name: "commands throwing",
|
|
1158
|
+
arrange: (): void => {
|
|
1159
|
+
redis.failReads = true;
|
|
1160
|
+
redis.failWrites = true;
|
|
1161
|
+
},
|
|
1162
|
+
},
|
|
1163
|
+
];
|
|
1164
|
+
|
|
1165
|
+
for (const scenario of scenarios) {
|
|
1166
|
+
describe(scenario.name, () => {
|
|
1167
|
+
beforeEach(() => {
|
|
1168
|
+
scenario.arrange();
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
it("still renders and caches segments in process", async () => {
|
|
1172
|
+
const renderFn: MockedFn = jest.fn(async () => {
|
|
1173
|
+
return ["seg"];
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
const read: () => Promise<unknown> = () => {
|
|
1177
|
+
return OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
1178
|
+
scheduleId: SCHEDULE_X,
|
|
1179
|
+
key: "v1",
|
|
1180
|
+
ttlSeconds: 3600,
|
|
1181
|
+
render: renderFn as unknown as () => Promise<unknown>,
|
|
1182
|
+
});
|
|
1183
|
+
};
|
|
1184
|
+
|
|
1185
|
+
expect(await read()).toEqual(["seg"]);
|
|
1186
|
+
expect(await read()).toEqual(["seg"]);
|
|
1187
|
+
expect(renderFn).toHaveBeenCalledTimes(1);
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
it("still stores and reads bodies in process", async () => {
|
|
1191
|
+
expect(
|
|
1192
|
+
await OnCallCalendarFeedCache.setBody({
|
|
1193
|
+
key: "k",
|
|
1194
|
+
scope,
|
|
1195
|
+
value: body("x"),
|
|
1196
|
+
ttlSeconds: 300,
|
|
1197
|
+
}),
|
|
1198
|
+
).toBe(true);
|
|
1199
|
+
|
|
1200
|
+
expect(
|
|
1201
|
+
(await OnCallCalendarFeedCache.getBody({ key: "k", scope }))?.etag,
|
|
1202
|
+
).toBe('"x"');
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
it("still stores and reads last-good in process", async () => {
|
|
1206
|
+
await OnCallCalendarFeedCache.setLastGood("k", body("good"));
|
|
1207
|
+
|
|
1208
|
+
expect((await OnCallCalendarFeedCache.getLastGood("k"))?.etag).toBe(
|
|
1209
|
+
'"good"',
|
|
1210
|
+
);
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
it("purges still take effect in this process", async () => {
|
|
1214
|
+
await OnCallCalendarFeedCache.setBody({
|
|
1215
|
+
key: "k",
|
|
1216
|
+
scope,
|
|
1217
|
+
value: body("x"),
|
|
1218
|
+
ttlSeconds: 300,
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
await OnCallCalendarFeedCache.purgeForUser(PROJECT_A, USER_1);
|
|
1222
|
+
|
|
1223
|
+
expect(
|
|
1224
|
+
await OnCallCalendarFeedCache.getBody({ key: "k", scope }),
|
|
1225
|
+
).toBeNull();
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
it("segment purges still take effect in this process", async () => {
|
|
1229
|
+
const renderFn: MockedFn = jest.fn(async () => {
|
|
1230
|
+
return ["seg"];
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
const read: () => Promise<unknown> = () => {
|
|
1234
|
+
return OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
1235
|
+
scheduleId: SCHEDULE_X,
|
|
1236
|
+
key: "v1",
|
|
1237
|
+
ttlSeconds: 3600,
|
|
1238
|
+
render: renderFn as unknown as () => Promise<unknown>,
|
|
1239
|
+
});
|
|
1240
|
+
};
|
|
1241
|
+
|
|
1242
|
+
await read();
|
|
1243
|
+
await OnCallCalendarFeedCache.purgeForSchedule(SCHEDULE_X);
|
|
1244
|
+
await read();
|
|
1245
|
+
|
|
1246
|
+
expect(renderFn).toHaveBeenCalledTimes(2);
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
it("never throws from any operation", async () => {
|
|
1250
|
+
await expect(
|
|
1251
|
+
OnCallCalendarFeedCache.getBody({ key: "k", scope }),
|
|
1252
|
+
).resolves.toBeNull();
|
|
1253
|
+
await expect(
|
|
1254
|
+
OnCallCalendarFeedCache.setBody({
|
|
1255
|
+
key: "k",
|
|
1256
|
+
scope,
|
|
1257
|
+
value: body("x"),
|
|
1258
|
+
ttlSeconds: 300,
|
|
1259
|
+
}),
|
|
1260
|
+
).resolves.toBe(true);
|
|
1261
|
+
await expect(
|
|
1262
|
+
OnCallCalendarFeedCache.getLastGood("k"),
|
|
1263
|
+
).resolves.toBeDefined();
|
|
1264
|
+
await expect(
|
|
1265
|
+
OnCallCalendarFeedCache.setLastGood("k", body("x")),
|
|
1266
|
+
).resolves.toBe(true);
|
|
1267
|
+
await expect(
|
|
1268
|
+
OnCallCalendarFeedCache.purgeForUser(PROJECT_A, USER_1),
|
|
1269
|
+
).resolves.toBeUndefined();
|
|
1270
|
+
await expect(
|
|
1271
|
+
OnCallCalendarFeedCache.purgeForSchedule(SCHEDULE_X),
|
|
1272
|
+
).resolves.toBeUndefined();
|
|
1273
|
+
await expect(
|
|
1274
|
+
OnCallCalendarFeedCache.purgeForProject(PROJECT_A),
|
|
1275
|
+
).resolves.toBeUndefined();
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
it("logs at debug only, never warn or error, so an outage does not flood the log", async () => {
|
|
1279
|
+
for (let i: number = 0; i < 20; i++) {
|
|
1280
|
+
await OnCallCalendarFeedCache.getBody({ key: `k${i}`, scope });
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
expect(loggerWarnMock).not.toHaveBeenCalled();
|
|
1284
|
+
expect(loggerErrorMock).not.toHaveBeenCalled();
|
|
1285
|
+
});
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/*
|
|
1290
|
+
* Regression: the in-process tiers were bounded by entry count only (500).
|
|
1291
|
+
* What they hold during an outage is whole calendar bodies and whole
|
|
1292
|
+
* schedule expansions -- megabytes each -- so 500 entries was hundreds of
|
|
1293
|
+
* megabytes of retained strings on the API tier. Nothing reads a fallback
|
|
1294
|
+
* tier once Redis is healthy again, so expired entries were never swept
|
|
1295
|
+
* either: they stayed resident until 500 newer writes pushed them out.
|
|
1296
|
+
*/
|
|
1297
|
+
it("bounds the in-process fallback by bytes, not just by entry count", async () => {
|
|
1298
|
+
isConnectedMock.mockReturnValue(false);
|
|
1299
|
+
|
|
1300
|
+
const chunk: string = "x".repeat(4 * 1024 * 1024);
|
|
1301
|
+
|
|
1302
|
+
for (let i: number = 0; i < 12; i++) {
|
|
1303
|
+
await OnCallCalendarFeedCache.setLastGood(`big-${i}`, {
|
|
1304
|
+
...body("x"),
|
|
1305
|
+
body: chunk,
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
expect(OnCallCalendarFeedCache.getInProcessBytes()).toBeLessThanOrEqual(
|
|
1310
|
+
MEMORY_MAX_BYTES,
|
|
1311
|
+
);
|
|
1312
|
+
|
|
1313
|
+
/* The newest write survived; the oldest were evicted to make room. */
|
|
1314
|
+
expect(
|
|
1315
|
+
(await OnCallCalendarFeedCache.getLastGood("big-11"))?.body.length,
|
|
1316
|
+
).toBe(chunk.length);
|
|
1317
|
+
expect(await OnCallCalendarFeedCache.getLastGood("big-0")).toBeNull();
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
it("drops an entry whose TTL has passed instead of holding it for 500 writes", async () => {
|
|
1321
|
+
isConnectedMock.mockReturnValue(false);
|
|
1322
|
+
|
|
1323
|
+
await OnCallCalendarFeedCache.setBody({
|
|
1324
|
+
key: "short",
|
|
1325
|
+
scope,
|
|
1326
|
+
value: body("short"),
|
|
1327
|
+
ttlSeconds: 1,
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
expect(OnCallCalendarFeedCache.getInProcessBytes()).toBeGreaterThan(0);
|
|
1331
|
+
|
|
1332
|
+
const realNow: () => number = Date.now;
|
|
1333
|
+
|
|
1334
|
+
try {
|
|
1335
|
+
Date.now = (): number => {
|
|
1336
|
+
return realNow() + 5000;
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
/* Any write is enough to make the store reclaim what has expired. */
|
|
1340
|
+
await OnCallCalendarFeedCache.setBody({
|
|
1341
|
+
key: "next",
|
|
1342
|
+
scope,
|
|
1343
|
+
value: body("next"),
|
|
1344
|
+
ttlSeconds: 300,
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
expect(
|
|
1348
|
+
await OnCallCalendarFeedCache.getBody({ key: "short", scope }),
|
|
1349
|
+
).toBeNull();
|
|
1350
|
+
} finally {
|
|
1351
|
+
Date.now = realNow;
|
|
1352
|
+
}
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
it("a render that throws still propagates while Redis is down", async () => {
|
|
1356
|
+
isConnectedMock.mockReturnValue(false);
|
|
1357
|
+
|
|
1358
|
+
await expect(
|
|
1359
|
+
OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
1360
|
+
scheduleId: SCHEDULE_X,
|
|
1361
|
+
key: "v1",
|
|
1362
|
+
ttlSeconds: 3600,
|
|
1363
|
+
render: async () => {
|
|
1364
|
+
throw new Error("boom");
|
|
1365
|
+
},
|
|
1366
|
+
}),
|
|
1367
|
+
).rejects.toThrow("boom");
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
it("does not serve the in-process copy while Redis is healthy", async () => {
|
|
1371
|
+
/*
|
|
1372
|
+
* Fill the in-process tier during an outage, then bring Redis back
|
|
1373
|
+
* empty: the read must miss, because another process may have purged
|
|
1374
|
+
* in the meantime and this process's copy is not authoritative.
|
|
1375
|
+
*/
|
|
1376
|
+
isConnectedMock.mockReturnValue(false);
|
|
1377
|
+
|
|
1378
|
+
await OnCallCalendarFeedCache.setBody({
|
|
1379
|
+
key: "k",
|
|
1380
|
+
scope,
|
|
1381
|
+
value: body("during-outage"),
|
|
1382
|
+
ttlSeconds: 300,
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
isConnectedMock.mockReturnValue(true);
|
|
1386
|
+
|
|
1387
|
+
expect(
|
|
1388
|
+
await OnCallCalendarFeedCache.getBody({ key: "k", scope }),
|
|
1389
|
+
).toBeNull();
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
it("a write that fails after a successful read falls back without losing the value", async () => {
|
|
1393
|
+
redis.failWrites = true;
|
|
1394
|
+
|
|
1395
|
+
await OnCallCalendarFeedCache.setBody({
|
|
1396
|
+
key: "k",
|
|
1397
|
+
scope,
|
|
1398
|
+
value: body("x"),
|
|
1399
|
+
ttlSeconds: 300,
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
/* Redis has it not; memory does; reads go to Redis first and miss. */
|
|
1403
|
+
expect(redis.keysWithPrefix("body:")).toHaveLength(0);
|
|
1404
|
+
|
|
1405
|
+
redis.failReads = true;
|
|
1406
|
+
|
|
1407
|
+
expect(
|
|
1408
|
+
(await OnCallCalendarFeedCache.getBody({ key: "k", scope }))?.etag,
|
|
1409
|
+
).toBe('"x"');
|
|
1410
|
+
});
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
// -- Render slots -------------------------------------------------------
|
|
1414
|
+
|
|
1415
|
+
describe("render slots", () => {
|
|
1416
|
+
it("defaults to four concurrent renders per process", () => {
|
|
1417
|
+
expect(DEFAULT_RENDER_CONCURRENCY).toBe(4);
|
|
1418
|
+
expect(OnCallCalendarFeedCache.getRenderConcurrency()).toBe(4);
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
it("grants slots up to the cap and refuses the next", () => {
|
|
1422
|
+
for (let i: number = 0; i < DEFAULT_RENDER_CONCURRENCY; i++) {
|
|
1423
|
+
expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(true);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(false);
|
|
1427
|
+
expect(OnCallCalendarFeedCache.getActiveRenderSlots()).toBe(
|
|
1428
|
+
DEFAULT_RENDER_CONCURRENCY,
|
|
1429
|
+
);
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
it("a release frees a slot", () => {
|
|
1433
|
+
for (let i: number = 0; i < DEFAULT_RENDER_CONCURRENCY; i++) {
|
|
1434
|
+
OnCallCalendarFeedCache.tryAcquireRenderSlot();
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
OnCallCalendarFeedCache.releaseRenderSlot();
|
|
1438
|
+
|
|
1439
|
+
expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(true);
|
|
1440
|
+
expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(false);
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
it("a refused acquire does not consume a slot", () => {
|
|
1444
|
+
for (let i: number = 0; i < DEFAULT_RENDER_CONCURRENCY + 5; i++) {
|
|
1445
|
+
OnCallCalendarFeedCache.tryAcquireRenderSlot();
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
expect(OnCallCalendarFeedCache.getActiveRenderSlots()).toBe(
|
|
1449
|
+
DEFAULT_RENDER_CONCURRENCY,
|
|
1450
|
+
);
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
it("never goes below zero on an unbalanced release", () => {
|
|
1454
|
+
OnCallCalendarFeedCache.releaseRenderSlot();
|
|
1455
|
+
OnCallCalendarFeedCache.releaseRenderSlot();
|
|
1456
|
+
|
|
1457
|
+
expect(OnCallCalendarFeedCache.getActiveRenderSlots()).toBe(0);
|
|
1458
|
+
|
|
1459
|
+
for (let i: number = 0; i < DEFAULT_RENDER_CONCURRENCY; i++) {
|
|
1460
|
+
expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(true);
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(false);
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
it("honours a configured concurrency", () => {
|
|
1467
|
+
OnCallCalendarFeedCache.configure({ renderConcurrency: 2 });
|
|
1468
|
+
|
|
1469
|
+
expect(OnCallCalendarFeedCache.getRenderConcurrency()).toBe(2);
|
|
1470
|
+
expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(true);
|
|
1471
|
+
expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(true);
|
|
1472
|
+
expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(false);
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
it("ignores a concurrency that is not a positive integer", () => {
|
|
1476
|
+
for (const bad of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
1477
|
+
OnCallCalendarFeedCache.configure({ renderConcurrency: bad });
|
|
1478
|
+
|
|
1479
|
+
expect(OnCallCalendarFeedCache.getRenderConcurrency()).toBe(
|
|
1480
|
+
DEFAULT_RENDER_CONCURRENCY,
|
|
1481
|
+
);
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
it("is not Redis-backed, so the cap holds while Redis is down", () => {
|
|
1486
|
+
isConnectedMock.mockReturnValue(false);
|
|
1487
|
+
|
|
1488
|
+
for (let i: number = 0; i < DEFAULT_RENDER_CONCURRENCY; i++) {
|
|
1489
|
+
expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(true);
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(false);
|
|
1493
|
+
expect(redis.setCalls).toBe(0);
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
/*
|
|
1497
|
+
* Regression: /my-shifts (a session route whose caller can retry and
|
|
1498
|
+
* whose mobile client falls back to its roster list) used to be able to
|
|
1499
|
+
* take every slot, leaving the public feed routes -- whose callers are
|
|
1500
|
+
* calendar clients that answer a 503 by showing a stale or empty calendar
|
|
1501
|
+
* -- with none.
|
|
1502
|
+
*/
|
|
1503
|
+
it("leaveFreeSlots keeps a caller out of the last slots", () => {
|
|
1504
|
+
expect(
|
|
1505
|
+
OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: 2 }),
|
|
1506
|
+
).toBe(true);
|
|
1507
|
+
expect(
|
|
1508
|
+
OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: 2 }),
|
|
1509
|
+
).toBe(true);
|
|
1510
|
+
expect(
|
|
1511
|
+
OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: 2 }),
|
|
1512
|
+
).toBe(false);
|
|
1513
|
+
|
|
1514
|
+
/* The reserved slots are still there for a caller that reserves none. */
|
|
1515
|
+
expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(true);
|
|
1516
|
+
expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(true);
|
|
1517
|
+
expect(OnCallCalendarFeedCache.tryAcquireRenderSlot()).toBe(false);
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
it("leaveFreeSlots can never reserve every slot", () => {
|
|
1521
|
+
OnCallCalendarFeedCache.configure({ renderConcurrency: 1 });
|
|
1522
|
+
|
|
1523
|
+
expect(
|
|
1524
|
+
OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: 9 }),
|
|
1525
|
+
).toBe(true);
|
|
1526
|
+
expect(
|
|
1527
|
+
OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: 9 }),
|
|
1528
|
+
).toBe(false);
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
it("ignores a negative or fractional reserve", () => {
|
|
1532
|
+
expect(
|
|
1533
|
+
OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: -3 }),
|
|
1534
|
+
).toBe(true);
|
|
1535
|
+
expect(
|
|
1536
|
+
OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: 1.9 }),
|
|
1537
|
+
).toBe(true);
|
|
1538
|
+
expect(
|
|
1539
|
+
OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: 1.9 }),
|
|
1540
|
+
).toBe(true);
|
|
1541
|
+
expect(
|
|
1542
|
+
OnCallCalendarFeedCache.tryAcquireRenderSlot({ leaveFreeSlots: 1.9 }),
|
|
1543
|
+
).toBe(false);
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
it("clearInProcessState releases every slot and restores the default cap", () => {
|
|
1547
|
+
OnCallCalendarFeedCache.configure({ renderConcurrency: 1 });
|
|
1548
|
+
OnCallCalendarFeedCache.tryAcquireRenderSlot();
|
|
1549
|
+
|
|
1550
|
+
OnCallCalendarFeedCache.clearInProcessState();
|
|
1551
|
+
|
|
1552
|
+
expect(OnCallCalendarFeedCache.getActiveRenderSlots()).toBe(0);
|
|
1553
|
+
expect(OnCallCalendarFeedCache.getRenderConcurrency()).toBe(
|
|
1554
|
+
DEFAULT_RENDER_CONCURRENCY,
|
|
1555
|
+
);
|
|
1556
|
+
});
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
// -- Namespace ---------------------------------------------------------
|
|
1560
|
+
|
|
1561
|
+
describe("namespace", () => {
|
|
1562
|
+
it("keeps every key under its own namespace", async () => {
|
|
1563
|
+
await OnCallCalendarFeedCache.setBody({
|
|
1564
|
+
key: "k",
|
|
1565
|
+
scope: { projectId: PROJECT_A },
|
|
1566
|
+
value: body("x"),
|
|
1567
|
+
ttlSeconds: 300,
|
|
1568
|
+
});
|
|
1569
|
+
await OnCallCalendarFeedCache.setLastGood("k", body("x"));
|
|
1570
|
+
await OnCallCalendarFeedCache.purgeForProject(PROJECT_A);
|
|
1571
|
+
await OnCallCalendarFeedCache.getOrRenderScheduleSegments({
|
|
1572
|
+
scheduleId: SCHEDULE_X,
|
|
1573
|
+
key: "v1",
|
|
1574
|
+
ttlSeconds: 60,
|
|
1575
|
+
render: async () => {
|
|
1576
|
+
return [];
|
|
1577
|
+
},
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
expect(redis.keys().length).toBeGreaterThan(0);
|
|
1581
|
+
|
|
1582
|
+
for (const key of redis.keys()) {
|
|
1583
|
+
expect(
|
|
1584
|
+
key.startsWith(`${ON_CALL_CALENDAR_FEED_CACHE_NAMESPACE}-`),
|
|
1585
|
+
).toBe(true);
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
});
|
|
1589
|
+
});
|