| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395 |
- import { describe, expect, it } from 'vitest';
- import { syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
- import type { XraySettingsValue } from '@/hooks/useXraySetting';
- function tpl(routing: Record<string, unknown>, extra: Record<string, unknown> = {}): XraySettingsValue {
- return { routing, ...extra } as unknown as XraySettingsValue;
- }
- type ExpectedObserver = 'none' | 'observatory' | 'burstObservatory';
- function expectObserver(t: XraySettingsValue, expected: ExpectedObserver, selectors: string[] = []) {
- if (expected === 'none') {
- expect(t.observatory).toBeUndefined();
- expect(t.burstObservatory).toBeUndefined();
- return;
- }
- if (expected === 'observatory') {
- expect(t.observatory).toBeDefined();
- expect(t.burstObservatory).toBeUndefined();
- expect(new Set((t.observatory as { subjectSelector: string[] }).subjectSelector)).toEqual(new Set(selectors));
- return;
- }
- expect(t.observatory).toBeUndefined();
- expect(t.burstObservatory).toBeDefined();
- expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(new Set(selectors));
- }
- // Observatory sections have no reload API in xray-core, so creating one turns
- // a balancer save from a live (hot-applied) routing change into a full
- // restart. These tests pin the rule: only strategies that genuinely need an
- // observer may create one — which, for random/roundRobin, means a fallbackTag
- // is set (xray-core then requires the Observatory feature; see #5605).
- describe('syncObservatories', () => {
- it.each([
- {
- name: 'random without fallback',
- balancers: [{ tag: 'random', selector: ['random-out'] }],
- expected: 'none' as const,
- selectors: [],
- },
- {
- name: 'random with fallback',
- balancers: [{ tag: 'random', selector: ['random-out'], fallbackTag: 'direct' }],
- expected: 'burstObservatory' as const,
- selectors: ['random-out'],
- },
- {
- name: 'roundRobin without fallback',
- balancers: [{ tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } }],
- expected: 'none' as const,
- selectors: [],
- },
- {
- name: 'roundRobin with fallback',
- balancers: [{ tag: 'rr', selector: ['rr-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } }],
- expected: 'burstObservatory' as const,
- selectors: ['rr-out'],
- },
- {
- name: 'leastPing without fallback',
- balancers: [{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }],
- expected: 'observatory' as const,
- selectors: ['lp-out'],
- },
- {
- name: 'leastPing with fallback',
- balancers: [{ tag: 'lp', selector: ['lp-out'], fallbackTag: 'direct', strategy: { type: 'leastPing' } }],
- expected: 'observatory' as const,
- selectors: ['lp-out'],
- },
- {
- name: 'leastLoad without fallback',
- balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }],
- expected: 'burstObservatory' as const,
- selectors: ['ll-out'],
- },
- {
- name: 'leastLoad with fallback',
- balancers: [{ tag: 'll', selector: ['ll-out'], fallbackTag: 'direct', strategy: { type: 'leastLoad' } }],
- expected: 'burstObservatory' as const,
- selectors: ['ll-out'],
- },
- ])('covers standalone strategy: $name', ({ balancers, expected, selectors }) => {
- const t = tpl({ balancers });
- syncObservatories(t);
- expectObserver(t, expected, selectors);
- });
- it.each([
- {
- name: 'random + roundRobin without fallbacks',
- balancers: [
- { tag: 'random', selector: ['random-out'] },
- { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
- ],
- expected: 'none' as const,
- selectors: [],
- },
- {
- name: 'leastPing + random without fallback',
- balancers: [
- { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
- { tag: 'random', selector: ['random-out'] },
- ],
- expected: 'observatory' as const,
- selectors: ['lp-out'],
- },
- {
- name: 'leastPing + roundRobin without fallback',
- balancers: [
- { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
- { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
- ],
- expected: 'observatory' as const,
- selectors: ['lp-out'],
- },
- {
- name: 'random fallback + random without fallback',
- balancers: [
- { tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
- { tag: 'random', selector: ['random-out'] },
- ],
- expected: 'burstObservatory' as const,
- selectors: ['random-fallback-out'],
- },
- {
- name: 'roundRobin fallback + roundRobin without fallback',
- balancers: [
- { tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
- { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
- ],
- expected: 'burstObservatory' as const,
- selectors: ['rr-fallback-out'],
- },
- {
- name: 'leastLoad + random without fallback',
- balancers: [
- { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
- { tag: 'random', selector: ['random-out'] },
- ],
- expected: 'burstObservatory' as const,
- selectors: ['ll-out'],
- },
- {
- name: 'leastLoad + roundRobin without fallback',
- balancers: [
- { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
- { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
- ],
- expected: 'burstObservatory' as const,
- selectors: ['ll-out'],
- },
- {
- name: 'leastPing + leastLoad with disjoint selectors',
- balancers: [
- { tag: 'lp', selector: ['lp-out', 'direct'], strategy: { type: 'leastPing' } },
- { tag: 'll', selector: ['ll-out-1', 'll-out-2'], strategy: { type: 'leastLoad' } },
- ],
- expected: 'burstObservatory' as const,
- selectors: ['lp-out', 'direct', 'll-out-1', 'll-out-2'],
- },
- {
- name: 'leastPing + random fallback',
- balancers: [
- { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
- { tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
- ],
- expected: 'burstObservatory' as const,
- selectors: ['lp-out', 'random-fallback-out'],
- },
- {
- name: 'leastPing + roundRobin fallback',
- balancers: [
- { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
- { tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
- ],
- expected: 'burstObservatory' as const,
- selectors: ['lp-out', 'rr-fallback-out'],
- },
- {
- name: 'all strategies mixed',
- balancers: [
- { tag: 'random', selector: ['random-out'] },
- { tag: 'rr', selector: ['rr-out'], strategy: { type: 'roundRobin' } },
- { tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
- { tag: 'rrf', selector: ['rr-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
- { tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } },
- { tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } },
- ],
- expected: 'burstObservatory' as const,
- selectors: ['random-fallback-out', 'rr-fallback-out', 'lp-out', 'll-out'],
- },
- {
- name: 'shared selectors are de-duplicated',
- balancers: [
- { tag: 'lp', selector: ['shared', 'lp-only'], strategy: { type: 'leastPing' } },
- { tag: 'll', selector: ['shared', 'll-only'], strategy: { type: 'leastLoad' } },
- { tag: 'rf', selector: ['shared', 'rf-only'], fallbackTag: 'direct' },
- ],
- expected: 'burstObservatory' as const,
- selectors: ['shared', 'lp-only', 'll-only', 'rf-only'],
- },
- ])('covers mixed strategy matrix: $name', ({ balancers, expected, selectors }) => {
- const t = tpl({ balancers });
- syncObservatories(t);
- expectObserver(t, expected, selectors);
- });
- it('does not create burstObservatory for a fresh random balancer (stays hot-appliable)', () => {
- const t = tpl({ balancers: [{ tag: 'b1', selector: ['direct'] }] });
- syncObservatories(t);
- expect(t.burstObservatory).toBeUndefined();
- expect(t.observatory).toBeUndefined();
- });
- it('does not create burstObservatory for roundRobin without fallback', () => {
- const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }] });
- syncObservatories(t);
- expect(t.burstObservatory).toBeUndefined();
- });
- it('creates burstObservatory for a random balancer with a fallbackTag (#5605)', () => {
- const t = tpl({ balancers: [{ tag: 'OverProxy', selector: ['opera-proxy'], fallbackTag: 'warp' }] });
- syncObservatories(t);
- expect(t.burstObservatory).toBeDefined();
- expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['opera-proxy']);
- });
- it('creates burstObservatory for roundRobin with a fallbackTag', () => {
- const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], fallbackTag: 'warp', strategy: { type: 'roundRobin' } }] });
- syncObservatories(t);
- expect(t.burstObservatory).toBeDefined();
- expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
- });
- it('treats an empty-string fallbackTag as no fallback (stays hot-appliable)', () => {
- const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], fallbackTag: '' }] });
- syncObservatories(t);
- expect(t.burstObservatory).toBeUndefined();
- });
- it('removes burstObservatory when a random balancer drops its fallbackTag', () => {
- const t = tpl(
- { balancers: [{ tag: 'OverProxy', selector: ['opera-proxy'], fallbackTag: '' }] },
- { burstObservatory: { subjectSelector: ['opera-proxy'] } },
- );
- syncObservatories(t);
- expect(t.burstObservatory).toBeUndefined();
- });
- it('removes burstObservatory when a roundRobin balancer drops its fallbackTag', () => {
- const t = tpl(
- { balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }] },
- { burstObservatory: { subjectSelector: ['a'] } },
- );
- syncObservatories(t);
- expect(t.burstObservatory).toBeUndefined();
- });
- it('keeps burstObservatory while another fallback balancer still needs it without adding no-fallback selectors', () => {
- const t = tpl(
- {
- balancers: [
- { tag: 'b1', selector: ['a'] },
- { tag: 'b2', selector: ['b'], fallbackTag: 'warp', strategy: { type: 'roundRobin' } },
- ],
- },
- { burstObservatory: { subjectSelector: ['a', 'b'] } },
- );
- syncObservatories(t);
- expect(t.burstObservatory).toBeDefined();
- expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b']);
- });
- it('creates burstObservatory for leastLoad (required by the strategy)', () => {
- const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
- syncObservatories(t);
- expect(t.burstObservatory).toBeDefined();
- expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
- });
- it('creates observatory for leastPing when no burst observer is required', () => {
- const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] });
- syncObservatories(t);
- expect(t.observatory).toBeDefined();
- expect((t.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
- });
- it('uses only burstObservatory when leastPing is mixed with leastLoad', () => {
- const t = tpl(
- {
- balancers: [
- { tag: 'lp', selector: ['least-ping-out'], strategy: { type: 'leastPing' } },
- { tag: 'll', selector: ['least-load-out'], strategy: { type: 'leastLoad' } },
- ],
- },
- { observatory: { subjectSelector: ['stale-least-ping-out'] } },
- );
- syncObservatories(t);
- expect(t.observatory).toBeUndefined();
- expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
- new Set(['least-load-out', 'least-ping-out']),
- );
- });
- it('uses only burstObservatory when leastPing is mixed with fallback balancers', () => {
- const t = tpl(
- {
- balancers: [
- { tag: 'lp', selector: ['least-ping-out'], strategy: { type: 'leastPing' } },
- { tag: 'rf', selector: ['random-fallback-out'], fallbackTag: 'direct' },
- { tag: 'rr', selector: ['round-robin-fallback-out'], fallbackTag: 'direct', strategy: { type: 'roundRobin' } },
- ],
- },
- { observatory: { subjectSelector: ['stale-least-ping-out'] } },
- );
- syncObservatories(t);
- expect(t.observatory).toBeUndefined();
- expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
- new Set(['random-fallback-out', 'round-robin-fallback-out', 'least-ping-out']),
- );
- });
- it('does not keep no-fallback random selectors in an observer created by another balancer', () => {
- const t = tpl(
- { balancers: [{ tag: 'b1', selector: ['a'] }, { tag: 'b2', selector: ['b'], strategy: { type: 'leastLoad' } }] },
- { burstObservatory: { subjectSelector: ['stale'] } },
- );
- syncObservatories(t);
- expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b']);
- });
- it('removes observatories when no balancer can use them', () => {
- const t = tpl({ balancers: [] }, {
- observatory: { subjectSelector: ['a'] },
- burstObservatory: { subjectSelector: ['a'] },
- });
- syncObservatories(t);
- expect(t.observatory).toBeUndefined();
- expect(t.burstObservatory).toBeUndefined();
- });
- it('switches from stale burstObservatory back to regular observatory when only leastPing remains', () => {
- const t = tpl(
- { balancers: [{ tag: 'lp', selector: ['lp-out'], strategy: { type: 'leastPing' } }] },
- {
- observatory: { subjectSelector: ['stale-observatory-out'] },
- burstObservatory: { subjectSelector: ['stale-burst-out'] },
- },
- );
- syncObservatories(t);
- expectObserver(t, 'observatory', ['lp-out']);
- });
- it('switches from stale observatory to burstObservatory when any burst strategy remains', () => {
- const t = tpl(
- { balancers: [{ tag: 'll', selector: ['ll-out'], strategy: { type: 'leastLoad' } }] },
- {
- observatory: { subjectSelector: ['stale-observatory-out'] },
- burstObservatory: { subjectSelector: ['stale-burst-out'] },
- },
- );
- syncObservatories(t);
- expectObserver(t, 'burstObservatory', ['ll-out']);
- });
- it('creates burstObservatory with the HEAD httpMethod default for leastLoad', () => {
- const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
- syncObservatories(t);
- const burst = t.burstObservatory as { pingConfig: { httpMethod: string; sampling: number } };
- expect(burst.pingConfig.httpMethod).toBe('HEAD');
- expect(burst.pingConfig.sampling).toBe(2);
- });
- it('drops only the prefixes no remaining balancer uses (note #2)', () => {
- const t = tpl({
- balancers: [
- { tag: 'a', selector: ['prefixA', 'prefixB'], strategy: { type: 'leastLoad' } },
- { tag: 'b', selector: ['prefixC', 'prefixB'], strategy: { type: 'leastLoad' } },
- ],
- });
- syncObservatories(t);
- expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
- new Set(['prefixA', 'prefixB', 'prefixC']),
- );
- (t.routing as { balancers: unknown[] }).balancers.splice(0, 1);
- syncObservatories(t);
- expect(new Set((t.burstObservatory as { subjectSelector: string[] }).subjectSelector)).toEqual(
- new Set(['prefixC', 'prefixB']),
- );
- });
- });
|