probe-scheduler.test.ts 15 KB

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