balancer-observatory-sync.test.ts 16 KB

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