Конечные автоматические переходы в определенное время - PullRequest
11 голосов
/ 24 октября 2011

Упрощенный пример:

У меня есть задание. Это может быть будущее, текущее или позднее, в зависимости от того, который час.

  Time       State
  8:00 am    Future
  9:00 am    Current
  10:00 am   Late

Итак, в этом примере, задание «текущее» с 9 до 10 утра.

Первоначально я думал о добавлении полей для "current_at" и "late_at", а затем с помощью метода экземпляра для возврата состояния. Я могу запросить все "текущие" задачи с now > current and now < late.

Короче говоря, я бы вычислял состояние каждый раз или использовал бы SQL, чтобы получить набор состояний, которые мне нужны.

Если бы я хотел использовать конечный автомат, у меня был бы набор состояний, и он сохранял бы это имя состояния в списке дел. Но как бы я инициировал переход между состояниями в определенное время для каждого дела?

  • Запускать задание cron каждую минуту, чтобы вытащить что-либо в состоянии, кроме времени перехода, и обновить его.
  • Использование фоновой обработки для постановки заданий на переход в нужное время в будущем, поэтому в приведенном выше примере у меня будет два задания: «переход к текущему в 9:00» и «переход к позднему в 10:00», которые предположительно будут иметь логика для защиты от удаленных задач и «не отмечать поздно, если сделано» и тому подобное.

Кто-нибудь имеет опыт управления любой из этих опций при попытке обработки большого количества переходов состояний в определенное время?

Это похоже на конечный автомат, я просто не уверен, что лучше всего управлять всеми этими переходами.

Обновление после ответов:

  • Да, мне нужно запросить "текущие" или "будущие" задачи
  • Да, мне нужно вызывать уведомления об изменении состояния («ваш задание не было выполнено»)

Следовательно, мое желание больше походить на конечную машину, чтобы я мог инкапсулировать переходы.

Ответы [ 6 ]

6 голосов
/ 09 ноября 2011

Я разработал и обслуживал несколько систем, которые управляют огромным количеством этих маленьких конечных автоматов. (Некоторые системы, до 100K / день, некоторые 100K / минута)

Я обнаружил, что чем больше вы указали явно возиться, тем больше вероятность, что оно где-то сломается. Или, другими словами, чем больше вы говорите вывод , тем более надежное решение.

Как говорится, вы должны поддерживать некоторое состояние. Но постарайтесь, чтобы он был минимальным.

Кроме того, логика конечного автомата в одном месте делает систему более надежной и простой в обслуживании. То есть не помещайте свою логику конечного автомата в код и базу данных. Я предпочитаю свою логику в коде.

Предпочтительное решение. (Простые картинки лучше).

Для вашего примера у меня будет очень простая таблица:

task_id, current_at, current_duration, is_done, is_deleted, description...

и выводят состояние, основанное на now относительно current_at и current_duration. Это работает на удивление хорошо. Убедитесь, что вы проиндексировали / разбили таблицу на current_at.

Логика обработки при изменении перехода

Вещи разные, когда вам нужно запустить событие при изменении перехода.

Измените таблицу так, чтобы она выглядела так:

task_id, current_at, current_duration, state, locked_by, locked_until, description...

Оставьте индекс на current_at и добавьте индекс state, если хотите. Сейчас вы работаете с состоянием, поэтому все становится немного более хрупким из-за параллелизма или сбоя, поэтому нам придется немного укрепить его, используя locked_by и locked_until для оптимистической блокировки, которую я опишу ниже.

Я полагаю, что ваша программа будет иногда зависать в середине обработки - даже если только для развертывания.

Вам нужен механизм для перевода задачи из одного состояния в другое. Чтобы упростить обсуждение, я буду заниматься переходом от БУДУЩЕГО к ТЕКУЩЕМУ, но логика одна и та же, независимо от перехода.

