balancer-observatory-sync.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. import { describe, expect, it } from 'vitest';
  2. import { syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
  3. import type { XraySettingsValue } from '@/hooks/useXraySetting';
  4. function tpl(
  5. routing: Record<string, unknown>,
  6. extra: Record<string, unknown> = {},
  7. ): XraySettingsValue {
  8. return { routing, ...extra } as unknown as XraySettingsValue;
  9. }
  10. type ExpectedObserver = 'none' | 'observatory' | 'burstObservatory';
  11. function expectObserver(
  12. t: XraySettingsValue,
  13. expected: ExpectedObserver,
  14. selectors: string[] = [],
  15. ) {
  16. if (expected === 'none') {
  17. expect(t.observatory).toBeUndefined();
  18. expect(t.burstObservatory).toBeUndefined();
  19. return;
  20. }
  21. if (expected === 'observatory') {
  22. expect(t.observatory).toBeDefined();
  23. expect(t.burstObservatory).toBeUndefined();
  24. expect(new Set((t.observatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
  25. new Set(selectors),
  26. );
  27. return;
  28. }
  29. expect(t.observatory).toBeUndefined();
  30. expect(t.burstObservatory).toBeDefined();
  31. expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
  32. new Set(selectors),
  33. );
  34. }
  35. // Observatory sections have no reload API in xray-core, so creating one turns
  36. // a balancer save from a live (hot-applied) routing change into a full
  37. // restart. These tests pin the rule: only strategies that genuinely need an
  38. // observer may create one — which, for random/roundRobin, means a fallbackTag
  39. // is set (xray-core then requires the Observatory feature; see #5605).
  40. describe('syncObservatories', () => {
  41. it.each([
  42. {
  43. name: 'random without fallback',
  44. balancers: [{ tag: 'random', selector: ['random-out'] }],
  45. expected: 'none' as const,
  46. selectors: [],
  47. },
  48. {
  49. name: 'random with fallback',
  50. balancers: [{ tag: 'random', selector: ['random-out'], fallbackTag: 'direct' }],
  51. expected: 'burstObservatory' as const,
  52. selectors: ['random-out'],
  53. },
  54. {
  55. name: 'roundRobin without fallback',
  56. balancers: [{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } }],
  57. expected: 'none' as const,
  58. selectors: [],
  59. },
  60. {
  61. name: 'roundRobin with fallback',
  62. balancers: [
  63. {
  64. tag: 'rr',
  65. selector: ['rr-out'],
  66. fallbackTag: 'direct',
  67. strategy: { type: 'roundRobin' },
  68. },
  69. ],
  70. expected: 'burstObservatory' as const,
  71. selectors: ['rr-out'],
  72. },
  73. {
  74. name: 'leastPing without fallback',
  75. balancers: [{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }],
  76. expected: 'observatory' as const,
  77. selectors: ['lp-out'],
  78. },
  79. {
  80. name: 'leastPing with fallback',
  81. balancers: [
  82. { tag: 'lp', selector: ['lp-out'], fallbackTag: 'direct', strategy: { type: 'leastPing' } },
  83. ],
  84. expected: 'observatory' as const,
  85. selectors: ['lp-out'],
  86. },
  87. {
  88. name: 'leastLoad without fallback',
  89. balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }],
  90. expected: 'burstObservatory' as const,
  91. selectors: ['ll-out'],
  92. },
  93. {
  94. name: 'leastLoad with fallback',
  95. balancers: [
  96. { tag: 'll', selector: ['ll-out'], fallbackTag: 'direct', strategy: { type: 'leastLoad' } },
  97. ],
  98. expected: 'burstObservatory' as const,
  99. selectors: ['ll-out'],
  100. },
  101. ])('covers standalone strategy: $name', ({ balancers, expected, selectors }) => {
  102. const t = tpl({ balancers });
  103. syncObservatories(t);
  104. expectObserver(t, expected, selectors);
  105. });
  106. it.each([
  107. {
  108. name: 'random + roundRobin without fallbacks',
  109. balancers: [
  110. { tag: 'random', selector: ['random-out'] },
  111. { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
  112. ],
  113. expected: 'none' as const,
  114. selectors: [],
  115. },
  116. {
  117. name: 'leastPing + random without fallback',
  118. balancers: [
  119. { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
  120. { tag: 'random', selector: ['random-out'] },
  121. ],
  122. expected: 'observatory' as const,
  123. selectors: ['lp-out'],
  124. },
  125. {
  126. name: 'leastPing + roundRobin without fallback',
  127. balancers: [
  128. { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
  129. { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
  130. ],
  131. expected: 'observatory' as const,
  132. selectors: ['lp-out'],
  133. },
  134. {
  135. name: 'random fallback + random without fallback',
  136. balancers: [
  137. { tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
  138. { tag: 'random', selector: ['random-out'] },
  139. ],
  140. expected: 'burstObservatory' as const,
  141. selectors: ['random-fallback-out'],
  142. },
  143. {
  144. name: 'roundRobin fallback + roundRobin without fallback',
  145. balancers: [
  146. {
  147. tag: 'rrf',
  148. selector: ['rr-fallback-out'],
  149. fallbackTag: 'direct',
  150. strategy: { type: 'roundRobin' },
  151. },
  152. { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
  153. ],
  154. expected: 'burstObservatory' as const,
  155. selectors: ['rr-fallback-out'],
  156. },
  157. {
  158. name: 'leastLoad + random without fallback',
  159. balancers: [
  160. { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
  161. { tag: 'random', selector: ['random-out'] },
  162. ],
  163. expected: 'burstObservatory' as const,
  164. selectors: ['ll-out'],
  165. },
  166. {
  167. name: 'leastLoad + roundRobin without fallback',
  168. balancers: [
  169. { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
  170. { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
  171. ],
  172. expected: 'burstObservatory' as const,
  173. selectors: ['ll-out'],
  174. },
  175. {
  176. name: 'leastPing + leastLoad with disjoint selectors',
  177. balancers: [
  178. { tag: 'lp', selector: ['lp-out', 'direct'], strategy: { type: 'leastPing' } },
  179. { tag: 'll', selector: ['ll-out-1', 'll-out-2'], strategy: { type: 'leastLoad' } },
  180. ],
  181. expected: 'burstObservatory' as const,
  182. selectors: ['lp-out', 'direct', 'll-out-1', 'll-out-2'],
  183. },
  184. {
  185. name: 'leastPing + random fallback',
  186. balancers: [
  187. { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
  188. { tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
  189. ],
  190. expected: 'burstObservatory' as const,
  191. selectors: ['lp-out', 'random-fallback-out'],
  192. },
  193. {
  194. name: 'leastPing + roundRobin fallback',
  195. balancers: [
  196. { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
  197. {
  198. tag: 'rrf',
  199. selector: ['rr-fallback-out'],
  200. fallbackTag: 'direct',
  201. strategy: { type: 'roundRobin' },
  202. },
  203. ],
  204. expected: 'burstObservatory' as const,
  205. selectors: ['lp-out', 'rr-fallback-out'],
  206. },
  207. {
  208. name: 'all strategies mixed',
  209. balancers: [
  210. { tag: 'random', selector: ['random-out'] },
  211. { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
  212. { tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
  213. {
  214. tag: 'rrf',
  215. selector: ['rr-fallback-out'],
  216. fallbackTag: 'direct',
  217. strategy: { type: 'roundRobin' },
  218. },
  219. { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
  220. { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
  221. ],
  222. expected: 'burstObservatory' as const,
  223. selectors: ['random-fallback-out', 'rr-fallback-out', 'lp-out', 'll-out'],
  224. },
  225. {
  226. name: 'shared selectors are de-duplicated',
  227. balancers: [
  228. { tag: 'lp', selector: ['shared', 'lp-only'], strategy: { type: 'leastPing' } },
  229. { tag: 'll', selector: ['shared', 'll-only'], strategy: { type: 'leastLoad' } },
  230. { tag: 'rf', selector: ['shared', 'rf-only'], fallbackTag: 'direct' },
  231. ],
  232. expected: 'burstObservatory' as const,
  233. selectors: ['shared', 'lp-only', 'll-only', 'rf-only'],
  234. },
  235. ])('covers mixed strategy matrix: $name', ({ balancers, expected, selectors }) => {
  236. const t = tpl({ balancers });
  237. syncObservatories(t);
  238. expectObserver(t, expected, selectors);
  239. });
  240. it('does not create burstObservatory for a fresh random balancer (stays hot-appliable)', () => {
  241. const t = tpl({ balancers: [{ tag: 'b1', selector: ['direct'] }] });
  242. syncObservatories(t);
  243. expect(t.burstObservatory).toBeUndefined();
  244. expect(t.observatory).toBeUndefined();
  245. });
  246. it('does not create burstObservatory for roundRobin without fallback', () => {
  247. const t = tpl({
  248. balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }],
  249. });
  250. syncObservatories(t);
  251. expect(t.burstObservatory).toBeUndefined();
  252. });
  253. it('creates burstObservatory for a random balancer with a fallbackTag (#5605)', () => {
  254. const t = tpl({
  255. balancers: [{ tag: 'OverProxy', selector: ['opera-proxy'], fallbackTag: 'warp' }],
  256. });
  257. syncObservatories(t);
  258. expect(t.burstObservatory).toBeDefined();
  259. expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual([
  260. 'opera-proxy',
  261. ]);
  262. });
  263. it('creates burstObservatory for roundRobin with a fallbackTag', () => {
  264. const t = tpl({
  265. balancers: [
  266. { tag: 'b1', selector: ['a'], fallbackTag: 'warp', strategy: { type: 'roundRobin' } },
  267. ],
  268. });
  269. syncObservatories(t);
  270. expect(t.burstObservatory).toBeDefined();
  271. expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
  272. });
  273. it('treats an empty-string fallbackTag as no fallback (stays hot-appliable)', () => {
  274. const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], fallbackTag: '' }] });
  275. syncObservatories(t);
  276. expect(t.burstObservatory).toBeUndefined();
  277. });
  278. it('removes burstObservatory when a random balancer drops its fallbackTag', () => {
  279. const t = tpl(
  280. { balancers: [{ tag: 'OverProxy', selector: ['opera-proxy'], fallbackTag: '' }] },
  281. { burstObservatory: { subjectSelector: ['opera-proxy'] } },
  282. );
  283. syncObservatories(t);
  284. expect(t.burstObservatory).toBeUndefined();
  285. });
  286. it('removes burstObservatory when a roundRobin balancer drops its fallbackTag', () => {
  287. const t = tpl(
  288. { balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }] },
  289. { burstObservatory: { subjectSelector: ['a'] } },
  290. );
  291. syncObservatories(t);
  292. expect(t.burstObservatory).toBeUndefined();
  293. });
  294. it('keeps burstObservatory while another fallback balancer still needs it without adding no-fallback selectors', () => {
  295. const t = tpl(
  296. {
  297. balancers: [
  298. { tag: 'b1', selector: ['a'] },
  299. { tag: 'b2', selector: ['b'], fallbackTag: 'warp', strategy: { type: 'roundRobin' } },
  300. ],
  301. },
  302. { burstObservatory: { subjectSelector: ['a', 'b'] } },
  303. );
  304. syncObservatories(t);
  305. expect(t.burstObservatory).toBeDefined();
  306. expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b']);
  307. });
  308. it('creates burstObservatory for leastLoad (required by the strategy)', () => {
  309. const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
  310. syncObservatories(t);
  311. expect(t.burstObservatory).toBeDefined();
  312. expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
  313. });
  314. it('creates observatory for leastPing when no burst observer is required', () => {
  315. const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] });
  316. syncObservatories(t);
  317. expect(t.observatory).toBeDefined();
  318. expect((t.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
  319. });
  320. it('uses only burstObservatory when leastPing is mixed with leastLoad', () => {
  321. const t = tpl(
  322. {
  323. balancers: [
  324. { tag: 'lp', selector: ['least-ping-out'], strategy: { type: 'leastPing' } },
  325. { tag: 'll', selector: ['least-load-out'], strategy: { type: 'leastLoad' } },
  326. ],
  327. },
  328. { observatory: { subjectSelector: ['stale-least-ping-out'] } },
  329. );
  330. syncObservatories(t);
  331. expect(t.observatory).toBeUndefined();
  332. expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
  333. new Set(['least-load-out', 'least-ping-out']),
  334. );
  335. });
  336. it('uses only burstObservatory when leastPing is mixed with fallback balancers', () => {
  337. const t = tpl(
  338. {
  339. balancers: [
  340. { tag: 'lp', selector: ['least-ping-out'], strategy: { type: 'leastPing' } },
  341. { tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
  342. {
  343. tag: 'rr',
  344. selector: ['round-robin-fallback-out'],
  345. fallbackTag: 'direct',
  346. strategy: { type: 'roundRobin' },
  347. },
  348. ],
  349. },
  350. { observatory: { subjectSelector: ['stale-least-ping-out'] } },
  351. );
  352. syncObservatories(t);
  353. expect(t.observatory).toBeUndefined();
  354. expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
  355. new Set(['random-fallback-out', 'round-robin-fallback-out', 'least-ping-out']),
  356. );
  357. });
  358. it('does not keep no-fallback random selectors in an observer created by another balancer', () => {
  359. const t = tpl(
  360. {
  361. balancers: [
  362. { tag: 'b1', selector: ['a'] },
  363. { tag: 'b2', selector: ['b'], strategy: { type: 'leastLoad' } },
  364. ],
  365. },
  366. { burstObservatory: { subjectSelector: ['stale'] } },
  367. );
  368. syncObservatories(t);
  369. expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b']);
  370. });
  371. it('removes observatories when no balancer can use them', () => {
  372. const t = tpl(
  373. { balancers: [] },
  374. {
  375. observatory: { subjectSelector: ['a'] },
  376. burstObservatory: { subjectSelector: ['a'] },
  377. },
  378. );
  379. syncObservatories(t);
  380. expect(t.observatory).toBeUndefined();
  381. expect(t.burstObservatory).toBeUndefined();
  382. });
  383. it('switches from stale burstObservatory back to regular observatory when only leastPing remains', () => {
  384. const t = tpl(
  385. { balancers: [{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }] },
  386. {
  387. observatory: { subjectSelector: ['stale-observatory-out'] },
  388. burstObservatory: { subjectSelector: ['stale-burst-out'] },
  389. },
  390. );
  391. syncObservatories(t);
  392. expectObserver(t, 'observatory', ['lp-out']);
  393. });
  394. it('switches from stale observatory to burstObservatory when any burst strategy remains', () => {
  395. const t = tpl(
  396. { balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }] },
  397. {
  398. observatory: { subjectSelector: ['stale-observatory-out'] },
  399. burstObservatory: { subjectSelector: ['stale-burst-out'] },
  400. },
  401. );
  402. syncObservatories(t);
  403. expectObserver(t, 'burstObservatory', ['ll-out']);
  404. });
  405. it('creates burstObservatory with the HEAD httpMethod default for leastLoad', () => {
  406. const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
  407. syncObservatories(t);
  408. const burst = t.burstObservatory as { pingConfig: { httpMethod: string; sampling: number } };
  409. expect(burst.pingConfig.httpMethod).toBe('HEAD');
  410. expect(burst.pingConfig.sampling).toBe(2);
  411. });
  412. it('drops only the prefixes no remaining balancer uses (note #2)', () => {
  413. const t = tpl({
  414. balancers: [
  415. { tag: 'a', selector: ['prefixA', 'prefixB'], strategy: { type: 'leastLoad' } },
  416. { tag: 'b', selector: ['prefixC', 'prefixB'], strategy: { type: 'leastLoad' } },
  417. ],
  418. });
  419. syncObservatories(t);
  420. expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
  421. new Set(['prefixA', 'prefixB', 'prefixC']),
  422. );
  423. (t.routing as { balancers: unknown[] }).balancers.splice(0, 1);
  424. syncObservatories(t);
  425. expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
  426. new Set(['prefixC', 'prefixB']),
  427. );
  428. });
  429. });