Урок курса

Передаём deadline до Geo

Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker

Родительский бюджет и дочерний вызов: context.WithTimeout(incomingCtx, timeout)

Когда Route Planner получает входящий запрос — по HTTP через r.Context() или по gRPC через аргумент серверного метода — этот context несёт сигнал отмены и может содержать установленный дедлайн. Локальный дедлайн HTTP-клиента не переносится в r.Context() автоматически. Если срок уже есть, его нужно сохранить; если его нет, локальный timeout задаст верхнее ограничение вызова Geo. Задача функции ниже — добавить локальное верхнее ограничение на вызов Geo, не потеряв при этом того, что уже содержит incomingCtx.


import (
    "context"
    "time"

    pb "example.com/routegeo/proto/geo/v1"
)

func getPointWithinBudget(
    incomingCtx context.Context,
    client pb.GeoClient,
    pointID string,
    timeout time.Duration,
) (*pb.Point, error) {
    callCtx, cancel := context.WithTimeout(incomingCtx, timeout)
    defer cancel()
    return client.GetPoint(callCtx, &pb.GetPointRequest{PointId: pointID})
}

context.WithTimeout(incomingCtx, timeout) создаёт дочерний context, отсчитывая timeout от момента вызова. Ключевое слово здесь — дочерний: дочерний context наследует все значения, хранящиеся в incomingCtx, и подписывается на его отмену. Если родительский дедлайн наступит раньше локального — effective deadline дочернего context совпадёт с родительским. Если локальный timeout истечёт раньше — дочерний context завершится по нему. В любом случае более ранний из двух барьеров побеждает.

defer cancel() стоит сразу после создания дочернего context — это не стилистика, а требование управления ресурсами. Вызов cancel своевременно освобождает связь дочернего context с родителем и связанные ресурсы таймера, если он создан. После раннего возврата RPC не нужно ждать истечения срока, чтобы освободить эти ресурсы. defer гарантирует вызов cancel при любом пути выхода из функции: и на nil-ошибке, и при ошибке сети, и при панике. При этом cancel не отменяет родительский context: он действует только вниз по иерархии, освобождая ресурсы дочернего.

client.GetPoint(callCtx, ...) получает именно callCtx, а не исходный incomingCtx. Это различие критично. Если передать incomingCtx напрямую, локальный timeout никак не повлияет на вызов: Geo сможет работать столько, сколько позволяет родительский дедлайн, или вообще без ограничения, если у родителя дедлайна нет. Передача callCtx означает, что Go-gRPC-транспорт прочитает дедлайн из этого context и отправит его серверу Geo в метаданных протокола — это и есть механизм переноса срока между процессами, описанный в документации gRPC.

Функция принимает уже готовый client pb.GeoClient — создание соединения и stub-объекта осталось в предыдущем уроке и здесь не повторяется. pointID задаётся снаружи; учебная fixture "depot-7" с координатами 55.75, 37.62 будет возвращена сервером Geo при успешном исходе. Функция возвращает (*pb.Point, error) без какой-либо обёртки над ошибкой: что вернул RPC, то и получает вызывающий.

Одна типичная ошибка выглядит так: разработчик понимает, что нужно ограничить время вызова, и пишет context.WithTimeout(context.Background(), timeout). Технически callCtx при этом создаётся с нужным таймером, но context.Background() — пустой корень без каких-либо значений и без связи с incomingCtx. Результат: родительский дедлайн не учитывается, значения, которые несёт входящий context (например, trace-id или авторизационные данные), не передаются, и отмена родителя не доходит до Geo. Вызов Geo продолжается даже если клиент Route Planner уже закрыл соединение и ждать ответа некому.

Две временные шкалы: как effective deadline выбирается из двух барьеров

Два числа управляют тем, когда закончится дочерний context: абсолютный дедлайн родителя и локальный timeout, отсчитываемый от момента вызова WithTimeout. Go сравнивает их как абсолютные моменты времени и берёт более ранний. Понять это лучше на конкретных числах.

