Планировщик фоновых и периодических задач для приложений на OneScript: расписания (интервал, однократно, ежедневно), защита от параллельного запуска задачи поверх самой себя, повтор при ошибке, изоляция сбоев и журнал состояния каждой задачи. Время, исполнитель обработчиков и хранилище состояния — инъектируемые абстракции, поэтому расписание тестируется на управляемых часах без реальных задержек, а само выполнение не привязано к конкретному фреймворку.
Публичный интерфейс — один класс и одна фабрика расписаний. Всё остальное
лежит в src/internal и меняется без оглядки на обратную совместимость.
Планировщик = Новый Планировщик; // реализации по умолчанию
Планировщик.Зарегистрировать("gc-кэша", Расписания.ЕжедневноВ(3, 30), Джоб); // что и когда
Планировщик.Запустить();
Планировщик.Тик(); // один шаг: цикл — за приложениемБиблиотека самодостаточна: не зависит ни от ОСени, ни от какого-либо другого фреймворка и подходит любому OneScript-приложению (winow, CGI, свой демон).
opm install os-scheduler#Использовать os-scheduler
// 1. Обработчик — любой объект с методом Исполнить().
// (в реальном приложении это, например, желудь-сервис)
Обработчик = Новый СбросСтатистики; // Процедура Исполнить() Экспорт ... КонецПроцедуры
// 2. Планировщик с реализациями по умолчанию:
// хранилище в памяти + синхронный исполнитель + системные часы.
Планировщик = Новый Планировщик;
// 3. Регистрируем задачи с расписаниями.
Планировщик.Зарегистрировать("сброс-статистики", Расписания.Интервал(60), Обработчик);
Планировщик.Зарегистрировать("gc-кэша", Расписания.ЕжедневноВ(3, 30), Новый ОчисткаКэша);
// 4. Запускаем и периодически вызываем Тик() из фонового цикла приложения.
Планировщик.Запустить();
Пока Истина Цикл
Планировщик.Тик();
Приостановить(1000); // 1 с; реальный сон организует приложение, не библиотека
КонецЦикла;Планировщик намеренно не создаёт потоков и не спит внутри себя — он лишь
предоставляет шаг Тик(). Реальный цикл (как часто вызывать Тик(), в каком
потоке) организует приложение. В приложениях на ОСени это желудь-рогатка с
фоновым заданием. Такая опросная модель делает поведение полностью
детерминированным и тестируемым на инъектируемых часах.
Расписания создаются фабрикой из модуля Расписания:
| Вызов | Смысл | Пример задачи |
|---|---|---|
Расписания.Интервал(Секунд) |
каждые N секунд; первый запуск — через N секунд после регистрации | сброс статистики раз в минуту Интервал(60), ревалидация раз в час Интервал(3600) |
Расписания.Однократно(Задержка) |
один раз через Задержка секунд после регистрации (по умолчанию 0 — немедленно) |
прогрев кэша при старте Однократно(), отложенная миграция Однократно(30) |
Расписания.ЕжедневноВ(Час, Минута, Секунда) |
раз в сутки в указанное время суток (по часам планировщика) | ночной GC ЕжедневноВ(3, 30), суточный отчёт ЕжедневноВ(0) |
Интервал — это fixed-delay, а не fixed-rate. Следующий запуск отсчитывается от момента фактического старта задачи и от текущего тика, а не от идеальной «сетки» времени. Если тики приложения идут реже интервала или задача выполняется долго, пропущенные срабатывания не «накапливаются» и не выстреливают залпом — просто следующий запуск сдвигается вперёд. Для привязки к конкретному времени суток используйте
ЕжедневноВ.
Полноценные cron-выражения намеренно не реализованы. Интервал покрывает
«раз в N секунд/минут/часов/суток», а ЕжедневноВ — единственный практически
важный случай, который интервал не выражает: запуск в конкретное время суток.
Полный парсер crontab (* * * * *, списки, диапазоны, шаги) с корректной
обработкой часовых поясов и переходов на летнее/зимнее время несоразмерен объёму
библиотеки и редко нужен фоновым задачам сервера. Контракт расписания открыт:
собственное расписание — это любой объект с методом
СледующийМомент(Опора, БылЗапуск) → Дата | Неопределено; его можно передать в
Зарегистрировать вместо штатного, не меняя планировщик.
- Изоляция ошибок. Обработчик, бросивший исключение, не роняет планировщик:
исключение перехватывает исполнитель, ошибка пишется в состояние задачи
(
статус = "ошибка",ошибка), остальные задачи и сам планировщик продолжают работу. Сбой самого исполнителя тоже изолирован. - Перекрытие (overlap). Если очередной запуск наступил, а предыдущий ещё
идёт (актуально для асинхронного исполнителя):
"пропустить"(по умолчанию) — срабатывание пропускается, момент переносится на следующий слот расписания;"очередь"— ставится один отложенный запуск, он стартует сразу по завершении текущего выполнения (очередь глубины 1, без неограниченного роста).
- Повтор при ошибке.
МаксПовторовиЗадержкаПовтораСекундв настройках задачи: после ошибки задача повторяется доМаксПовторовраз с указанной паузой; при исчерпании — возврат к обычному расписанию. Бюджет повторов восстанавливается в начале каждого планового цикла, то есть каждый плановый запуск получает своиМаксПовторовпопыток (важно для регулярно падающих джобов), но «шторм» повторов внутри одного цикла невозможен. Сбой самого исполнителя изолируется и идёт через ту же логику повторов (без учёта взапусковВсего— обработчик не стартовал). Исполнитель, нарушивший контракт (вернувший не дескриптор), не подвешивает задачу — фиксируется ошибка контракта. - Грациозная остановка.
Остановить()(без отмены) прекращает запуск новых задач, ноТик()продолжает финализировать уже идущие — приложение вызываетТик(), покаАктивных()не станет 0 (дождаться текущих). Библиотека не может прервать чужой поток исполнителя, поэтому «жёсткая»Остановить(Истина)лишь помечает идущие задачи как"остановлена"и отпускает их дескрипторы; фактическое прерывание работы — на стороне исполнителя.
Новый Планировщик(Настройки = Неопределено)
Настройки — Структура или Соответствие с необязательными полями
Хранилище, Исполнитель, Часы. Незаданное берётся по умолчанию: хранилище
в памяти, синхронный исполнитель, системные часы. Все три — утиные контракты
(см. ниже), никакого класса библиотеки реализация наследовать не обязана.
Зарегистрировать(Имя, Расписание, Обработчик, Настройки = Неопределено)— регистрирует задачу; восстанавливает её состояние из хранилища (переживает рестарт) и вычисляет момент первого запуска. Дубликат имени — исключение.Имя— уникальное имя (ключ в планировщике и хранилище);Расписание— результатРасписания.Интервал/Однократно/ЕжедневноВлибо свой объект с методомСледующийМомент;Обработчик— любой объект с методомИсполнить();Настройки—Структура/Соответствие:ПолитикаПерекрытия("пропустить"по умолчанию |"очередь"),МаксПовторов(0),ЗадержкаПовтораСекунд(0).
Состояние(Имя)→Структура|Неопределено— снимок состояния задачи;Состояния()→Соответствие— имя → снимок, по всем задачам;Запустить()— разрешает диспетчеризацию;Тик()→Число— один шаг: финализирует завершившиеся выполнения и запускает задачи, у которых наступил момент; возвращает число запущенных в этом тике;Остановить(Отменять = Ложь)→Число— грациозная (по умолчанию) либо отменяющая остановка; возвращает число ещё активных выполнений;Запущен()→Булево,Активных()→Число.
Наружу выдаётся не объект задачи, а плоская Структура-копия — её можно
логировать, класть в ответ health-check и менять, не задев планировщик:
| Поле | Тип | Смысл |
|---|---|---|
имя |
Строка | имя задачи |
статус |
Строка | "ожидает", "выполняется", "успех", "ошибка", "остановлена" |
последнийЗапуск |
Дата, Неопределено | момент последнего старта |
следующийЗапуск |
Дата, Неопределено | запланированный момент (Неопределено — запусков больше не будет) |
ошибка |
Строка, Неопределено | текст ошибки последнего запуска |
выполняется |
Булево | идёт ли выполнение сейчас |
оставшихсяПовторов |
Число | остаток бюджета повторов текущего цикла |
запусковВсего, успехов, ошибок |
Число | счётчики |
Для Каждого Запись Из Планировщик.Состояния() Цикл
Снимок = Запись.Значение;
Сообщить(СтрШаблон("%1: %2, запусков %3, ошибок %4",
Снимок.имя, Снимок.статус, Снимок.запусковВсего, Снимок.ошибок));
КонецЦикла;Расписания.Интервал(ИнтервалСекунд)→ расписаниеРасписания.Однократно(ЗадержкаСекунд = 0)→ расписаниеРасписания.ЕжедневноВ(Час, Минута = 0, Секунда = 0)→ расписание
Исполнитель развязывает библиотеку с конкретным способом выполнения (синхронно, через фоновые задания, через autumn-async и т.п.):
Запустить(Обработчик)→ дескриптор выполнения — запускает обработчик и возвращает дескриптор. Исполнитель обязан изолировать исключение обработчика: падение обработчика фиксируется в дескрипторе, а не летит в планировщик.
Дескриптор — любой объект с методами Завершено() → Булево,
ЗавершеноУспешно() → Булево, ТекстОшибки() → Строка | Неопределено.
Тип не проверяется: планировщик опрашивает дескриптор в каждом тике по этим
методам. Исполнитель, вернувший не дескриптор, задачу не подвешивает — задача
получает ошибку контракта и обычный слот расписания.
По умолчанию используется синхронный исполнитель: выполняет обработчик немедленно в тике и возвращает уже завершённый дескриптор (без зависимости от ОСени). Асинхронный исполнитель приложения возвращает незавершённый дескриптор и завершает его из фонового потока — планировщик подхватит это в одном из следующих тиков (опросная модель).
Хранилище персистит только состояние задач (последний/следующий запуск, статус, ошибка, счётчики) — обработчик и расписание задаются в коде при регистрации:
СохранитьСостояние(Имя, Состояние)— сохранить снимок (Структура примитивов);ПрочитатьСостояние(Имя)→Структура|Неопределено(независимая копия);Удалить(Имя);ВсеСостояния()→Соответствие(имя → копия снимка).
По умолчанию — хранилище в памяти (теряется при завершении процесса). Реализация поверх БД (состояние переживает рестарт) пишется приложением по этому же контракту; рекомендуется индекс по «следующийЗапуск». Повреждённый или неполный снимок регистрацию не роняет: задача стартует с чистого состояния, факт логируется.
Планировщик берёт время только из объекта-часов с единственным методом
Сейчас() → Дата. По умолчанию — системные часы
(ТекущаяУниверсальнаяДата()).
Управляемые часы — это шесть строк в тестах приложения; никакого Sleep и
никакой зависимости от системного времени, поэтому проверки расписания,
перекрытия и повторов детерминированы и не флейкают:
// ЧасыРучные.os — тестовый двойник в вашем проекте
Перем Момент_;
Процедура ПриСозданииОбъекта()
Момент_ = Дата(2026, 1, 1, 12, 0, 0);
КонецПроцедуры
Функция Сейчас() Экспорт
Возврат Момент_;
КонецФункции
Процедура Прибавить(Знач Секунд) Экспорт
Момент_ = Момент_ + Секунд;
КонецПроцедурыЧасы = ЗагрузитьСценарий("ЧасыРучные.os");
Планировщик = Новый Планировщик(Новый Структура("Часы", Часы));
Планировщик.Зарегистрировать("job", Расписания.Интервал(60), Обработчик);
Планировщик.Запустить();
Часы.Прибавить(60); // «прошла минута» — мгновенно
Планировщик.Тик(); // задача запущена ровно один раз
Ожидаем.Что(Планировщик.Состояние("job").успехов).Равно(1);Готовые двойники (управляемые часы, ручной и падающий исполнители, обработчики) лежат в tests/fixtures/ — их можно взять за образец.
Тесты — OneUnit + asserts:
oneunit execute