Retryable Async Request в YTsaurus Flow (Go)
Расширение примера Async Request: обработчик запросов повторяет каждую неудачную попытку через таймеры. Запрос целиком сохраняется во внутреннем стейте и повторяется с фиксированной задержкой, пока внешний сервис на него не ответит.
Структура
Пайплайн состоит из трёх компьютейшенов:
injector— нативный сорс (TSwiftPassthroughOrderedSourceComputation), объявленный прямо в спеке: он читает очередь и публикует события в стримevent. Go-кода у него нет.state(stateKeeper) — transform-компьютейшен, сгруппированный поkey: на каждое событие он открывает запрос в стримеrequest, а пришедший в стримеresponseответ прибавляет к суммарной длине ответов ключа во внешнем стейте/state.processor(requestProcessor) — transform-компьютейшен, сгруппированный поrequest_id: он делает попытки обращения к внешнему сервису и публикует ответ в стримresponse.
Два компьютейшена сгруппированы по разным ключам не случайно: processor партиционируется по идентификатору запроса, поэтому запросы одного ключа повторяются независимо друг от друга. Таймерный стрим delay объявлен в спеке с allow_timer_self_dependency = %true — компьютейшен ставит таймеры сам себе.
main.go
Точка входа: создание пайплайна и регистрация обоих компьютейшенов компаньона.
type eventMessage struct {
flow.YSONMessage
Key uint64 `yson:"key"`
Data string `yson:"data"`
}
type requestMessage struct {
flow.YSONMessage
RequestID uint64 `yson:"request_id"`
Key uint64 `yson:"key"`
Request string `yson:"request"`
}
type responseMessage struct {
flow.YSONMessage
RequestID uint64 `yson:"request_id"`
Key uint64 `yson:"key"`
Length int64 `yson:"length"`
}
func main() {
pipeline := flow.NewPipeline()
pipeline.AddStreams(
flow.NewYSONStream[eventMessage](eventStreamID),
flow.NewYSONStream[requestMessage](requestStreamID),
flow.NewYSONStream[responseMessage](responseStreamID),
)
pipeline.Add(
flow.NewRowComputation("state", &stateKeeper{}),
flow.NewRowComputation("processor", &requestProcessor{}),
)
if err := pipeline.Run(); err != nil {
fmt.Fprintf(os.Stderr, "retryable_async_request: %v\n", err)
os.Exit(1)
}
}
state_keeper.go
Маршрутизация стримов и накопление результатов во внешнем стейте: ветвление по msg.StreamID разводит событие и ответ по двум обработчикам.
type totalLengthState struct {
TotalLength int64 `yson:"total_length"`
}
type stateKeeper struct{}
var _ flow.RowFunction = (*stateKeeper)(nil)
func (s *stateKeeper) OnMessage(
ctx context.Context,
rt flow.Runtime,
msg flow.ExtendedMessage,
out flow.OutputCollector,
) error {
switch msg.StreamID {
case eventStreamID:
return s.sendRequest(rt, msg, out)
case responseStreamID:
return s.bookResponse(rt, msg)
default:
return fmt.Errorf("unhandled stream %q", msg.StreamID)
}
}
func (s *stateKeeper) sendRequest(rt flow.Runtime, msg flow.ExtendedMessage, out flow.OutputCollector) error {
var event eventMessage
if err := msg.ConvertTo(&event); err != nil {
return err
}
request := flow.NewYSONMessage[requestMessage](requestStreamID)
request.RequestID = rand.Uint64()
request.Key = event.Key
request.Request = event.Data
encoded, err := flow.ConvertFrom(rt, request)
if err != nil {
return err
}
out.AddMessage(encoded)
return nil
}
func (s *stateKeeper) bookResponse(rt flow.Runtime, msg flow.ExtendedMessage) error {
var response responseMessage
if err := msg.ConvertTo(&response); err != nil {
return err
}
state, err := flow.OpenExternalState(rt, totalStateName, msg)
if err != nil {
return err
}
var total totalLengthState
if _, err := state.ConvertTo(&total); err != nil {
return err
}
total.TotalLength += response.Length
return state.ConvertFrom(&total)
}
request_processor.go
Значение стейта — обычная Go-структура с YSON-тегами. В ней лежит всё, что нужно повтору, включая счётчик неудачных попыток, поэтому исходное сообщение повтору уже не требуется:
type requestState struct {
RequestID uint64 `yson:"request_id"`
Key uint64 `yson:"key"`
Request string `yson:"request"`
FailedAttempts int64 `yson:"failed_attempts"`
}
Логика повторов: OnMessage открывает запрос и делает первую попытку, OnTimer читает запрос из стейта и повторяет её. Общий для обоих обработчиков метод attempt либо ставит таймер на следующую попытку, либо публикует ответ и очищает стейт:
type requestProcessor struct{}
var (
_ flow.RowFunction = (*requestProcessor)(nil)
_ flow.RowTimerFunction = (*requestProcessor)(nil)
)
func (p *requestProcessor) OnMessage(
ctx context.Context,
rt flow.Runtime,
msg flow.ExtendedMessage,
out flow.OutputCollector,
) error {
state, err := flow.OpenYSONState[requestState](rt, requestStateName, msg)
if err != nil {
return err
}
var input requestMessage
if err := msg.ConvertTo(&input); err != nil {
return err
}
request := requestState{
RequestID: input.RequestID,
Key: input.Key,
Request: input.Request,
}
return p.attempt(rt, state, request, out)
}
func (p *requestProcessor) OnTimer(
ctx context.Context,
rt flow.Runtime,
timer flow.Timer,
out flow.OutputCollector,
) error {
state, err := flow.OpenYSONState[requestState](rt, requestStateName, timer)
if err != nil {
return err
}
if state.Empty() {
return nil
}
return p.attempt(rt, state, *state.Value(), out)
}
func (p *requestProcessor) attempt(
rt flow.Runtime,
state *flow.YSONState[requestState],
request requestState,
out flow.OutputCollector,
) error {
if !succeeds(request) {
request.FailedAttempts++
*state.Value() = request
out.AddTimer(flow.TimerRequest{TriggerTimestamp: uint64(time.Now().Add(retryDelay).Unix())})
return nil
}
response := flow.NewYSONMessage[responseMessage](responseStreamID)
response.RequestID = request.RequestID
response.Key = request.Key
response.Length = int64(len(request.Request))
encoded, err := flow.ConvertFrom(rt, response)
if err != nil {
return err
}
out.AddMessage(encoded)
state.Clear()
return nil
}
func succeeds(request requestState) bool {
return (request.RequestID+uint64(request.FailedAttempts))%attemptsToSucceed == 0
}
Ключевые паттерны
- Повторы через таймеры:
out.AddTimer(flow.TimerRequest{TriggerTimestamp: ...})откладывает следующую попытку наretryDelay, аOnTimerеё выполняет. Это стандартный способ реализовать повторы в Flow без внешних очередей. ПустойStreamIDвflow.TimerRequestозначает единственный таймерный стрим компьютейшена. - Счётчик попыток живёт в стейте вместе с данными запроса, поэтому переживает рестарт воркера: после перезапуска повтор продолжается с того же места.
- Вход один раз преобразуется в
requestMessageчерезmsg.ConvertTo(&input), а retry-логика работает с отдельной структуройrequestState, содержащей счётчик неудачных попыток. - Один и тот же стейт открывается и по сообщению, и по таймеру:
flow.OpenYSONState[requestState](rt, requestStateName, msg)иflow.OpenYSONState[requestState](rt, requestStateName, timer)— оба входа несут ключ группировки, по которому стейт и адресуется. - Очистка стейта при успехе: после
state.Clear()сработавший позже таймер видитstate.Empty()и ничего не делает — устаревшее срабатывание безвредно. - Разделение ответственности:
stateKeeperничего не знает о повторах, вся логика инкапсулирована вrequestProcessor, поэтому стратегию повторов можно поменять, не трогая учёт результатов. - Детерминированная симуляция сбоев:
succeeds(request)стоит на месте настоящего клиента внешнего сервиса — в реальном коде здесь был бы HTTP-вызов.