Возьмём t=0 как момент получения входящего запроса. До вызова GetPoint Route Planner тратит 80 ms на собственную обработку. WithTimeout вызывается в t=80.


Сценарий A  (parent=120 ms, local=200 ms)

t=0          t=80         t=120        t=280
|____________|____________|____________|
             ^            ^
             WithTimeout  parent deadline истекает
             вызван       (= effective deadline child)
             local=200 ms → child до t=280, но parent раньше
             effective = min(120, 80+200) = 120 ms
             remaining = 120 − 80 = 40 ms


Сценарий B  (parent=500 ms, local=200 ms)

t=0          t=80         t=280        t=500
|____________|____________|____________|
             ^            ^
             WithTimeout  local timeout истекает
             вызван       (= effective deadline child)
             effective = min(500, 80+200) = 280 ms
             remaining = 280 − 80 = 200 ms
             у родителя ещё 220 ms после t=280

Сценарий Входящий deadline local timeout WithTimeout вызван Effective deadline Remaining на вызов
A: родитель раньше t=120 ms 200 ms t=80 t=120 40 ms
B: локальный раньше t=500 ms 200 ms t=80 t=280 200 ms

В сценарии A вызов Geo получает всего 40 ms. Если Geo не ответит за это время, дочерний context завершится с context.DeadlineExceeded — но не потому что timeout=200 ms истёк, а потому что родительский дедлайн наступил раньше. В сценарии B у дочернего context есть полные 200 ms: локальный таймер истечёт в t=280, а родительский дедлайн в t=500 — Geo не касается того, что у родителя ещё есть запас.

Важно зафиксировать: истечение дочернего context в B не отменяет родителя. После t=280 incomingCtx живёт дальше; Route Planner может выполнять другую работу или вернуть ответ клиенту в оставшиеся 220 ms.

Теперь — антипример, который нарушает сценарий A:


// дефект: Background вместо incomingCtx
callCtx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()

При таком коде в сценарии A дочерний context живёт до t=280: он не знает о родительском дедлайне t=120. Этот context разрешает работе Geo продолжаться ещё до 160 ms после истечения входящего бюджета. Это разрешённый интервал, а не измеренная длительность работы: Geo может завершиться раньше. Однако отмена входящего запроса сама по себе уже не остановит такой вызов, и ресурсы могут расходоваться после того, как ответ перестал быть нужен.

Правильная форма:


// корректно: incomingCtx сохраняет оба барьера
callCtx, cancel := context.WithTimeout(incomingCtx, 200*time.Millisecond)
defer cancel()

timeout — это длительность от момента создания дочернего context, а не абсолютный момент времени. Когда в документации говорится, что WithTimeout сохраняет более ранний deadline родителя, речь идёт именно об этом сравнении: Go вычисляет абсолютный момент now + timeout и сравнивает его с абсолютным дедлайном родителя, беря меньший. Никакой гарантии, что вся функция вернётся ровно в момент effective deadline, нет: кооперативная отмена означает, что Geo и Route Planner должны сами проверять ctx.Done(), иначе они продолжат работу и после истечения срока.

Geo кооперативно замечает отмену: select по ctx.Done и timer.C

Go не останавливает горутину принудительно в момент, когда context истекает. Сервер Geo обязан сам проверять ctx.Done() и прекращать работу при получении сигнала. Следующий фрагмент показывает, как это выглядит для задержанного успешного lookup: Geo имитирует работу с хранилищем через таймер и прерывается, если context завершился раньше.

Исходящий gRPC-вызов наследует deadline. 120/30/90 мс — условная учебная шкала. Контекст передаёт сигнал отмены; остановка работы требует обработки сигнала.
120/30/90 мс — условная учебная шкала. Контекст передаёт сигнал отмены; остановка работы требует обработки сигнала.

Статичная схема (PNG)


import (
    "context"
    "time"

    pb "example.com/routegeo/proto/geo/v1"
    "google.golang.org/grpc/status"
)

