350 lines
9.5 KiB
GDScript
350 lines
9.5 KiB
GDScript
class_name RegionalJobScheduler
|
|
extends RefCounted
|
|
|
|
const SCHEMA_VERSION := 1
|
|
const UNLIMITED_BUDGET := -1
|
|
|
|
var _jobs_by_id: Dictionary = {}
|
|
var _job_heap: Array[ScheduledJobRecord] = []
|
|
var _heap_index_by_id: Dictionary = {}
|
|
var _dedupe_job_ids: Dictionary = {}
|
|
var _sequence_job_ids: Dictionary = {}
|
|
var _next_stable_sequence := 0
|
|
var _last_drain_tick := -1
|
|
var _executed_count := 0
|
|
var _last_executed_job: ScheduledJobRecord
|
|
|
|
|
|
static func from_dictionary(record_data: Dictionary) -> RegionalJobScheduler:
|
|
var scheduler := RegionalJobScheduler.new()
|
|
return scheduler if scheduler.restore_from_dictionary(record_data) else null
|
|
|
|
|
|
func size() -> int:
|
|
return _jobs_by_id.size()
|
|
|
|
|
|
func is_empty() -> bool:
|
|
return _jobs_by_id.is_empty()
|
|
|
|
|
|
func has_job(job_id: StringName) -> bool:
|
|
return _jobs_by_id.has(job_id)
|
|
|
|
|
|
func get_by_id(job_id: StringName) -> ScheduledJobRecord:
|
|
return _jobs_by_id.get(job_id) as ScheduledJobRecord
|
|
|
|
|
|
func schedule(job: ScheduledJobRecord) -> bool:
|
|
if (
|
|
job == null
|
|
or not job.is_valid()
|
|
or has_job(job.get_job_id())
|
|
or _sequence_job_ids.has(job.get_stable_sequence())
|
|
or (_last_executed_job != null and not ScheduledJobRecord.compare(_last_executed_job, job))
|
|
):
|
|
return false
|
|
var dedupe_key := job.get_dedupe_key()
|
|
if not dedupe_key.is_empty() and _dedupe_job_ids.has(dedupe_key):
|
|
return false
|
|
var stored := ScheduledJobRecord.from_dictionary(job.to_dictionary())
|
|
if stored == null:
|
|
return false
|
|
_jobs_by_id[stored.get_job_id()] = stored
|
|
_sequence_job_ids[stored.get_stable_sequence()] = stored.get_job_id()
|
|
if not dedupe_key.is_empty():
|
|
_dedupe_job_ids[dedupe_key] = stored.get_job_id()
|
|
_heap_push(stored)
|
|
_next_stable_sequence = maxi(_next_stable_sequence, stored.get_stable_sequence() + 1)
|
|
return true
|
|
|
|
|
|
func schedule_new(
|
|
job_id: StringName,
|
|
job_type: StringName,
|
|
due_tick: int,
|
|
phase: int,
|
|
entity_id: StringName,
|
|
payload: Dictionary = {},
|
|
dedupe_key: StringName = &"",
|
|
repeat_interval: int = ScheduledJobRecord.NO_REPEAT
|
|
) -> ScheduledJobRecord:
|
|
var job := ScheduledJobRecord.create(
|
|
job_id,
|
|
job_type,
|
|
due_tick,
|
|
phase,
|
|
entity_id,
|
|
_next_stable_sequence,
|
|
payload,
|
|
dedupe_key,
|
|
repeat_interval
|
|
)
|
|
return job if schedule(job) else null
|
|
|
|
|
|
func cancel(job_id: StringName) -> ScheduledJobRecord:
|
|
var job := get_by_id(job_id)
|
|
if job == null:
|
|
return null
|
|
var remove_index := int(_heap_index_by_id[job_id])
|
|
var last_index := _job_heap.size() - 1
|
|
if remove_index != last_index:
|
|
_heap_swap(remove_index, last_index)
|
|
_job_heap.pop_back()
|
|
_heap_index_by_id.erase(job_id)
|
|
_jobs_by_id.erase(job_id)
|
|
_sequence_job_ids.erase(job.get_stable_sequence())
|
|
var dedupe_key := job.get_dedupe_key()
|
|
if not dedupe_key.is_empty():
|
|
_dedupe_job_ids.erase(dedupe_key)
|
|
if remove_index < _job_heap.size():
|
|
var parent := (remove_index - 1) / 2
|
|
if (
|
|
remove_index > 0
|
|
and ScheduledJobRecord.compare(_job_heap[remove_index], _job_heap[parent])
|
|
):
|
|
_heap_sift_up(remove_index)
|
|
else:
|
|
_heap_sift_down(remove_index)
|
|
return job
|
|
|
|
|
|
func peek_next() -> ScheduledJobRecord:
|
|
return _job_heap[0] if not _job_heap.is_empty() else null
|
|
|
|
|
|
func get_all_sorted() -> Array[ScheduledJobRecord]:
|
|
var jobs := _job_heap.duplicate()
|
|
jobs.sort_custom(ScheduledJobRecord.compare)
|
|
return jobs
|
|
|
|
|
|
func get_due_count(current_tick: int) -> int:
|
|
var count := 0
|
|
for job in _jobs_by_id.values():
|
|
if (job as ScheduledJobRecord).get_due_tick() <= current_tick:
|
|
count += 1
|
|
return count
|
|
|
|
|
|
func take_due(
|
|
current_tick: int, execution_budget: int = UNLIMITED_BUDGET
|
|
) -> Array[ScheduledJobRecord]:
|
|
var due: Array[ScheduledJobRecord] = []
|
|
if current_tick < 0 or current_tick < _last_drain_tick or execution_budget < UNLIMITED_BUDGET:
|
|
return due
|
|
_last_drain_tick = current_tick
|
|
if execution_budget == 0:
|
|
return due
|
|
var remaining := execution_budget
|
|
while not _job_heap.is_empty():
|
|
var next := _job_heap[0]
|
|
if next.get_due_tick() > current_tick:
|
|
break
|
|
if remaining == 0:
|
|
break
|
|
var job := cancel(next.get_job_id())
|
|
due.append(job)
|
|
_executed_count += 1
|
|
_last_executed_job = job
|
|
if job.is_repeating():
|
|
var next_occurrence := job.create_next_occurrence(_next_stable_sequence)
|
|
if not schedule(next_occurrence):
|
|
push_error("Could not schedule next occurrence for '%s'" % job.get_job_id())
|
|
if remaining > 0:
|
|
remaining -= 1
|
|
return due
|
|
|
|
|
|
func execute_due(
|
|
current_tick: int, execution_budget: int, executor: Callable
|
|
) -> Array[ScheduledJobRecord]:
|
|
var executed: Array[ScheduledJobRecord] = []
|
|
if not executor.is_valid() or execution_budget < UNLIMITED_BUDGET:
|
|
return executed
|
|
var remaining := execution_budget
|
|
while remaining != 0:
|
|
var batch := take_due(current_tick, 1)
|
|
if batch.is_empty():
|
|
break
|
|
var job := batch[0]
|
|
executed.append(job)
|
|
executor.call(job)
|
|
if remaining > 0:
|
|
remaining -= 1
|
|
return executed
|
|
|
|
|
|
func restore(records: Array[ScheduledJobRecord], cursor: Dictionary = {}) -> bool:
|
|
var candidate := RegionalJobScheduler.new()
|
|
for record in records:
|
|
if not candidate.schedule(record):
|
|
return false
|
|
var next_sequence := int(cursor.get("next_stable_sequence", candidate._next_stable_sequence))
|
|
var last_drain_tick := int(cursor.get("last_drain_tick", -1))
|
|
var executed_count := int(cursor.get("executed_count", 0))
|
|
var saved_last_order: Variant = cursor.get("last_executed_order")
|
|
var last_executed_job := _parse_cursor_order(saved_last_order)
|
|
if (
|
|
next_sequence < candidate._next_stable_sequence
|
|
or last_drain_tick < -1
|
|
or executed_count < 0
|
|
or (executed_count == 0 and last_executed_job != null)
|
|
or (executed_count > 0 and last_executed_job == null)
|
|
or (saved_last_order != null and last_executed_job == null)
|
|
or (
|
|
last_executed_job != null
|
|
and (
|
|
last_executed_job.get_due_tick() > last_drain_tick
|
|
or next_sequence <= last_executed_job.get_stable_sequence()
|
|
)
|
|
)
|
|
):
|
|
return false
|
|
if (
|
|
last_executed_job != null
|
|
and not candidate._job_heap.is_empty()
|
|
and not ScheduledJobRecord.compare(last_executed_job, candidate._job_heap[0])
|
|
):
|
|
return false
|
|
_adopt(candidate)
|
|
_next_stable_sequence = next_sequence
|
|
_last_drain_tick = last_drain_tick
|
|
_executed_count = executed_count
|
|
_last_executed_job = last_executed_job
|
|
return true
|
|
|
|
|
|
func restore_from_dictionary(record_data: Dictionary) -> bool:
|
|
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
|
|
return false
|
|
if not record_data.has_all(["jobs", "cursor"]) or not record_data["jobs"] is Array:
|
|
return false
|
|
if not record_data["cursor"] is Dictionary:
|
|
return false
|
|
if not (
|
|
record_data["cursor"]
|
|
. has_all(
|
|
[
|
|
"next_stable_sequence",
|
|
"last_drain_tick",
|
|
"executed_count",
|
|
"last_executed_order",
|
|
]
|
|
)
|
|
):
|
|
return false
|
|
var records: Array[ScheduledJobRecord] = []
|
|
for saved_job in record_data["jobs"]:
|
|
if not saved_job is Dictionary:
|
|
return false
|
|
var job := ScheduledJobRecord.from_dictionary(saved_job)
|
|
if job == null:
|
|
return false
|
|
records.append(job)
|
|
return restore(records, record_data["cursor"])
|
|
|
|
|
|
func get_cursor() -> Dictionary:
|
|
return {
|
|
"next_stable_sequence": _next_stable_sequence,
|
|
"last_drain_tick": _last_drain_tick,
|
|
"executed_count": _executed_count,
|
|
"last_executed_order": _serialize_cursor_order(_last_executed_job),
|
|
}
|
|
|
|
|
|
func to_dictionary() -> Dictionary:
|
|
var jobs: Array[Dictionary] = []
|
|
for job in get_all_sorted():
|
|
jobs.append(job.to_dictionary())
|
|
return {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"jobs": jobs,
|
|
"cursor": get_cursor(),
|
|
}
|
|
|
|
|
|
func _heap_push(job: ScheduledJobRecord) -> void:
|
|
var index := _job_heap.size()
|
|
_job_heap.append(job)
|
|
_heap_index_by_id[job.get_job_id()] = index
|
|
_heap_sift_up(index)
|
|
|
|
|
|
func _heap_sift_up(start_index: int) -> void:
|
|
var index := start_index
|
|
while index > 0:
|
|
var parent := (index - 1) / 2
|
|
if not ScheduledJobRecord.compare(_job_heap[index], _job_heap[parent]):
|
|
break
|
|
_heap_swap(index, parent)
|
|
index = parent
|
|
|
|
|
|
func _heap_sift_down(start_index: int) -> void:
|
|
var index := start_index
|
|
while true:
|
|
var left := index * 2 + 1
|
|
if left >= _job_heap.size():
|
|
return
|
|
var right := left + 1
|
|
var smallest := left
|
|
if (
|
|
right < _job_heap.size()
|
|
and ScheduledJobRecord.compare(_job_heap[right], _job_heap[left])
|
|
):
|
|
smallest = right
|
|
if not ScheduledJobRecord.compare(_job_heap[smallest], _job_heap[index]):
|
|
return
|
|
_heap_swap(index, smallest)
|
|
index = smallest
|
|
|
|
|
|
func _heap_swap(first_index: int, second_index: int) -> void:
|
|
var first := _job_heap[first_index]
|
|
_job_heap[first_index] = _job_heap[second_index]
|
|
_job_heap[second_index] = first
|
|
_heap_index_by_id[_job_heap[first_index].get_job_id()] = first_index
|
|
_heap_index_by_id[_job_heap[second_index].get_job_id()] = second_index
|
|
|
|
|
|
func _adopt(source: RegionalJobScheduler) -> void:
|
|
_jobs_by_id = source._jobs_by_id
|
|
_job_heap = source._job_heap
|
|
_heap_index_by_id = source._heap_index_by_id
|
|
_dedupe_job_ids = source._dedupe_job_ids
|
|
_sequence_job_ids = source._sequence_job_ids
|
|
_next_stable_sequence = source._next_stable_sequence
|
|
|
|
|
|
static func _serialize_cursor_order(job: ScheduledJobRecord) -> Variant:
|
|
if job == null:
|
|
return null
|
|
return {
|
|
"due_tick": job.get_due_tick(),
|
|
"phase": job.get_phase(),
|
|
"entity_id": String(job.get_entity_id()),
|
|
"stable_sequence": job.get_stable_sequence(),
|
|
}
|
|
|
|
|
|
static func _parse_cursor_order(value: Variant) -> ScheduledJobRecord:
|
|
if value == null:
|
|
return null
|
|
if (
|
|
not value is Dictionary
|
|
or not value.has_all(["due_tick", "phase", "entity_id", "stable_sequence"])
|
|
):
|
|
return null
|
|
return ScheduledJobRecord.create(
|
|
&"__cursor__",
|
|
&"__cursor__",
|
|
int(value["due_tick"]),
|
|
int(value["phase"]),
|
|
StringName(value["entity_id"]),
|
|
int(value["stable_sequence"])
|
|
)
|