Проверка повторных задач iOS при переводе часов

Проверка повторных задач iOS при переводе часов

Если поручить фоновому заданию «синхронизацию каждый день в 02:30 по местному времени», в обычные дни всё может работать исправно, а при переводе часов — пропускать запуски или выполнять их повторно. Сначала вынесите расчёт следующего запуска в чистую функцию и проверьте её на фиксированных датах на облачном Mac. Только после этого подключайте уведомления или фоновое планирование. Здесь проверяется контракт календарных вычислений: момент, когда операционная система фактически разбудила приложение, не считается результатом алгоритма.

Разделяйте местное время и прошедшую длительность

«Каждые двадцать четыре часа» и «каждый день в 02:30» — разные правила. Первое отсчитывает прошедшие секунды, второе следует календарю определённого часового пояса. Сутки не всегда длятся двадцать четыре часа, поэтому реализовать оба правила одним и тем же прибавлением секунд нельзя.

Для ежедневного правила сохраняйте час, минуту, идентификатор часового пояса и версию политики. Абсолютное время следующего запуска удобно хранить для запросов, но оно не заменяет исходное правило. Если пользователь изменит часовой пояс или политику, время нужно пересчитать.

Date представляет абсолютный момент времени и не содержит часового пояса. «В какое время каждый день» — это календарное правило, которое нужно интерпретировать с явно заданными Calendar и TimeZone.

Также определите, к чему привязан часовой пояс: фиксируется ли он при создании правила или меняется вслед за местоположением пользователя. Не позволяйте текущему часовому поясу облачной машины определять поведение продукта. Не ограничивайтесь и сохранением фиксированного смещения: оно не описывает будущие переходы на летнее и зимнее время.

Определите поведение для несуществующего и повторяющегося времени

При переводе часов вперёд весной время 02:30 может не существовать. Можно пропустить этот день, перенести запуск на первое допустимое время в тот же день или сдвинуть его вперёд с сохранением минутной составляющей. Эти три варианта дают разные результаты.

Политика .strict у Calendar требует точного совпадения. Если при ежедневном правиле указанного времени не существует, поиск продолжится на последующих датах. .nextTime выбирает следующее допустимое время, но нельзя считать, что исходное значение минут обязательно сохранится. Если его нужно сохранить, отдельно проверьте .nextTimePreservingSmallerComponents.

При переводе часов назад осенью время 02:30 может наступить дважды. Явно выберите нужное вхождение с помощью .first или .last. Если продукт должен выполнять задачу оба раза, необходимо предусмотреть два времени-кандидата и отдельный идентификатор события для каждого. Одного вызова поиска совпадения для такой реализации недостаточно.

Запустите приёмочный скрипт Swift с фиксированными датами

Сохраните следующий код в recurrence.swift. В примере используются григорианский календарь и явно заданный часовой пояс. Тестовые даты — это фикстуры для граничных условий, а не даты проведения измерений. Менять время на машине или запускать симулятор не требуется.

import Foundation

var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "Europe/Berlin")!

let formatter = ISO8601DateFormatter()

func instant(_ value: String) -> Date {
    guard let date = formatter.date(from: value) else {
        fatalError("Invalid fixture date")
    }
    return date
}

func next(
    after cursor: Date,
    policy: Calendar.MatchingPolicy,
    repeated: Calendar.RepeatedTimePolicy
) -> Date {
    guard let date = calendar.nextDate(
        after: cursor,
        matching: DateComponents(hour: 2, minute: 30, second: 0),
        matchingPolicy: policy,
        repeatedTimePolicy: repeated,
        direction: .forward
    ) else {
        fatalError("No matching occurrence")
    }
    precondition(date > cursor)
    return date
}

let spring = instant("2027-03-28T00:00:00+01:00")

let skipped = next(
    after: spring,
    policy: .strict,
    repeated: .first
)
precondition(skipped == instant("2027-03-29T02:30:00+01:00"))

let shifted = next(
    after: spring,
    policy: .nextTime,
    repeated: .first
)
precondition(shifted == instant("2027-03-28T03:00:00+02:00"))

let autumn = instant("2027-10-31T00:00:00+02:00")

let first = next(
    after: autumn,
    policy: .strict,
    repeated: .first
)
let last = next(
    after: autumn,
    policy: .strict,
    repeated: .last
)
precondition(first == instant("2027-10-31T02:30:00+02:00"))
precondition(last == instant("2027-10-31T02:30:00+01:00"))
precondition(last.timeIntervalSince(first) == 3600)

Запустите скрипт с выбранным набором инструментов разработки:

xcrun swift recurrence.swift

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

Скрипт проверяет только фиксированное правило для 02:30. При интеграции в проект сделайте час и минуту параметрами и добавьте тесты для обычных дат, конца месяца, конца года и високосного дня. Для каждого случая явно укажите ожидаемый абсолютный момент времени, чтобы проверка не сводилась к сравнению отформатированных строк.

Проверьте продвижение курсора и отсутствие дублей при обработке пропусков

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

Для обработки пропущенных запусков используйте фиксированное окно: начальная граница не включается, конечная включается. Сначала зафиксируйте конец окна, затем перечисляйте кандидатов. Не считывайте текущее время заново на каждой итерации. Иначе при долгой обработке окно будет постоянно расширяться, а результаты приёмочных тестов окажутся невоспроизводимыми.

Если задача выполняется только один раз в день, формируйте ключ дедупликации из «идентификатора расписания + версии правила + местной даты в часовом поясе правила». Записи о завершении должны храниться постоянно. Если бизнес-операция имеет внешние побочные эффекты, передавайте этот ключ и на уровень бизнес-логики: одной отметки об успехе в памяти недостаточно.

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

Проверяйте календарные вычисления отдельно от системного запуска

После успешной проверки чистой функции протестируйте в симуляторе редактирование правил, их отображение и чтение сохранённых данных. Возможность фонового выполнения определяется системным планировщиком, поэтому обещать запуск с точностью до секунды нельзя. Показывайте два отдельных поля — «Плановое время выполнения» и «Фактическое время выполнения», чтобы при диагностике не принять задержку за ошибку календарного расчёта.

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

Часто задаваемые вопросы

Можно ли прибавлять двадцать четыре часа для ежедневного запуска?

Только если нужен фиксированный интервал прошедшего времени. Для запуска в одно и то же местное время используйте Calendar с явным часовым поясом и сопоставлением часов, минут и секунд.

Нужно ли выполнять задачу дважды, если местное время повторяется?

Правило продукта должно выбрать первое, второе или оба вхождения. Для одного запуска в день устраняйте дубли по идентификатору расписания, версии правила и местной дате, а не только по метке UTC.

Запускайте проекты на выделенном физическом Mac

Собирайте и тестируйте проекты, а также работайте с удалённым рабочим столом на облачном Mac. Выберите конфигурацию и период оплаты под свои задачи. Фактическая доступность отображается в консоли в реальном времени.

Следующий шаг — настройте среду запуска

Сначала сравните конфигурации, затем выберите способ подключения. Используйте SSH для работы в командной строке и VNC для отладки с графическим интерфейсом.