func delayedPoint(ctx context.Context, delay time.Duration) (*pb.Point, error) {
    timer := time.NewTimer(delay)
    defer timer.Stop()
    select {
    case <-ctx.Done():
        return nil, status.FromContextError(ctx.Err()).Err()
    case <-timer.C:
        if err := ctx.Err(); err != nil {
            return nil, status.FromContextError(err).Err()
        }
        return &pb.Point{Id: "depot-7", Latitude: 55.75, Longitude: 37.62}, nil
    }
}

time.NewTimer(delay) создаёт одноразовый таймер; defer timer.Stop() освобождает его ресурсы при выходе из функции независимо от того, по какой ветви вышли. select ждёт одновременно двух каналов: ctx.Done() — сигнал отмены или истечения дедлайна, и timer.C — сигнал завершения задержки.

Если ctx.Done() закрывается первым, функция возвращает ошибку через status.FromContextError(ctx.Err()).Err(). Это не случайный выбор: голая context.DeadlineExceeded или context.Canceled — это обычные Go-ошибки без gRPC-статуса. Если передать их клиенту напрямую, status.Code вернёт codes.Unknown, а не codes.DeadlineExceeded или codes.Canceled. status.FromContextError специально предназначен для этого преобразования: он переводит context.DeadlineExceeded в статус с кодом DeadlineExceeded, а context.Canceled — в Canceled. Это задокументированное поведение функции из пакета google.golang.org/grpc/status.

Повторная проверка ctx.Err() в ветви timer.C нужна, потому что Go не гарантирует, какая ветвь select выполнится, если обе готовы одновременно. Когда таймер и отмена context срабатывают почти одновременно, планировщик может выбрать timer.C. Без повторной проверки функция вернула бы успешный Point клиенту, чей context уже завершился. Это не исключает все возможные гонки между отменой и завершением таймера — ctx.Err() читается в конкретный момент, и отмена может прийти сразу после этой проверки. Но вероятность нежелательного поведения существенно снижается.

Этот фрагмент не заменяет валидацию входных данных и доменные ошибки из предыдущего урока. Если pointID невалиден или точка отсутствует в хранилище, ответственный код возвращает соответственно codes.InvalidArgument или codes.NotFound. delayedPoint моделирует только задержанный успешный lookup: запись в хранилище найдена, остаётся только подождать. Context error при этом сценарии — ожидаемый исход, не внутренняя ошибка сервера; превращать её в codes.Internal было бы неверно.

PointStore, который в реальной реализации читает хранилище, обязан принимать context и передавать его дальше в запросы к БД или кэшу. Если PointStore игнорирует context, чтение может продолжаться после отмены и удерживать вызывающую функцию, пока не получит результат или ошибку. Отдельная горутина для этого не обязательна: синхронный блокирующий вызов тоже задерживает возврат. Показанный delayedPoint вообще не вызывает store; его select демонстрирует только прерываемое ожидание таймера. Кооперативность требует, чтобы context передавался через все слои.

Между процессами Go-object context не передаётся: gRPC-транспорт переносит дедлайн в метаданных протокола, а сервер Geo создаёт собственный context на основе этих метаданных. Поэтому клиент Route Planner может получить DeadlineExceeded в момент, когда сервер Geo ещё не завершил свою горутину — они идут независимо. Нельзя утверждать, что оба процесса останавливаются синхронно в одну миллисекунду.

Наблюдение context без сети: fake-call и проверяемые свойства

Настоящий pb.GeoClient.GetPoint требует gRPC-соединения, сервера и generated protobuf. Для наблюдения за context можно использовать обычную функцию func(context.Context) error — fake-call. Она получает дочерний context и смотрит на его свойства внутри вызова. Ниже отдельные наблюдения для учебной операции с дочерним context; это фрагменты тела функции, а не реализация предстоящего CallGeo.

Первый фрагмент располагается внутри функции, которая уже создала callCtx от живого родителя с достаточно далёким положительным timeout. До вызова операции cancel ещё не должен сработать:


// внутри функции, после создания callCtx
observeEntry := func(ctx context.Context) error {
    if ctx.Err() != nil {
        panic("context already done at operation entry")
    }
    select {
    case <-ctx.Done():
        panic("Done closed at operation entry")
    default:
    }

    deadline, ok := ctx.Deadline()
    _ = deadline
    _ = ok // ожидаем установленный срок
    return nil
}
err := observeEntry(callCtx)
_ = err

