Перейти к содержанию

SearchSession

SearchSession представляет одну поисковую операцию. Она запускает все включённые provider'ы параллельно, собирает их вывод в snapshot-словари по provider'ам, объединяет их на каждом кадре анимации и предоставляет реактивный results ref, за которым следят Vue-компоненты.

Источник: src/search/session.ts


Состояние

class SearchSession {
  readonly query: string;          // запрос для provider'а (канонический или необработанный)
  readonly rawQuery: string;       // оригинальный ввод пользователя
  readonly resolved: ResolveResult | null;

  readonly results: ShallowRef<PipelineEntity[]>;  // реактивный, обновляется при каждом flush
  readonly status: Ref<"streaming" | "done" | "cancelled">;
  readonly providerStatus: Ref<Record<string, ProviderStatus>>;
  readonly providerError: Ref<Record<string, string | null>>;
  readonly completed: Promise<void>;  // разрешается, когда все provider'ы завершают работу
}

results — это ShallowRef (не Ref) — Vue отслеживает ссылку, а не глубокие мутации объекта. Это важно, поскольку pipeline при каждом flush создаёт новый массив; глубокая реактивность на потенциально тысячах entity была бы дорогостоящей.


Выполнение provider'ов

Каждый provider запускается как отдельный async-метод _runProvider:

private async _runProvider(provider: SearchProvider): Promise<void> {
  const ctx: SearchProviderCtx = {
    signal: this._abort.signal,  // общий AbortController
    log: ...,
    requestId: this.requestId,
  };
  try {
    for await (const snapshot of provider.search(this.query, ctx)) {
      if (this._abort.signal.aborted) break;
      this._snapshots.set(provider.kind, snapshot);      // замена последнего snapshot
      this._setProviderStatus(provider.kind, "streaming");
      this._scheduleFlush();
    }
    this._setProviderStatus(provider.kind, "done");
  } catch (err) {
    this._setProviderError(provider.kind, msg);
    this._setProviderStatus(provider.kind, "error");
  }
}

Provider'ы yield'ят snapshot'ы PipelineEntity[] — полное текущее представление, а не дельту. Каждый yield заменяет предыдущий snapshot для данного provider'а. Session хранит только последний snapshot на provider в _snapshots: Map<string, PipelineEntity[]>.

Все запуски provider'ов стартуют в конструкторе, и их промисы собираются в completed:

const runs = providers.map((p) => this._runProvider(p));
this.completed = Promise.allSettled(runs).then(() => {
  if (this.status.value !== "cancelled") this.status.value = "done";
  this._flushImmediate();
});

Flushing и rAF batching

Механизм _scheduleFlush / _flushImmediate предотвращает запуск pipeline при каждом yield provider'а:

private _scheduleFlush(): void {
  if (this._rafPending) return;  // уже запланировано — объединяем
  this._rafPending = true;
  requestAnimationFrame(() => {
    this._rafPending = false;
    if (this._abort.signal.aborted) return;
    this._flushImmediate();
  });
}

private _flushImmediate(): void {
  const flat: PipelineEntity[] = [];
  for (const snap of this._snapshots.values()) flat.push(...snap);
  this.results.value = runPipeline(flat, this._pipeline);
}

_rafPending — булев флаг. Когда несколько snapshot'ов provider'ов приходят в течение одного кадра (например, SoulSeek доставляет 10 peer batch'ей за 16 мс), все вызовы _scheduleFlush, кроме первого, являются no-op'ами. Реальный pipeline запускается один раз за кадр, а не один раз за batch.

Это обеспечивает отзывчивость главного потока даже для популярных запросов, где SoulSeek генерирует сотни peer-ответов в секунду.


Snapshot map

_snapshots: Map<"rutracker" | "soulseek", PipelineEntity[]>

Каждый ключ provider'а отображается на его последний полный snapshot. Когда срабатывает _flushImmediate:

  1. Все snapshot'ы разворачиваются в единый массив
  2. Массив проходит через pipeline (normalize → dedup → score → filter)
  3. results.value обновляется результатом pipeline

Поскольку provider'ы независимо обновляют свой собственный слот snapshot'а, медленная загрузка деталей RuTracker не блокирует появление результатов SoulSeek — каждый из них отправляет обновления в свой слот по мере поступления.


AbortController

Все provider'ы используют один AbortController. Вызов session.cancel() прерывает его:

cancel(): void {
  if (this._abort.signal.aborted) return;
  this._abort.abort();
  this.status.value = "cancelled";
}

Provider'ы проверяют ctx.signal.aborted в своих внутренних циклах и досрочно выходят. Для SoulSeek слушатель Tauri-событий также снимается в блоке finally. Для RuTracker выполняющиеся вызовы getTorrentDetails проверяют signal перед каждым запросом.


Жизненный цикл ProviderStatus

"pending"   → provider ещё не запущен (устанавливается в конструкторе)
"streaming" → получен первый yield
"done"      → генератор завершился без ошибки
"error"     → генератор выбросил исключение (сообщение сохраняется в providerError)

providerStatus и providerError — обычные объекты Ref, обновляемые через spread объекта для активации реактивности Vue:

this.providerStatus.value = { ...this.providerStatus.value, [kind]: s };

requestId

Каждая SearchSession инкрементирует счётчик уровня модуля для получения уникального requestId. SoulSeek использует его для фильтрации входящих Tauri-событий soulseek-search-batch — бэкенд помечает каждый batch тем requestId, с которым он был запущен, поэтому устаревшие события из предыдущего поиска не загрязняют snapshot текущей session.

let _sessionCounter = 0;
// в конструкторе:
this.requestId = ++_sessionCounter;