Если ваш набор данных достаточно велик, вы постоянно опрашиваете базу данных, чтобы обнаружить задачи, требующие перехода (конечно, с линейным или экспоненциальным откатом, когда нечего делать); в противном случае вы используете или ваш любимый планировщик, будь то cron или на основе ruby ​​, или Quartz, если вы подписываетесь на Java / Scala / C #.

Выберите все записи, которые нужно переместить из БУДУЩЕГО в ТЕКУЩИЙ и в настоящее время не заблокированы.

( обновлен :)

-- move from pending to current
select task_id
  from tasks
 where now >= current_at
   and (locked_until is null OR locked_until < now)
   and state == 'PENDING'
   and current_at >= (now - 3 days)         -- optimization
 limit :LIMIT                               -- optimization

Добавьте все эти task_id в вашу надежную очередь. Или, если необходимо, просто обработайте их в своем сценарии.

Когда вы начинаете работать с элементом, вы должны сначала заблокировать его, используя нашу оптимистическую схему блокировки:

update tasks
   set locked_by = :worker_id     -- unique identifier for host + process + thread
     , locked_until = now + 5 minutes -- however this looks in your SQL langage
 where task_id = :task_id         -- you can lock multiple tasks here if necessary
   and (locked_until is null OR locked_until < now) -- only if it's not locked!

Теперь, если вы действительно обновили запись, у вас есть блокировка. Теперь вы можете использовать свою специальную логику при переходе. (Аплодисменты. Это то, что отличает вас от всех других менеджеров задач, верно?)

Когда это успешно , обновите состояние задачи, убедитесь, что вы все еще используете оптимистическую блокировку:

update tasks
   set state = :new_state
     , locked_until = null -- explicitly release the lock (an optimization, really)
 where task_id = :task_id
   and locked_by = :worker_id -- make sure we still own the lock
                              -- no-one really cares if we overstep our time-bounds

Многопоточность / оптимизация процесса

Делайте это только тогда, когда у вас есть несколько потоков или процессов, выполняющих задачи обновления в пакетном режиме (например, в задании cron или опросе базы данных)! Проблема в том, что каждый из них получит аналогичные результаты из базы данных, а затем будет бороться за блокировку каждой строки. Это неэффективно как из-за замедления работы базы данных, так и из-за того, что у вас есть потоки, которые в основном ничего не делают, кроме как замедляют другие.

Итак, добавьте ограничение на количество результатов, возвращаемых запросом, и следуйте этому алгоритму:

results = database.tasks_to_move_to_current_state :limit => BATCH_SIZE
while !results.empty
    results.shuffle! # make sure we're not in lock step with another worker
    contention_count = 0
    results.each do |task_id|
        if database.lock_task :task_id => task_id
           on_transition_to_current task_id
        else
           contention_count += 1
        end
        break if contention_count > MAX_CONTENTION_COUNT # too much contention!
    done
    results = database.tasks_to_move_to_current_state :limit => BATCH_SIZE
end

Возьмите с собой BATCH_SIZE и MAX_CONTENTION_COUNT, пока программа не станет супербыстрой.


Обновление:

Оптимистическая блокировка позволяет использовать несколько процессоров параллельно.

Имея таймаут блокировки (через поле locked_until), он допускает сбой при обработке перехода.Если процессор выходит из строя, другой процессор может выполнить задачу по истечении времени ожидания (5 минут в приведенном выше коде).В таком случае важно а) блокировать задачу только тогда, когда вы собираетесь над ней работать;и б) заблокировать задачу на то, сколько времени потребуется для выполнения задачи плюс щедрая свобода действий.

Поле locked_by в основном для целей отладки (какой процесс / машина былаэто включено?) Достаточно иметь поле locked_until, если драйвер базы данных возвращает количество обновленных строк, но только , если вы обновляете по одной строке за раз.

4 голосов
/ 08 ноября 2011