Неблокирующий select с default проверяет канал без ожидания: если Done закрыт, первая ветвь отработает немедленно; если открыт, выполнится default. Вместе с ctx.Err()==nil это показывает, что операция получила живой context. Наличие дедлайна само по себе этого не доказывает: после отмены срок остаётся доступен через Deadline().

Чтобы наблюдать связь с отменой родителя, порядок действий нужен явный. Сначала fake выполняет обе проверки живого context. Только после них он посылает сигнал в канал входа. Наблюдающая функция получает этот сигнал и затем вызывает отмену родителя; fake ждёт закрытия дочернего Done и читает Err. Канал служит барьером: отмена не может обогнать проверку входа. Предварительный cancel дочернего context нарушает первое наблюдение даже при сохранённом дедлайне.

Для такой демонстрации выбирают далёкие будущие deadline и положительный timeout, чтобы срок намеренно не истекал во время входных проверок. Широкий watchdog ограничивает ожидание, если связь с родителем сломана. Это защита наблюдающей функции от зависания, а не оценка скорости планировщика или задержки RPC. Точный sleep не заменяет барьер: он не устанавливает, дошла ли операция до нужной точки.

Вторая пара наблюдений относится к возврату операции и освобождению дочернего context. Здесь требуется импорт errors; объявления и вызов снова находятся внутри функции:


sentinel := errors.New("operation sentinel error")
var capturedDone <-chan struct{}

operation := func(ctx context.Context) error {
    capturedDone = ctx.Done()
    return sentinel
}
err := operation(callCtx)
if err != sentinel {
    panic("original error was changed")
}

errors.New создаёт отдельную ошибку, которую можно сравнить напрямую. err != sentinel проверяет, вернулась ли именно она. Обёртка через fmt.Errorf("%w", sentinel) уже является другой ошибкой. errors.Is нашёл бы sentinel внутри такой обёртки, поэтому для требования точной идентичности здесь нужен прямой оператор сравнения. В упражнении исходная ошибка возвращается без mapping в gRPC-статус, wrapping или retry; nil сохраняется как nil.

После возврата функции, владевшей дочерним context и вызвавшей отложенный cancel, наблюдающий код внутри другой функции проверяет захваченный канал:


// после возврата функции-владельца; capturedDone получен из операции
select {
case <-capturedDone:
    // дочерний context завершён
 default:
    panic("child Done still open after owner returned")
}
if parentCtx.Err() != nil {
    panic("parent was canceled")
}

Такое наблюдение нужно и после успеха, и после ошибки. Закрытый дочерний Done показывает освобождение context на выходе; живой родитель показывает, что отмена не поднялась вверх по иерархии. Отмена до входа операции не подходит: дочерний context должен оставаться живым, пока операция начинает работу.

При нулевом или отрицательном timeout дочерний context уже завершён. С живым родителем fake увидит context.DeadlineExceeded и закрытый Done; если родитель уже отменён, дочерний context может унаследовать context.Canceled. Контракт упражнения всё равно требует вызвать fake ровно один раз для любого такого входа. Ранний return incomingCtx.Err() вместо вызова нарушает эту редакционную политику; это не требование gRPC.

Fake наблюдает свойства context и сохранность ошибки. Он не доказывает перенос deadline по реальной gRPC-сети: для этого нужен запущенный сервер. Дедлайн следует смотреть во время операции. Для локального срока можно записать моменты до и после создания context и сравнить его deadline с интервалом before + timeout … after + timeout, без требования попасть в один точный timestamp.

Задача 1

Входящий запрос несёт дедлайн t=120 мс (отсчёт от получения запроса t=0). Route Planner тратит 80 мс на собственную обработку и только затем вызывает context.WithTimeout(incomingCtx, 200 мс). Чему равен effective deadline дочернего context и сколько миллисекунд остаётся на вызов Geo?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

