File size: 5,421 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
/**
 * Shared shuffle deck utility β€” Fisher-Yates shuffle with anti-repeat guarantee.
 * Used by both combo model rotation and credential connection selection.
 *
 * Thread-safe: each deck namespace gets its own promise-based mutex to prevent
 * race conditions when concurrent requests hit the same deck simultaneously.
 */

import { secureRandomInt } from "./secureRandom";

// ─── Types ──────────────────────────────────────────────────────────────────

interface ShuffleDeck {
  order: readonly string[];
  index: number;
  idsKey: string;
}

// ─── State ──────────────────────────────────────────────────────────────────

const decks = new Map<string, ShuffleDeck>();
const mutexes = new Map<string, Promise<void>>();

// ─── Fisher-Yates Shuffle ───────────────────────────────────────────────────

/**
 * Fisher-Yates shuffle β€” returns a new shuffled copy of the array.
 * Does NOT mutate the original.
 */
export function fisherYatesShuffle<T>(arr: readonly T[]): T[] {
  const result = [...arr];
  for (let i = result.length - 1; i > 0; i--) {
    const j = secureRandomInt(i + 1);
    const tmp = result[i];
    result[i] = result[j];
    result[j] = tmp;
  }
  return result;
}

// ─── Deck Operations ────────────────────────────────────────────────────────

/**
 * Get next item from a namespaced shuffle deck.
 *
 * - Namespace isolates decks (e.g. "combo:myCombo" vs "conn:openai").
 * - Uses each item exactly once per cycle before reshuffling.
 * - Guarantees the last item of a cycle is not the first of the next.
 * - Resets deck when the item set changes (detected via sorted key).
 * - Serialized per namespace via promise-based mutex (no race conditions).
 */
export async function getNextFromDeck(
  namespace: string,
  itemIds: readonly string[]
): Promise<string> {
  if (itemIds.length === 0) return "";
  if (itemIds.length === 1) return itemIds[0];

  // Acquire per-namespace mutex
  const currentMutex = mutexes.get(namespace) ?? Promise.resolve();
  let resolveMutex: (() => void) | undefined;
  mutexes.set(
    namespace,
    new Promise<void>((resolve) => {
      resolveMutex = resolve;
    })
  );

  try {
    await currentMutex;

    const idsKey = [...itemIds].sort().join(",");
    const existing = decks.get(namespace);

    // If deck exists, same item set, and not exhausted β€” advance
    if (existing && existing.idsKey === idsKey && existing.index < existing.order.length) {
      const id = existing.order[existing.index];
      decks.set(namespace, { ...existing, index: existing.index + 1 });
      return id;
    }

    // Reshuffle β€” ensure last of previous cycle is not first of new cycle
    const lastUsedId =
      existing && existing.idsKey === idsKey && existing.order.length > 0
        ? existing.order[existing.order.length - 1]
        : undefined;

    const newOrder = fisherYatesShuffle(itemIds);

    if (lastUsedId !== undefined && newOrder[0] === lastUsedId && newOrder.length > 1) {
      const swapIdx = 1 + secureRandomInt(newOrder.length - 1);
      const tmp = newOrder[0];
      newOrder[0] = newOrder[swapIdx];
      newOrder[swapIdx] = tmp;
    }

    decks.set(namespace, { order: newOrder, index: 1, idsKey });
    return newOrder[0];
  } finally {
    resolveMutex?.();
  }
}

// ─── Sync version (backwards compat for non-concurrent callers) ─────────────

/**
 * Synchronous version of getNextFromDeck β€” NO mutex protection.
 * Only safe when the caller already holds a mutex (e.g. auth.ts getProviderCredentials).
 */
export function getNextFromDeckSync(namespace: string, itemIds: readonly string[]): string {
  if (itemIds.length === 0) return "";
  if (itemIds.length === 1) return itemIds[0];

  const idsKey = [...itemIds].sort().join(",");
  const existing = decks.get(namespace);

  if (existing && existing.idsKey === idsKey && existing.index < existing.order.length) {
    const id = existing.order[existing.index];
    decks.set(namespace, { ...existing, index: existing.index + 1 });
    return id;
  }

  const lastUsedId =
    existing && existing.idsKey === idsKey && existing.order.length > 0
      ? existing.order[existing.order.length - 1]
      : undefined;

  const newOrder = fisherYatesShuffle(itemIds);

  if (lastUsedId !== undefined && newOrder[0] === lastUsedId && newOrder.length > 1) {
    const swapIdx = 1 + secureRandomInt(newOrder.length - 1);
    const tmp = newOrder[0];
    newOrder[0] = newOrder[swapIdx];
    newOrder[swapIdx] = tmp;
  }

  decks.set(namespace, { order: newOrder, index: 1, idsKey });
  return newOrder[0];
}

// ─── Test helpers ───────────────────────────────────────────────────────────

/** Reset all decks β€” for testing only. */
export function _resetAllDecks(): void {
  decks.clear();
  mutexes.clear();
}