Управление всеми этими переходами в определенное время кажется сложным.Возможно, вы могли бы использовать что-то вроде DelayedJob для планирования переходов, так что работа cron каждую минуту не потребовалась бы, а восстановление после сбоя было бы более автоматизированным?

В противном случае - если это Ruby, используетсяПеречислимая опция?

Примерно так (в непроверенном псевдокоде с упрощенными методами)

Класс ToDo

def state
  if to_do.future?
    return "Future"
  elsif to_do.current?
    return "Current"
  elsif to_do.late?
    return "Late"
  else 
    return "must not have been important"
  end
end

def future?
    Time.now.hour <= 8
end

def current?
    Time.now.hour == 9
end

def late?
    Time.now.hour >= 10
end

def self.find_current_to_dos
    self.find(:all, :conditions => " 1=1 /* or whatever */ ").select(&:state == 'Current')
end
3 голосов
/ 09 ноября 2011

Одним из простых решений для умеренно больших наборов данных является использование базы данных SQL. Каждая запись todo должна иметь поля «state_id», «current_at» и «late_at». Вы, вероятно, можете опустить "future_at", если у вас нет четырех состояний.

Это позволяет три состояния:

  1. Будущее: когда сейчас
  2. Текущий: когда current_at <= <strong>сейчас
  3. Поздно: когда поздно_at <= <strong>сейчас

Сохранение состояния в виде state_id (необязательно, создание внешнего ключа для таблицы поиска с именем «states», где 1: Future, 2: Current, 3: Late) в основном хранит ненормализованные данные, что позволяет избежать повторного вычисления состояние как оно редко меняется.

Если вы на самом деле не запрашиваете записи дел в зависимости от состояния (например, ... WHERE state_id = 1) или не запускаете какой-либо побочный эффект (например, отправку электронной почты) при изменении состояния, возможно, вам не нужно управлять состоянием. Если вы просто показываете пользователю список задач и указываете, какие из них опоздали, самая дешевая реализация может даже быть рассчитана на стороне клиента. Для ответа, я предполагаю, что вам нужно управлять государством.

У вас есть несколько вариантов обновления state_id. Я предполагаю, что вы применяете ограничение current_at < late_at.

  • Самое простое - обновить каждую запись: UPDATE todos SET state_id = CASE WHEN late_at <= NOW() THEN 3 WHEN current_at <= NOW() THEN 2 ELSE 1 END;.

  • Вероятно, вы получите лучшую производительность с чем-то вроде (за одну транзакцию) UPDATE todos SET state_id = 3 WHERE state_id <> 3 AND late_at <= NOW(), UPDATE todos SET state_id = 2 WHERE state_id <> 2 AND NOW() < late_at AND current_at <= NOW(), UPDATE todos SET state_id = 1 WHERE state_id <> 1 AND NOW() < current_at. Это позволяет избежать извлечения строк, которые не нужно обновлять, но вам понадобятся индексы для «late_at» и «future_at» (вы можете попробовать индексировать «state_id», см. Примечание ниже). Вы можете запускать эти три обновления так часто, как вам нужно.

  • Небольшое отклонение от приведенного выше - сначала получить идентификаторы записей, чтобы вы могли что-то делать с задачами, которые изменили состояния. Это выглядит примерно так: SELECT id FROM todos WHERE state_id <> 3 AND late_at <= NOW() FOR UPDATE. Затем вы должны сделать обновление как UPDATE todos SET state_id = 3 WHERE id IN (:ids). Теперь у вас все еще есть идентификаторы, с которыми можно что-то делать позже (например, отправить уведомление по электронной почте «20 задач устарели»).

  • Планирование или постановка в очередь заданий на обновление для каждой задачи (например, обновление этого «текущего» в 10:00 и «позднее» в 11:00) приведет к лоту запланированных заданий, по крайней мере, двум умноженное на количество задач и низкая производительность - каждое запланированное задание обновляет только одну запись.

  • Вы можете запланировать пакетные обновления, например UPDATE state_id = 2 WHERE ID IN (1,2,3,4,5,...), где вы предварительно рассчитали список идентификаторов задач, которые станут актуальными в течение определенного времени. Это, вероятно, не получится так хорошо на практике по нескольким причинам. Одно из полей current_at и late_at некоторых задач может измениться после запланированных обновлений.

