Устойчивый таймер и восстановление ожидания
В минимальной стадии «Согласование» публикует материал сразу после брони. Теперь введём паузу между этими шагами. Это ещё не ожидание решения редактора: процесс просто ждёт заданный срок, затем продолжает. Урок нужен для одного результата — понять, как таймер становится частью истории и почему перезапуск Worker не должен начинать ожидание заново.
Полный новый файл — src/workflows/04-timer.ts. Для самостоятельной работы читатель выбирает WORKFLOW_STAGE=04-timer и новый submissionId. Автор не запускал таймеры, не останавливал Worker и не измерял время. Описанные дальнейшие состояния являются ожидаемыми.
Sleep внутри Workflow
Полный вариант Workflow:
import { proxyActivities, sleep } from '@temporalio/workflow';
import type * as activities from '../activities';
import type { Submission, Result } from '../contracts';
const { reserveSlot, publish } = proxyActivities<typeof activities>({
startToCloseTimeout: '10 seconds',
});
export async function approvePublication(input: Submission): Promise<Result> {
await reserveSlot(input);
await sleep(input.approvalWindowMs ?? 60000);
const result = await publish(input);
return { status: 'published', receipt: result.receipt };
}
Sleep импортирован из @temporalio/workflow. Он выражает устойчивое ожидание в модели Temporal. Значение approvalWindowMs приходит в аргументах заявки и сохраняется как часть входа. Если параметр отсутствует, пример использует минуту. Это удобно для обучения; реальная редакция может ждать часы или дни без принципиальной замены механизма.
Обычный setTimeout Node вне Workflow живёт в конкретном процессе. Если процесс исчез, таймер исчезает вместе с ним, пока приложение не реализует отдельное восстановление. Наш sleep относится к исполнению в Service. Поэтому срок не должен зависеть от того, открыт ли терминал Worker и сколько раз Node был перезапущен.
Для Workflow timer не требуется отдельная Activity, которая минуту ничего не делает. Такая Activity занимала бы исполнительскую работу и усложняла timeout. В модели Temporal ожидание события и внешняя операция представлены по-разному. Это помогает экономно описывать длительные процессы без множества простаивающих HTTP-обработчиков.
Что записывается и что воспроизводится
После завершения reserveSlot Workflow формирует команду таймера. История фиксирует его начало и последующее срабатывание. Когда нужен следующий шаг, Worker восстанавливает решения по записанным событиям и продолжает до publish. Он не повторно вызывает внешнюю бронь только потому, что функция визуально начинается с первой строки.
Это место часто вызывает неверный вывод: «если код исполняется заново, все HTTP-запросы повторятся». Завершённая Activity при replay возвращает записанный результат, а не выполняется как обычная локальная функция. Но незавершённый внешний эффект действительно может потребовать повторной попытки. Поэтому история и идемпотентность работают вместе, решая разные части задачи.
Результат sleep не несёт внешних данных. Он означает наступление условия времени в модели процесса. Если нужно узнать состояние удалённого материала после ожидания, делается Activity. Нельзя вместо неё читать произвольный файл Worker и считать его содержимое стабильным для всех повторных воспроизведений.
Механизм устойчивых ожиданий описан в документации Timers. Для учебного проекта основной вывод предметный: время согласования принадлежит заявке, а не текущему процессу исполнителя. Следовательно, restart Worker не должен давать редактору новую полную минуту по умолчанию.
Остановка Worker во время паузы
Будущий сценарий читателя начинается со стадии timer:
WORKFLOW_STAGE=04-timer npm run worker
npm run client -- start article-104
После появления ожидания читатель может завершить Worker и позже запустить тот же вариант. Если срок ещё не истёк, Workflow остаётся ожидающим. Если таймер уже сработал, подходящий Worker получает возможность выполнить дальнейший шаг. Завершение ожидается после обработки, а не обязательно в ту же миллисекунду, когда наступил deadline.
Недоступный Worker влияет на продвижение кода. Temporal Service может знать о наступившем событии, но ваша функция publish всё равно выполняется через Worker. Поэтому устойчивый таймер не обещает мгновенную публикацию при отсутствии исполнителя. Он сохраняет правильное условие продолжения, а не создаёт отсутствующий вычислительный процесс.
Если одновременно остановлен локальный Service, свойства зависят от сохранённой базы и восстановления самого сервера. Наш dev-server запускается с --db-filename ./temporal.db; удаление этой базы не является обычным restart Worker. В учебном регламенте сохраняются два отдельных состояния: Temporal history и данные gateway.
Время компьютера, доступность Service и задержки обработки влияют на фактическое наблюдение. Поэтому не следует обещать абсолютно точную минуту между двумя строками консоли. Для нашего процесса важно другое: публикация не должна случиться раньше разрешённого ожидания и не должна бесконечно откладываться из-за каждый раз заново созданного обычного таймера.
Дедлайн процесса и timeout операции
Записанное начало таймера позволяет расследовать ожидаемую паузу. Если публикация ещё не появилась, сначала выясните, создан ли таймер и наступил ли его срок, затем — есть ли Worker для продолжения. Не следует повторно стартовать ту же редакцию с новым ID только ради ускорения: получится другая заявка с собственной бронью и временем.
Для интерфейса полезно показывать предметный срок отдельно от анимации ожидания. Браузерный счётчик может отображать остаток, но он не управляет Workflow timer и не является источником завершения.
Approval window — предметное время ожидания. Start-to-close timeout Activity — предел одной попытки внешнего действия. Эти числа могут отличаться на несколько порядков: редактору дают сутки, HTTP-запросу — секунды. Если заменить окно согласования Activity timeout, вы получите ошибку исполнения операции вместо понятного предметного исхода.
Workflow execution timeout тоже не является удобной заменой всем бизнес-дедлайнам. При его истечении процесс может завершиться без той ветки, которая должна освобождать бронь. Лучше описать истёкшее согласование в Workflow и выполнить необходимые действия. Технические пределы нужны, но они не должны скрывать бизнес-смысл expiry.
В текущей стадии после таймера публикация происходит автоматически. Это учебная модель отложенного действия, не действительный процесс одобрения человеком. Следующий урок заменит безусловное продолжение условием, которое реагирует на Signal. Там истечение срока приведёт к expired и освобождению брони.
Заданный срок входит в аргументы, а не читается при каждом шаге из process.env. Если настройку Worker поменять во время ожидания, уже начатая заявка не должна непредсказуемо получить другой deadline. При необходимости менять срок нужен явный сигнал или другой протокол изменения, записанный в истории.
После этого этапа ожидающая заявка переживает обычную смену исполнителя без потери логики времени. Продолжим с Signal редактора и Query состояния, сохранив тот же контракт Submission и добавив предметное решение.