Входящий запрос несёт дедлайн t=500 мс (t=0 — момент получения запроса). Route Planner тратит 80 мс и в t=80 вызывает context.WithTimeout(incomingCtx, 200 мс). Нет сетевой задержки. Чему равен effective deadline дочернего context и сколько миллисекунд остаётся на вызов Geo?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

Создан дочерний context от incomingCtx с локальным timeout. incomingCtx содержит значения и поддерживает отмену. После завершения операции владелец дочернего context вызывает его cancel. Какое утверждение о наследовании и отмене верно?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 4

В Route Planner каждый входящий запрос несёт context: он может содержать дедлайн клиента, значения (trace-id, авторизация) и сигнал отмены. Когда мы обращаемся к внешнему сервису — например Geo — нужно добавить локальное верхнее ограничение на этот вызов, не потеряв того, что уже есть в родительском context.

Дочерний context, созданный через context.WithTimeout(incomingCtx, timeout), решает задачу сразу: он наследует все значения и подписывается на отмену родителя, а эффективный дедлайн равен минимуму из родительского дедлайна и момента now + timeout. После завершения вызова cancel() освобождает ресурсы дочернего context — при любом пути выхода из функции.

Типичный дефект: создать дочерний context от context.Background() вместо incomingCtx. Тогда отмена родителя не достигает вызова, значения теряются, а бюджет входящего запроса игнорируется.

Пример двух временны́х шкал (t=0 — получение запроса, WithTimeout вызван в t=80 ms):

Сценарий A: входящий дедлайн t=120 ms, локальный timeout=200 ms
  effective = min(120, 80+200) = 120 ms  →  у вызова Geo осталось 40 ms

Сценарий B: входящий дедлайн t=500 ms, локальный timeout=200 ms
  effective = min(500, 80+200) = 280 ms  →  у вызова Geo есть все 200 ms

В сценарии A родительский дедлайн завершает дочерний context, хотя локальный timeout ещё не истёк. Остановка работы и возврат из call требуют, чтобы функция реагировала на отмену context: синхронный вызов, игнорирующий её, может продолжить работу и задержать возврат CallGeo после t=120. В сценарии B локальный таймер срабатывает первым; после t=280 родительский context продолжает жить — истечение дочернего не отменяет его.

Ваша задача — реализовать функцию CallGeo. Дополните её реализацию в редакторе, сохранив сигнатуру, package main, импорты и предоставленные вспомогательные типы и функции, включая RunScenario. Отправьте весь код из редактора. Свою функцию main добавлять не нужно: JSON-адаптер запуска платформа добавит сама.

Сигнатура (менять нельзя):

func CallGeo(
    incomingCtx context.Context,
    timeout time.Duration,
    call func(context.Context) error,
) error

Контракт:

  • incomingCtx и call не равны nil; timeout может быть положительным, нулевым или отрицательным.
  • Выведите дочерний context из incomingCtx с помощью context.WithTimeout(incomingCtx, timeout).
  • Вызовите call ровно один раз с дочерним context — даже если родительский context уже завершён или timeout <= 0.
  • Для живого родителя с достаточно далёким дедлайном (если он задан) и достаточно далёкого положительного timeout дочерний context должен быть живым в момент входа в call (т. е. ctx.Err() == nil и канал ctx.Done() открыт).
  • Освободите дочерний context (cancel) при любом выходе из функции — как на успехе, так и на ошибке.
  • Верните ту же самую ошибку, которую вернул call, без оборачивания, маппинга в gRPC-статус, подавления или повторного вызова.
  • Не вызывайте ранний return incomingCtx.Err() вместо call — это нарушает контракт.
  • Не добавляйте сетевой код, protobuf, retry или вывод в stdout.

Fake внутри тестов проверяет только свойства context (наличие дедлайна, связь с родительской отменой, закрытие Done после выхода). Реальный gRPC-транспорт не используется.

Starter:

func CallGeo(incomingCtx context.Context, timeout time.Duration, call func(context.Context) error) error {
    // Реализуйте контракт передачи бюджета и освобождения дочернего context.
    return nil
}
РешениеGo

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку