Пакет · logger

Изоляция контекста

Контекст уровня запроса — та фича, что с наибольшей вероятностью вызовет тонкий баг в проде: user_id из одного запроса всплывает в логах другого. Вот почему стратегия изоляции — это заменяемый объект, и как реально работает каждая реализация.

Опасность

Вы хотите задать request_id один раз и чтобы он ехал в каждой строке лога этого запроса. ContextInjectingProcessor читает снимок текущего хранилища на каждой записи. Значит вопрос: что означает «текущее»? Ошибитесь — и вы либо потеряете поле, либо — хуже — протечёте его в конкурентный запрос.

Ответ целиком зависит от модели конкурентности рантайма, которую библиотека отказывается определять. Поэтому она делегирует объекту ContextStorage, поставляемому фреймворком.

ProcessContext — обычный массив

Под FPM и CLI конкурентность происходит между процессами, а не внутри одного. Каждый FPM-запрос — свежий воркер-процесс; каждый вызов CLI — один процесс. Так что поле-массив уже идеально изолировано — ничто больше не делит память этого процесса.

php
final class ProcessContext implements ContextStorage
{
  private array $data = [];

  public function set(string $key, mixed $value): void { $this->data[$key] = $value; }
  public function all(): array { return $this->data; }
  public function clear(): void { $this->data = []; }
  // ...
}

Загвоздка — переиспользование процесса. FPM перерабатывает воркеры между запросами, а демон крутится в одном процессе вечно. Массив выживает, а вместе с ним и устаревшие поля. Именно поэтому существует clear() и почему доки настаивают на его вызове в конце каждой единицы работы.

Небезопасно под Swoole

Swoole запускает много корутин в одном процессе. Все они делили бы этот единственный массив — set() запроса A был бы виден запросу B. ProcessContext там небезопасен по построению; используйте CoroutineContext.

CoroutineContext — мешок на корутину

Swoole даёт каждой корутине свой объект контекста через Swoole\Coroutine::getContext(), который возвращает ArrayObject, уничтожаемый Swoole при завершении корутины. CoroutineContext хранит свои поля внутри этого объекта под приватным ключом, так что два конкурентных запроса читают и пишут в разные мешки.

Интересная часть — bag(): он возвращает массив по ссылке, чтобы мутации попадали в собственный объект корутины, а не в копию:

php
private function &bag(): array
{
  if ($this->inCoroutine()) {
      $ctx = \Swoole\Coroutine::getContext();
      if ($ctx !== null) {
          if (!isset($ctx[self::KEY])) {
              $ctx[self::KEY] = [];
          }
          return $ctx[self::KEY];
      }
  }
  return $this->fallback;
}

set(), forget() и all() — все идут через bag(), поэтому автоматически локальны для корутины, когда корутина активна. Когда нет — проваливаются в $fallback.

Фолбэк вне корутины

Не весь код выполняется внутри корутины. У бутстрапа сервера, onWorkerStart или обычного режима CLI корутины нет — Swoole\Coroutine::getCid() там возвращает неположительное значение:

php
private function inCoroutine(): bool
{
  return class_exists(\Swoole\Coroutine::class, false)
      && \Swoole\Coroutine::getCid() > 0;
}

В этом состоянии bag() возвращает статический массив $fallback. Логгер продолжает работать во время бутстрапа, не бросая исключений; эти поля просто не изолированы по корутинам — корутины ещё нет, чтобы от неё изолировать. Проверка class_exists(..., false) (без автозагрузки) означает, что этот же класс безопасно инстанцировать даже там, где Swoole не загружен, хотя вы выбрали бы его только когда он есть.

Почему clear() отличается по рантайму

text
FPM        — процесс умирает после каждого запроса    → clear() опционален (хук на shutdown аккуратен)
CLI разово — процесс умирает после задачи             → clear() не нужен
CLI-демон  — один процесс, много итераций             → clear() ОБЯЗАТЕЛЕН на итерацию
Swoole     — мешок корутины уничтожается Swoole        → clear() опционален (явный = чётче намерение)

Правило большого пальца: если единица исполнения переживает одну задачу — очищайте между задачами. Два хранилища делают это безопасным по-разному — одно сбрасывает массив, которым вы владеете, другое опирается на снос корутины в Swoole.

Смотрите также