Примечание: вы можете не сильно выиграть, если индексировать "state_id", так как он разбивает ваш набор данных только на три набора. Вероятно, этого недостаточно для планировщика запросов, чтобы рассмотреть возможность его использования в запросе, подобном SELECT * FROM todos WHERE state_id = 1.

ключ к этой проблеме, которую вы не обсуждали: что происходит с завершенными задачами? Если вы оставите их в этой таблице задач, таблица будет расти бесконечно, а ваша производительность будет ухудшаться со временем . Решение состоит в том, чтобы разбить данные на две отдельные таблицы (например, "complete_todos" и "pending_todos"). Затем вы можете использовать UNION для объединения обеих таблиц, когда вам это действительно нужно.

2 голосов
/ 09 ноября 2011

Конечные машины движутся чем-то. взаимодействие с пользователем или последний ввод из потока, верно? В этом случае время управляет конечным автоматом. Я думаю, что работа cron - правильная игра. это были бы часы, управляющие машиной.

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

сейчас> текущий && сейчас <поздно будет сложно представить в базе данных качественным способом в качестве атрибута задачи </p>

Идентификатор | название | future_time | current_time | late_time

1 | привет | 8: 00 утра | 9: 00 утра | 10: 00 утра

1 голос
/ 15 ноября 2011

Никогда не пытайтесь навязать шаблоны в проблемы. Все наоборот. Итак, перейдите непосредственно, чтобы найти хорошее решение для этого.

Вот идея: (как я понял, ваша)

Используйте постоянные оповещения и один отслеживаемый процесс, чтобы «потреблять» их. Во-вторых, запросите их.

Это позволит вам:

  1. будь проще
  2. Держите это дешево, чтобы поддерживать. Во-вторых, это также будет держать вас умственно более свежий, чтобы сделать что-то еще.
  3. сохранить всю логику только в коде (как и должно быть).

Я подчеркиваю смысл наблюдения за этим процессом с помощью некоторого сторожевого таймера, чтобы вы могли отправлять эти оповещения вовремя (или, в худшем случае, с некоторой задержкой после сбоя или тому подобного).

Обратите внимание: факт наличия этих предупреждений позволяет сделать следующее:

  1. делает / поддерживает вашу систему эластичной (более отказоустойчивой) и
  2. позволяет вам запрашивать будущие и текущие элементы (поэкспериментируя с запросом временного диапазона оповещений, который наилучшим образом соответствует вашим потребностям)
0 голосов
/ 15 ноября 2011

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

В вашем случае, я думаю, вы можете (и должны) использовать SQL для решения вашей проблемы вместо того, чтобы беспокоиться об использовании конечного автомата:

Создайте таблицу todo_states:

todo_id  todo_state_id    datetime  notified
1        1 (future)       8:00      0
1        2 (current)      9:00      0
1        3 (late)         10:00     0

Ваш SQL-запрос, где происходит вся настоящая работа:

SELECT todo_id, MAX(todo_state_id) AS todo_state_id 
FROM todo_states
WHERE time < NOW()
GROUP BY todo_id

Текущее активное состояние - всегда то, которое вы выбираете. Если вы хотите уведомить пользователя только один раз, вставьте исходное состояние с уведомлением = 0 и увеличьте его при первом выборе.

После того, как задача «выполнена», вы можете либо вставить другое состояние в таблицу todo_states, либо просто удалить все состояния, связанные с задачей, и поднять флаг «выполнено» в элементе todo, или что-либо наиболее полезное в ваш случай.

Не забудьте очистить устаревшие состояния.

...