probe-scheduler.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. type ProbeTarget = {
  2. id: number;
  3. url: string;
  4. vendorId: number;
  5. lastProbedAt: Date | null;
  6. lastProbeOk: boolean | null;
  7. lastProbeErrorType: string | null;
  8. };
  9. type ProbeResult = {
  10. ok: boolean;
  11. method: "HEAD" | "GET";
  12. statusCode: number | null;
  13. latencyMs: number | null;
  14. errorType: string | null;
  15. errorMessage: string | null;
  16. };
  17. function makeEndpoint(id: number, overrides: Partial<ProbeTarget> = {}): ProbeTarget {
  18. return {
  19. id,
  20. url: `https://example.com/${id}`,
  21. vendorId: overrides.vendorId ?? 1,
  22. lastProbedAt: overrides.lastProbedAt ?? null,
  23. lastProbeOk: overrides.lastProbeOk ?? null,
  24. lastProbeErrorType: overrides.lastProbeErrorType ?? null,
  25. };
  26. }
  27. function makeOkResult(): ProbeResult {
  28. return {
  29. ok: true,
  30. method: "HEAD",
  31. statusCode: 200,
  32. latencyMs: 1,
  33. errorType: null,
  34. errorMessage: null,
  35. };
  36. }
  37. async function flushMicrotasks(times: number = 6): Promise<void> {
  38. for (let i = 0; i < times; i++) {
  39. await Promise.resolve();
  40. }
  41. }
  42. let acquireLeaderLockMock: ReturnType<typeof vi.fn>;
  43. let renewLeaderLockMock: ReturnType<typeof vi.fn>;
  44. let releaseLeaderLockMock: ReturnType<typeof vi.fn>;
  45. let findEnabledEndpointsMock: ReturnType<typeof vi.fn>;
  46. let probeByEndpointMock: ReturnType<typeof vi.fn>;
  47. vi.mock("@/lib/provider-endpoints/leader-lock", () => ({
  48. acquireLeaderLock: (...args: unknown[]) => acquireLeaderLockMock(...args),
  49. renewLeaderLock: (...args: unknown[]) => renewLeaderLockMock(...args),
  50. releaseLeaderLock: (...args: unknown[]) => releaseLeaderLockMock(...args),
  51. }));
  52. vi.mock("@/repository", () => ({
  53. findEnabledProviderEndpointsForProbing: (...args: unknown[]) => findEnabledEndpointsMock(...args),
  54. }));
  55. vi.mock("@/lib/provider-endpoints/probe", () => ({
  56. probeProviderEndpointAndRecordByEndpoint: (...args: unknown[]) => probeByEndpointMock(...args),
  57. }));
  58. describe("provider-endpoints: probe scheduler", () => {
  59. afterEach(async () => {
  60. vi.useRealTimers();
  61. vi.unstubAllEnvs();
  62. vi.unstubAllGlobals();
  63. });
  64. test("not leader: scheduled probing does nothing", async () => {
  65. vi.resetModules();
  66. vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "1000");
  67. vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0");
  68. acquireLeaderLockMock = vi.fn(async () => null);
  69. renewLeaderLockMock = vi.fn(async () => false);
  70. releaseLeaderLockMock = vi.fn(async () => {});
  71. findEnabledEndpointsMock = vi.fn(async () => [makeEndpoint(1)]);
  72. probeByEndpointMock = vi.fn(async () => makeOkResult());
  73. const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import(
  74. "@/lib/provider-endpoints/probe-scheduler"
  75. );
  76. startEndpointProbeScheduler();
  77. await flushMicrotasks();
  78. expect(acquireLeaderLockMock).toHaveBeenCalled();
  79. expect(findEnabledEndpointsMock).not.toHaveBeenCalled();
  80. expect(probeByEndpointMock).not.toHaveBeenCalled();
  81. stopEndpointProbeScheduler();
  82. });
  83. test("concurrency is respected and cycle does not overlap", async () => {
  84. vi.useFakeTimers();
  85. vi.resetModules();
  86. vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "1000");
  87. vi.stubEnv("ENDPOINT_PROBE_TIMEOUT_MS", "5000");
  88. vi.stubEnv("ENDPOINT_PROBE_CONCURRENCY", "2");
  89. vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0");
  90. vi.stubEnv("ENDPOINT_PROBE_LOCK_TTL_MS", "30000");
  91. acquireLeaderLockMock = vi.fn(async () => ({
  92. key: "locks:endpoint-probe-scheduler",
  93. lockId: "test",
  94. lockType: "memory" as const,
  95. }));
  96. renewLeaderLockMock = vi.fn(async () => true);
  97. releaseLeaderLockMock = vi.fn(async () => {});
  98. const endpoints = [
  99. makeEndpoint(1),
  100. makeEndpoint(2),
  101. makeEndpoint(3),
  102. makeEndpoint(4),
  103. makeEndpoint(5),
  104. ];
  105. findEnabledEndpointsMock = vi.fn(async () => endpoints);
  106. let inFlight = 0;
  107. let maxInFlight = 0;
  108. const pending: Array<(res: ProbeResult) => void> = [];
  109. probeByEndpointMock = vi.fn(async () => {
  110. inFlight += 1;
  111. maxInFlight = Math.max(maxInFlight, inFlight);
  112. return new Promise<ProbeResult>((resolve) => {
  113. pending.push((res) => {
  114. inFlight -= 1;
  115. resolve(res);
  116. });
  117. });
  118. });
  119. const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import(
  120. "@/lib/provider-endpoints/probe-scheduler"
  121. );
  122. startEndpointProbeScheduler();
  123. await flushMicrotasks();
  124. expect(findEnabledEndpointsMock).toHaveBeenCalledTimes(1);
  125. expect(probeByEndpointMock).toHaveBeenCalledTimes(2);
  126. expect(inFlight).toBe(2);
  127. expect(maxInFlight).toBe(2);
  128. vi.advanceTimersByTime(2000);
  129. await flushMicrotasks();
  130. expect(findEnabledEndpointsMock).toHaveBeenCalledTimes(1);
  131. while (probeByEndpointMock.mock.calls.length < endpoints.length || inFlight > 0) {
  132. const next = pending.shift();
  133. if (!next) {
  134. break;
  135. }
  136. next(makeOkResult());
  137. await flushMicrotasks(2);
  138. }
  139. expect(probeByEndpointMock).toHaveBeenCalledTimes(endpoints.length);
  140. expect(maxInFlight).toBe(2);
  141. stopEndpointProbeScheduler();
  142. });
  143. describe("dynamic interval calculation", () => {
  144. test("default interval is 60s - endpoints probed 60s ago should be probed", async () => {
  145. vi.useFakeTimers();
  146. vi.setSystemTime(new Date("2024-01-01T12:01:00Z"));
  147. vi.resetModules();
  148. vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000");
  149. vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0");
  150. acquireLeaderLockMock = vi.fn(async () => ({
  151. key: "locks:endpoint-probe-scheduler",
  152. lockId: "test",
  153. lockType: "memory" as const,
  154. }));
  155. renewLeaderLockMock = vi.fn(async () => true);
  156. releaseLeaderLockMock = vi.fn(async () => {});
  157. // Two endpoints from SAME vendor (multi-endpoint vendor uses base 60s interval)
  158. // Both probed 61s ago - should be due
  159. const endpoint = makeEndpoint(1, {
  160. vendorId: 1,
  161. lastProbedAt: new Date("2024-01-01T11:59:59Z"), // 61s ago
  162. });
  163. const endpoint2 = makeEndpoint(2, {
  164. vendorId: 1, // Same vendor
  165. lastProbedAt: new Date("2024-01-01T11:59:59Z"), // 61s ago
  166. });
  167. findEnabledEndpointsMock = vi.fn(async () => [endpoint, endpoint2]);
  168. probeByEndpointMock = vi.fn(async () => makeOkResult());
  169. const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import(
  170. "@/lib/provider-endpoints/probe-scheduler"
  171. );
  172. startEndpointProbeScheduler();
  173. await flushMicrotasks();
  174. // Both endpoints should be probed since they're due (61s > 60s interval)
  175. expect(probeByEndpointMock).toHaveBeenCalledTimes(2);
  176. stopEndpointProbeScheduler();
  177. });
  178. test("single-endpoint vendor uses 10min interval", async () => {
  179. vi.useFakeTimers();
  180. vi.setSystemTime(new Date("2024-01-01T12:05:00Z"));
  181. vi.resetModules();
  182. vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000");
  183. vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0");
  184. acquireLeaderLockMock = vi.fn(async () => ({
  185. key: "locks:endpoint-probe-scheduler",
  186. lockId: "test",
  187. lockType: "memory" as const,
  188. }));
  189. renewLeaderLockMock = vi.fn(async () => true);
  190. releaseLeaderLockMock = vi.fn(async () => {});
  191. // Vendor 1: single endpoint probed 5min ago (should NOT be due - 10min interval)
  192. // Vendor 2: two endpoints, one probed 30s ago (should NOT be due - 60s interval but recently probed)
  193. const singleVendorEndpoint = makeEndpoint(1, {
  194. vendorId: 1,
  195. lastProbedAt: new Date("2024-01-01T12:00:00Z"), // 5min ago
  196. });
  197. const multiVendorEndpoint1 = makeEndpoint(2, {
  198. vendorId: 2,
  199. lastProbedAt: new Date("2024-01-01T12:04:30Z"), // 30s ago - NOT due
  200. });
  201. const multiVendorEndpoint2 = makeEndpoint(3, {
  202. vendorId: 2,
  203. lastProbedAt: new Date("2024-01-01T12:00:00Z"), // 5min ago - should be due
  204. });
  205. findEnabledEndpointsMock = vi.fn(async () => [
  206. singleVendorEndpoint,
  207. multiVendorEndpoint1,
  208. multiVendorEndpoint2,
  209. ]);
  210. probeByEndpointMock = vi.fn(async () => makeOkResult());
  211. const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import(
  212. "@/lib/provider-endpoints/probe-scheduler"
  213. );
  214. startEndpointProbeScheduler();
  215. await flushMicrotasks();
  216. // Only multiVendorEndpoint2 should be probed (5min > 60s, multi-endpoint vendor)
  217. // singleVendorEndpoint not due (5min < 10min)
  218. // multiVendorEndpoint1 not due (30s < 60s)
  219. expect(probeByEndpointMock).toHaveBeenCalledTimes(1);
  220. expect(probeByEndpointMock.mock.calls[0][0].endpoint.id).toBe(3);
  221. stopEndpointProbeScheduler();
  222. });
  223. test("timeout endpoint uses 10s override interval", async () => {
  224. vi.useFakeTimers();
  225. vi.setSystemTime(new Date("2024-01-01T12:00:15Z"));
  226. vi.resetModules();
  227. vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000");
  228. vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0");
  229. acquireLeaderLockMock = vi.fn(async () => ({
  230. key: "locks:endpoint-probe-scheduler",
  231. lockId: "test",
  232. lockType: "memory" as const,
  233. }));
  234. renewLeaderLockMock = vi.fn(async () => true);
  235. releaseLeaderLockMock = vi.fn(async () => {});
  236. // Endpoint with timeout error 15s ago - should be due (10s override)
  237. const timeoutEndpoint = makeEndpoint(1, {
  238. vendorId: 1,
  239. lastProbedAt: new Date("2024-01-01T12:00:00Z"),
  240. lastProbeOk: false,
  241. lastProbeErrorType: "timeout",
  242. });
  243. // Normal endpoint from same vendor probed 15s ago - not due (60s interval)
  244. const normalEndpoint = makeEndpoint(2, {
  245. vendorId: 1,
  246. lastProbedAt: new Date("2024-01-01T12:00:00Z"),
  247. lastProbeOk: true,
  248. });
  249. findEnabledEndpointsMock = vi.fn(async () => [timeoutEndpoint, normalEndpoint]);
  250. probeByEndpointMock = vi.fn(async () => makeOkResult());
  251. const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import(
  252. "@/lib/provider-endpoints/probe-scheduler"
  253. );
  254. startEndpointProbeScheduler();
  255. await flushMicrotasks();
  256. // Only timeout endpoint should be probed
  257. expect(probeByEndpointMock).toHaveBeenCalledTimes(1);
  258. expect(probeByEndpointMock.mock.calls[0][0].endpoint.id).toBe(1);
  259. stopEndpointProbeScheduler();
  260. });
  261. test("timeout override takes priority over 10min single-vendor interval", async () => {
  262. vi.useFakeTimers();
  263. vi.setSystemTime(new Date("2024-01-01T12:00:15Z"));
  264. vi.resetModules();
  265. vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000");
  266. vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0");
  267. acquireLeaderLockMock = vi.fn(async () => ({
  268. key: "locks:endpoint-probe-scheduler",
  269. lockId: "test",
  270. lockType: "memory" as const,
  271. }));
  272. renewLeaderLockMock = vi.fn(async () => true);
  273. releaseLeaderLockMock = vi.fn(async () => {});
  274. // Single-endpoint vendor with timeout error 15s ago
  275. // Without timeout, would use 10min interval and not be due
  276. // With timeout, uses 10s override and IS due
  277. const timeoutSingleVendor = makeEndpoint(1, {
  278. vendorId: 1, // only endpoint for this vendor
  279. lastProbedAt: new Date("2024-01-01T12:00:00Z"),
  280. lastProbeOk: false,
  281. lastProbeErrorType: "timeout",
  282. });
  283. findEnabledEndpointsMock = vi.fn(async () => [timeoutSingleVendor]);
  284. probeByEndpointMock = vi.fn(async () => makeOkResult());
  285. const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import(
  286. "@/lib/provider-endpoints/probe-scheduler"
  287. );
  288. startEndpointProbeScheduler();
  289. await flushMicrotasks();
  290. // Timeout override should take priority
  291. expect(probeByEndpointMock).toHaveBeenCalledTimes(1);
  292. stopEndpointProbeScheduler();
  293. });
  294. test("recovered endpoint (lastProbeOk=true) reverts to normal interval", async () => {
  295. vi.useFakeTimers();
  296. vi.setSystemTime(new Date("2024-01-01T12:00:15Z"));
  297. vi.resetModules();
  298. vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000");
  299. vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0");
  300. acquireLeaderLockMock = vi.fn(async () => ({
  301. key: "locks:endpoint-probe-scheduler",
  302. lockId: "test",
  303. lockType: "memory" as const,
  304. }));
  305. renewLeaderLockMock = vi.fn(async () => true);
  306. releaseLeaderLockMock = vi.fn(async () => {});
  307. // Had timeout before but now recovered (lastProbeOk=true) - uses normal interval
  308. const recoveredEndpoint = makeEndpoint(1, {
  309. vendorId: 1,
  310. lastProbedAt: new Date("2024-01-01T12:00:00Z"), // 15s ago
  311. lastProbeOk: true, // recovered!
  312. lastProbeErrorType: "timeout", // had timeout before
  313. });
  314. // Multi-vendor so 60s base interval applies
  315. const otherEndpoint = makeEndpoint(2, {
  316. vendorId: 1,
  317. lastProbedAt: new Date("2024-01-01T12:00:00Z"),
  318. lastProbeOk: true,
  319. });
  320. findEnabledEndpointsMock = vi.fn(async () => [recoveredEndpoint, otherEndpoint]);
  321. probeByEndpointMock = vi.fn(async () => makeOkResult());
  322. const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import(
  323. "@/lib/provider-endpoints/probe-scheduler"
  324. );
  325. startEndpointProbeScheduler();
  326. await flushMicrotasks();
  327. // Neither should be probed - 15s < 60s and lastProbeOk=true means no timeout override
  328. expect(probeByEndpointMock).toHaveBeenCalledTimes(0);
  329. stopEndpointProbeScheduler();
  330. });
  331. test("null lastProbedAt is always due for probing", async () => {
  332. vi.useFakeTimers();
  333. vi.setSystemTime(new Date("2024-01-01T12:00:00Z"));
  334. vi.resetModules();
  335. vi.stubEnv("ENDPOINT_PROBE_INTERVAL_MS", "60000");
  336. vi.stubEnv("ENDPOINT_PROBE_CYCLE_JITTER_MS", "0");
  337. acquireLeaderLockMock = vi.fn(async () => ({
  338. key: "locks:endpoint-probe-scheduler",
  339. lockId: "test",
  340. lockType: "memory" as const,
  341. }));
  342. renewLeaderLockMock = vi.fn(async () => true);
  343. releaseLeaderLockMock = vi.fn(async () => {});
  344. // Never probed endpoint should always be due
  345. const neverProbed = makeEndpoint(1, {
  346. vendorId: 1,
  347. lastProbedAt: null,
  348. });
  349. findEnabledEndpointsMock = vi.fn(async () => [neverProbed]);
  350. probeByEndpointMock = vi.fn(async () => makeOkResult());
  351. const { startEndpointProbeScheduler, stopEndpointProbeScheduler } = await import(
  352. "@/lib/provider-endpoints/probe-scheduler"
  353. );
  354. startEndpointProbeScheduler();
  355. await flushMicrotasks();
  356. expect(probeByEndpointMock).toHaveBeenCalledTimes(1);
  357. stopEndpointProbeScheduler();
  358. });
  359. });
  360. });