1560 lines
56 KiB
GDScript
1560 lines
56 KiB
GDScript
class_name InventoryService
|
|
extends RefCounted
|
|
|
|
const InventoryReservationType = preload("res://scripts/inventory/inventory_reservation.gd")
|
|
|
|
const SNAPSHOT_VERSION := 2
|
|
const LEGACY_SNAPSHOT_VERSION := 1
|
|
const DEFAULT_QUALITY := 100
|
|
const DEFAULT_UNIT_WEIGHT := 1
|
|
|
|
var current_minute := 0
|
|
var last_restore_error := ""
|
|
var last_repair_report: Dictionary = {}
|
|
|
|
var _inventories: Dictionary = {}
|
|
## Derived O(1) stack location index. It is rebuilt after restore and repaired
|
|
## lazily if diagnostic code mutates the public test seam `_inventories`.
|
|
var _stack_owner_by_id: Dictionary = {}
|
|
var _reservations: Dictionary = {}
|
|
## Persisted tombstones keep generated and explicit ids single-use even after
|
|
## their live stack/reservation has been removed.
|
|
var _known_stack_ids: Dictionary = {}
|
|
var _known_reservation_ids: Dictionary = {}
|
|
var _idempotency_results: Dictionary = {}
|
|
var _next_stack_sequence := 1
|
|
var _next_reservation_sequence := 1
|
|
var _atomic_batch_depth := 0
|
|
|
|
|
|
func add_stack(
|
|
owner_inventory_id: StringName,
|
|
item_id: StringName,
|
|
quantity: int,
|
|
quality: int = DEFAULT_QUALITY,
|
|
expires_at: int = -1,
|
|
created_at: int = -1,
|
|
unit_weight: int = DEFAULT_UNIT_WEIGHT,
|
|
stack_id: StringName = &""
|
|
) -> Dictionary:
|
|
if owner_inventory_id == &"" or item_id == &"":
|
|
return _failure(&"INVALID_ID", "Inventory and item ids are required.")
|
|
if quantity <= 0 or unit_weight <= 0:
|
|
return _failure(&"INVALID_QUANTITY", "Quantity and unit weight must be positive.")
|
|
var effective_stack_id := stack_id
|
|
if effective_stack_id == &"":
|
|
effective_stack_id = _next_stack_id()
|
|
elif _known_stack_ids.has(effective_stack_id) or bool(_find_stack(effective_stack_id)[&"found"]):
|
|
return _failure(&"DUPLICATE_STACK", "Stack id has already been used.")
|
|
var stack := _make_stack(
|
|
effective_stack_id,
|
|
owner_inventory_id,
|
|
item_id,
|
|
quantity,
|
|
quality,
|
|
-1 if expires_at < 0 else expires_at,
|
|
current_minute if created_at < 0 else created_at,
|
|
unit_weight
|
|
)
|
|
_insert_stack(stack)
|
|
return _success({&"stack_id": effective_stack_id, &"quantity": quantity})
|
|
|
|
|
|
func reserve_item(
|
|
owner_inventory_id: StringName,
|
|
item_id: StringName,
|
|
quantity: int,
|
|
requester_id: StringName,
|
|
purpose: StringName,
|
|
created_at: int = -1,
|
|
expires_at: int = -1,
|
|
operation_id: StringName = &""
|
|
) -> Dictionary:
|
|
var minute := current_minute if created_at < 0 else created_at
|
|
var normalized_expires_at := -1 if expires_at < 0 else expires_at
|
|
var fingerprint := _canonical_fingerprint([
|
|
owner_inventory_id,
|
|
item_id,
|
|
quantity,
|
|
requester_id,
|
|
purpose,
|
|
minute,
|
|
normalized_expires_at,
|
|
])
|
|
var replay := _get_replay(&"reserve", operation_id, fingerprint)
|
|
if not replay.is_empty():
|
|
return replay
|
|
if owner_inventory_id == &"" or item_id == &"" or requester_id == &"":
|
|
return _remember_failure(
|
|
&"reserve", operation_id, fingerprint, &"INVALID_ID", "Inventory, item, and requester ids are required."
|
|
)
|
|
if quantity <= 0:
|
|
return _remember_failure(
|
|
&"reserve", operation_id, fingerprint, &"INVALID_QUANTITY", "Reservation quantity must be positive."
|
|
)
|
|
var candidates := _available_stacks(owner_inventory_id, item_id, minute)
|
|
var total_available := 0
|
|
for stack: Dictionary in candidates:
|
|
total_available += _available_in_stack(stack)
|
|
if total_available < quantity:
|
|
return _remember_failure(
|
|
&"reserve",
|
|
operation_id,
|
|
fingerprint,
|
|
&"INSUFFICIENT_AVAILABLE",
|
|
"Available stock is below the requested quantity.",
|
|
{&"requested": quantity, &"available": total_available}
|
|
)
|
|
|
|
var remaining := quantity
|
|
var allocations: Array[Dictionary] = []
|
|
for stack: Dictionary in candidates:
|
|
if remaining <= 0:
|
|
break
|
|
var allocated := mini(remaining, _available_in_stack(stack))
|
|
if allocated > 0:
|
|
allocations.append({&"stack_id": stack[&"stack_id"], &"quantity": allocated})
|
|
remaining -= allocated
|
|
|
|
var reservation_ids: Array[StringName] = []
|
|
var stack_ids: Array[StringName] = []
|
|
for allocation: Dictionary in allocations:
|
|
var stack_id: StringName = allocation[&"stack_id"]
|
|
var location := _find_stack(stack_id)
|
|
var stack: Dictionary = location[&"stack"]
|
|
var allocated := int(allocation[&"quantity"])
|
|
stack[&"reserved_quantity"] = int(stack[&"reserved_quantity"]) + allocated
|
|
_set_stack(location[&"owner_inventory_id"], stack)
|
|
var reservation_id := _next_reservation_id()
|
|
var reservation := InventoryReservationType.new().configure(
|
|
reservation_id,
|
|
stack_id,
|
|
owner_inventory_id,
|
|
item_id,
|
|
allocated,
|
|
requester_id,
|
|
purpose,
|
|
minute,
|
|
normalized_expires_at
|
|
)
|
|
_reservations[reservation_id] = reservation
|
|
reservation_ids.append(reservation_id)
|
|
stack_ids.append(stack_id)
|
|
|
|
var result := _success({
|
|
&"reservation_ids": reservation_ids,
|
|
&"stack_ids": stack_ids,
|
|
&"quantity": quantity,
|
|
})
|
|
return _remember(&"reserve", operation_id, result, fingerprint)
|
|
|
|
|
|
func reserve(
|
|
owner_inventory_id: StringName,
|
|
item_id: StringName,
|
|
quantity: int,
|
|
requester_id: StringName,
|
|
purpose: StringName,
|
|
created_at: int = -1,
|
|
expires_at: int = -1,
|
|
operation_id: StringName = &""
|
|
) -> Dictionary:
|
|
return reserve_item(
|
|
owner_inventory_id,
|
|
item_id,
|
|
quantity,
|
|
requester_id,
|
|
purpose,
|
|
created_at,
|
|
expires_at,
|
|
operation_id
|
|
)
|
|
|
|
|
|
func consume_reservation(
|
|
reservation_id: StringName,
|
|
operation_id: StringName = &"",
|
|
absolute_minute: int = -1
|
|
) -> Dictionary:
|
|
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
|
var fingerprint := _canonical_fingerprint([reservation_id, minute])
|
|
var replay := _get_replay(&"consume", operation_id, fingerprint)
|
|
if not replay.is_empty():
|
|
return replay
|
|
if not _reservations.has(reservation_id):
|
|
return _remember_failure(
|
|
&"consume", operation_id, fingerprint, &"NOT_FOUND", "Reservation does not exist."
|
|
)
|
|
var reservation: InventoryReservation = _reservations[reservation_id]
|
|
if reservation.state == InventoryReservationType.State.CONSUMED:
|
|
return _remember(&"consume", operation_id, _success({
|
|
&"reservation_id": reservation_id,
|
|
&"quantity": 0,
|
|
&"replay": true,
|
|
&"code": &"ALREADY_CONSUMED",
|
|
}), fingerprint)
|
|
if not reservation.is_active():
|
|
return _remember_failure(
|
|
&"consume", operation_id, fingerprint, &"RESERVATION_CLOSED", "Reservation is no longer active."
|
|
)
|
|
var validation := _validate_active_reservation(reservation, minute)
|
|
if not bool(validation[&"ok"]):
|
|
return _remember(&"consume", operation_id, validation, fingerprint)
|
|
var location: Dictionary = validation[&"location"]
|
|
var stack: Dictionary = location[&"stack"]
|
|
stack[&"quantity"] = int(stack[&"quantity"]) - reservation.quantity
|
|
stack[&"reserved_quantity"] = int(stack[&"reserved_quantity"]) - reservation.quantity
|
|
reservation.mark_consumed(minute)
|
|
_set_or_remove_stack(location[&"owner_inventory_id"], stack)
|
|
var result := _success({
|
|
&"reservation_id": reservation_id,
|
|
&"stack_id": reservation.stack_id,
|
|
&"quantity": reservation.quantity,
|
|
})
|
|
return _remember(&"consume", operation_id, result, fingerprint)
|
|
|
|
|
|
func consume_reservations(
|
|
reservation_ids: Array[StringName],
|
|
operation_id: StringName = &"",
|
|
absolute_minute: int = -1
|
|
) -> Dictionary:
|
|
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
|
var fingerprint_ids := _sorted_names(reservation_ids)
|
|
var fingerprint := _canonical_fingerprint([fingerprint_ids, minute])
|
|
var replay := _get_replay(&"consume_many", operation_id, fingerprint)
|
|
if not replay.is_empty():
|
|
return replay
|
|
var unique_ids := _unique_sorted_names(reservation_ids)
|
|
if unique_ids.is_empty() or unique_ids.size() != reservation_ids.size():
|
|
return _remember_failure(
|
|
&"consume_many", operation_id, fingerprint, &"INVALID_RESERVATIONS", "Reservation ids must be unique and non-empty."
|
|
)
|
|
for reservation_id: StringName in unique_ids:
|
|
if not _reservations.has(reservation_id):
|
|
return _remember_failure(
|
|
&"consume_many", operation_id, fingerprint, &"NOT_FOUND", "Reservation does not exist."
|
|
)
|
|
var validation := _validate_active_reservation(_reservations[reservation_id], minute)
|
|
if not bool(validation[&"ok"]):
|
|
return _remember(&"consume_many", operation_id, validation, fingerprint)
|
|
|
|
var capture := create_snapshot()
|
|
var consumed := 0
|
|
for reservation_id: StringName in unique_ids:
|
|
var result := consume_reservation(reservation_id, &"", minute)
|
|
if not bool(result[&"ok"]):
|
|
restore_snapshot(capture)
|
|
return _remember_failure(
|
|
&"consume_many",
|
|
operation_id,
|
|
fingerprint,
|
|
&"ATOMIC_APPLY_FAILED",
|
|
"Reservation group consumption was rolled back.",
|
|
{&"cause": result}
|
|
)
|
|
consumed += int(result.get(&"quantity", 0))
|
|
var final_result := _success({
|
|
&"reservation_ids": unique_ids,
|
|
&"quantity": consumed,
|
|
})
|
|
return _remember(&"consume_many", operation_id, final_result, fingerprint)
|
|
|
|
|
|
func release_reservation(
|
|
reservation_id: StringName,
|
|
operation_id: StringName = &"",
|
|
absolute_minute: int = -1
|
|
) -> Dictionary:
|
|
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
|
var fingerprint := _canonical_fingerprint([reservation_id, minute])
|
|
var replay := _get_replay(&"release", operation_id, fingerprint)
|
|
if not replay.is_empty():
|
|
return replay
|
|
if not _reservations.has(reservation_id):
|
|
return _remember_failure(
|
|
&"release", operation_id, fingerprint, &"NOT_FOUND", "Reservation does not exist."
|
|
)
|
|
var reservation: InventoryReservation = _reservations[reservation_id]
|
|
if reservation.state == InventoryReservationType.State.RELEASED or reservation.state == InventoryReservationType.State.EXPIRED:
|
|
return _remember(&"release", operation_id, _success({
|
|
&"reservation_id": reservation_id,
|
|
&"quantity": 0,
|
|
&"replay": true,
|
|
&"code": &"ALREADY_RELEASED",
|
|
}), fingerprint)
|
|
if not reservation.is_active():
|
|
return _remember_failure(
|
|
&"release", operation_id, fingerprint, &"RESERVATION_CLOSED", "Consumed stock cannot be released."
|
|
)
|
|
var location := _find_stack(reservation.stack_id)
|
|
if not bool(location[&"found"]):
|
|
reservation.mark_released(minute)
|
|
var orphan_result := _success({
|
|
&"reservation_id": reservation_id,
|
|
&"quantity": 0,
|
|
&"orphan_repaired": true,
|
|
})
|
|
return _remember(&"release", operation_id, orphan_result, fingerprint)
|
|
var stack: Dictionary = location[&"stack"]
|
|
stack[&"reserved_quantity"] = maxi(
|
|
int(stack[&"reserved_quantity"]) - reservation.quantity,
|
|
0
|
|
)
|
|
reservation.mark_released(minute)
|
|
_set_or_remove_stack(location[&"owner_inventory_id"], stack)
|
|
var result := _success({
|
|
&"reservation_id": reservation_id,
|
|
&"quantity": reservation.quantity,
|
|
})
|
|
return _remember(&"release", operation_id, result, fingerprint)
|
|
|
|
|
|
func transfer_available(
|
|
source_inventory_id: StringName,
|
|
destination_inventory_id: StringName,
|
|
item_id: StringName,
|
|
quantity: int,
|
|
operation_id: StringName = &"",
|
|
absolute_minute: int = -1
|
|
) -> Dictionary:
|
|
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
|
var fingerprint := _transfer_fingerprint(
|
|
source_inventory_id,
|
|
destination_inventory_id,
|
|
item_id,
|
|
quantity,
|
|
minute
|
|
)
|
|
var replay := _get_replay(&"transfer", operation_id, fingerprint)
|
|
if not replay.is_empty():
|
|
return replay
|
|
if source_inventory_id == &"" or destination_inventory_id == &"" or item_id == &"":
|
|
return _remember_failure(&"transfer", operation_id, fingerprint, &"INVALID_ID", "Transfer ids are required.")
|
|
if source_inventory_id == destination_inventory_id:
|
|
return _remember_failure(
|
|
&"transfer", operation_id, fingerprint, &"SAME_INVENTORY", "Source and destination must differ."
|
|
)
|
|
if quantity <= 0:
|
|
return _remember_failure(
|
|
&"transfer", operation_id, fingerprint, &"INVALID_QUANTITY", "Transfer quantity must be positive."
|
|
)
|
|
var candidates := _available_stacks(source_inventory_id, item_id, minute)
|
|
var available := 0
|
|
for stack: Dictionary in candidates:
|
|
available += _available_in_stack(stack)
|
|
if available < quantity:
|
|
return _remember_failure(
|
|
&"transfer",
|
|
operation_id,
|
|
fingerprint,
|
|
&"INSUFFICIENT_AVAILABLE",
|
|
"Available stock is below the transfer quantity.",
|
|
{&"requested": quantity, &"available": available}
|
|
)
|
|
|
|
var capture := {} if _atomic_batch_depth > 0 else create_snapshot()
|
|
var remaining := quantity
|
|
var destination_stack_ids: Array[StringName] = []
|
|
for candidate: Dictionary in candidates:
|
|
if remaining <= 0:
|
|
break
|
|
var moved := mini(remaining, _available_in_stack(candidate))
|
|
if moved <= 0:
|
|
continue
|
|
var location := _find_stack(candidate[&"stack_id"])
|
|
if not bool(location[&"found"]):
|
|
_restore_standalone_capture(capture)
|
|
return _remember_failure(
|
|
&"transfer", operation_id, fingerprint, &"ATOMIC_APPLY_FAILED", "Transfer was rolled back."
|
|
)
|
|
var source_stack: Dictionary = location[&"stack"]
|
|
source_stack[&"quantity"] = int(source_stack[&"quantity"]) - moved
|
|
_set_or_remove_stack(source_inventory_id, source_stack)
|
|
var destination_stack_id := _next_stack_id()
|
|
_insert_stack(_make_stack(
|
|
destination_stack_id,
|
|
destination_inventory_id,
|
|
item_id,
|
|
moved,
|
|
int(candidate[&"quality"]),
|
|
int(candidate[&"expires_at"]),
|
|
int(candidate[&"created_at"]),
|
|
int(candidate[&"unit_weight"])
|
|
))
|
|
destination_stack_ids.append(destination_stack_id)
|
|
remaining -= moved
|
|
if remaining != 0 or (_atomic_batch_depth == 0 and not validate_invariants()[&"ok"]):
|
|
_restore_standalone_capture(capture)
|
|
return _remember_failure(
|
|
&"transfer", operation_id, fingerprint, &"ATOMIC_APPLY_FAILED", "Transfer invariant failed and was rolled back."
|
|
)
|
|
var result := _success({
|
|
&"source_inventory_id": source_inventory_id,
|
|
&"destination_inventory_id": destination_inventory_id,
|
|
&"item_id": item_id,
|
|
&"quantity": quantity,
|
|
&"destination_stack_ids": destination_stack_ids,
|
|
})
|
|
return _remember(&"transfer", operation_id, result, fingerprint)
|
|
|
|
|
|
func transfer(
|
|
source_inventory_id: StringName,
|
|
destination_inventory_id: StringName,
|
|
item_id: StringName,
|
|
quantity: int,
|
|
operation_id: StringName = &"",
|
|
absolute_minute: int = -1
|
|
) -> Dictionary:
|
|
return transfer_available(
|
|
source_inventory_id,
|
|
destination_inventory_id,
|
|
item_id,
|
|
quantity,
|
|
operation_id,
|
|
absolute_minute
|
|
)
|
|
|
|
|
|
## Applies the same ordered transfers as repeated transfer_available calls, but
|
|
## captures and validates state once for the whole atomic group. This keeps a
|
|
## large logistics wave deterministic without doing a full-world snapshot for
|
|
## every item line.
|
|
func transfer_available_batch(
|
|
transfers: Array[Dictionary],
|
|
absolute_minute: int = -1
|
|
) -> Dictionary:
|
|
if transfers.is_empty() or _atomic_batch_depth != 0:
|
|
return _failure(&"INVALID_BATCH", "A non-empty top-level transfer batch is required.")
|
|
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
|
var preflight := _preflight_available_batch(transfers, minute)
|
|
if not bool(preflight[&"ok"]):
|
|
return preflight
|
|
var capture := create_snapshot()
|
|
var results: Array[Dictionary] = []
|
|
_atomic_batch_depth = 1
|
|
for index: int in range(transfers.size()):
|
|
var entry: Dictionary = transfers[index]
|
|
var result := transfer_available(
|
|
StringName(entry.get(&"source_inventory_id", entry.get("source_inventory_id", ""))),
|
|
StringName(entry.get(&"destination_inventory_id", entry.get("destination_inventory_id", ""))),
|
|
StringName(entry.get(&"item_id", entry.get("item_id", ""))),
|
|
int(entry.get(&"quantity", entry.get("quantity", 0))),
|
|
StringName(entry.get(&"operation_id", entry.get("operation_id", ""))),
|
|
minute
|
|
)
|
|
if not bool(result.get(&"ok", false)):
|
|
_atomic_batch_depth = 0
|
|
restore_snapshot(capture)
|
|
return _failure(
|
|
&"ATOMIC_BATCH_FAILED",
|
|
"Available-stock transfer batch was rolled back.",
|
|
{&"failed_index": index, &"cause": result}
|
|
)
|
|
results.append(result)
|
|
_atomic_batch_depth = 0
|
|
if not validate_invariants()[&"ok"]:
|
|
restore_snapshot(capture)
|
|
return _failure(
|
|
&"ATOMIC_BATCH_FAILED",
|
|
"Available-stock transfer batch invariant failed and was rolled back."
|
|
)
|
|
return _success({&"results": results, &"transfer_count": results.size()})
|
|
|
|
|
|
func transfer_reserved(
|
|
reservation_ids: Array[StringName],
|
|
destination_inventory_id: StringName,
|
|
operation_id: StringName = &"",
|
|
absolute_minute: int = -1
|
|
) -> Dictionary:
|
|
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
|
var fingerprint_ids := _sorted_names(reservation_ids)
|
|
var fingerprint := _reserved_transfer_fingerprint(
|
|
fingerprint_ids,
|
|
destination_inventory_id,
|
|
minute
|
|
)
|
|
var replay := _get_replay(&"transfer_reserved", operation_id, fingerprint)
|
|
if not replay.is_empty():
|
|
return replay
|
|
var ids := _unique_sorted_names(reservation_ids)
|
|
if ids.is_empty() or ids.size() != reservation_ids.size() or destination_inventory_id == &"":
|
|
return _remember_failure(
|
|
&"transfer_reserved",
|
|
operation_id,
|
|
fingerprint,
|
|
&"INVALID_ARGUMENT",
|
|
"Reservation ids must be unique and destination is required."
|
|
)
|
|
var source_inventory_id: StringName = &""
|
|
for reservation_id: StringName in ids:
|
|
if not _reservations.has(reservation_id):
|
|
return _remember_failure(
|
|
&"transfer_reserved", operation_id, fingerprint, &"NOT_FOUND", "Reservation does not exist."
|
|
)
|
|
var reservation: InventoryReservation = _reservations[reservation_id]
|
|
var validation := _validate_active_reservation(reservation, minute)
|
|
if not bool(validation[&"ok"]):
|
|
return _remember(&"transfer_reserved", operation_id, validation, fingerprint)
|
|
if source_inventory_id == &"":
|
|
source_inventory_id = reservation.owner_inventory_id
|
|
elif source_inventory_id != reservation.owner_inventory_id:
|
|
return _remember_failure(
|
|
&"transfer_reserved", operation_id, fingerprint, &"MULTIPLE_SOURCES", "One transfer cannot mix source inventories."
|
|
)
|
|
if source_inventory_id == destination_inventory_id:
|
|
return _remember_failure(
|
|
&"transfer_reserved", operation_id, fingerprint, &"SAME_INVENTORY", "Source and destination must differ."
|
|
)
|
|
|
|
var capture := {} if _atomic_batch_depth > 0 else create_snapshot()
|
|
var moved_quantity := 0
|
|
var moved_weight := 0
|
|
var destination_stack_ids: Array[StringName] = []
|
|
for reservation_id: StringName in ids:
|
|
var reservation: InventoryReservation = _reservations[reservation_id]
|
|
var location := _find_stack(reservation.stack_id)
|
|
if not bool(location[&"found"]):
|
|
_restore_standalone_capture(capture)
|
|
return _remember_failure(
|
|
&"transfer_reserved", operation_id, fingerprint, &"ATOMIC_APPLY_FAILED", "Transfer was rolled back."
|
|
)
|
|
var source_stack: Dictionary = location[&"stack"]
|
|
source_stack[&"quantity"] = int(source_stack[&"quantity"]) - reservation.quantity
|
|
source_stack[&"reserved_quantity"] = int(source_stack[&"reserved_quantity"]) - reservation.quantity
|
|
_set_or_remove_stack(source_inventory_id, source_stack)
|
|
reservation.mark_consumed(minute)
|
|
var destination_stack_id := _next_stack_id()
|
|
_insert_stack(_make_stack(
|
|
destination_stack_id,
|
|
destination_inventory_id,
|
|
reservation.item_id,
|
|
reservation.quantity,
|
|
int(source_stack[&"quality"]),
|
|
int(source_stack[&"expires_at"]),
|
|
int(source_stack[&"created_at"]),
|
|
int(source_stack[&"unit_weight"])
|
|
))
|
|
destination_stack_ids.append(destination_stack_id)
|
|
moved_quantity += reservation.quantity
|
|
moved_weight += reservation.quantity * int(source_stack[&"unit_weight"])
|
|
if _atomic_batch_depth == 0 and not validate_invariants()[&"ok"]:
|
|
_restore_standalone_capture(capture)
|
|
return _remember_failure(
|
|
&"transfer_reserved",
|
|
operation_id,
|
|
fingerprint,
|
|
&"ATOMIC_APPLY_FAILED",
|
|
"Transfer invariant failed and was rolled back."
|
|
)
|
|
var result := _success({
|
|
&"source_inventory_id": source_inventory_id,
|
|
&"destination_inventory_id": destination_inventory_id,
|
|
&"reservation_ids": ids,
|
|
&"destination_stack_ids": destination_stack_ids,
|
|
&"quantity": moved_quantity,
|
|
&"weight": moved_weight,
|
|
})
|
|
return _remember(&"transfer_reserved", operation_id, result, fingerprint)
|
|
|
|
|
|
## Atomic ordered counterpart to repeated transfer_reserved calls. Individual
|
|
## operation ids retain their normal replay semantics and snapshots remain
|
|
## byte-structure equivalent to the sequential path.
|
|
func transfer_reserved_batch(
|
|
transfers: Array[Dictionary],
|
|
absolute_minute: int = -1
|
|
) -> Dictionary:
|
|
if transfers.is_empty() or _atomic_batch_depth != 0:
|
|
return _failure(&"INVALID_BATCH", "A non-empty top-level reserved transfer batch is required.")
|
|
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
|
var preflight := _preflight_reserved_batch(transfers, minute)
|
|
if not bool(preflight[&"ok"]):
|
|
return preflight
|
|
var capture := create_snapshot()
|
|
var results: Array[Dictionary] = []
|
|
_atomic_batch_depth = 1
|
|
for index: int in range(transfers.size()):
|
|
var entry: Dictionary = transfers[index]
|
|
var reservation_ids: Array[StringName] = []
|
|
var raw_ids: Variant = entry.get(&"reservation_ids", entry.get("reservation_ids", []))
|
|
if raw_ids is Array:
|
|
for raw_id: Variant in raw_ids:
|
|
reservation_ids.append(StringName(raw_id))
|
|
var result := transfer_reserved(
|
|
reservation_ids,
|
|
StringName(entry.get(&"destination_inventory_id", entry.get("destination_inventory_id", ""))),
|
|
StringName(entry.get(&"operation_id", entry.get("operation_id", ""))),
|
|
minute
|
|
)
|
|
if not bool(result.get(&"ok", false)):
|
|
_atomic_batch_depth = 0
|
|
restore_snapshot(capture)
|
|
return _failure(
|
|
&"ATOMIC_BATCH_FAILED",
|
|
"Reserved-stock transfer batch was rolled back.",
|
|
{&"failed_index": index, &"cause": result}
|
|
)
|
|
results.append(result)
|
|
_atomic_batch_depth = 0
|
|
if not validate_invariants()[&"ok"]:
|
|
restore_snapshot(capture)
|
|
return _failure(
|
|
&"ATOMIC_BATCH_FAILED",
|
|
"Reserved-stock transfer batch invariant failed and was rolled back."
|
|
)
|
|
return _success({&"results": results, &"transfer_count": results.size()})
|
|
|
|
|
|
func process_expired(absolute_minute: int, waste_inventory_id: StringName = &"") -> Dictionary:
|
|
current_minute = maxi(current_minute, absolute_minute)
|
|
var expired_reservations: Array[StringName] = []
|
|
for reservation_id: StringName in _sorted_names(_reservations.keys()):
|
|
var reservation: InventoryReservation = _reservations[reservation_id]
|
|
if not reservation.is_active() or not reservation.is_expired(absolute_minute):
|
|
continue
|
|
var location := _find_stack(reservation.stack_id)
|
|
if bool(location[&"found"]):
|
|
var stack: Dictionary = location[&"stack"]
|
|
stack[&"reserved_quantity"] = maxi(
|
|
int(stack[&"reserved_quantity"]) - reservation.quantity,
|
|
0
|
|
)
|
|
_set_or_remove_stack(location[&"owner_inventory_id"], stack)
|
|
reservation.mark_expired(absolute_minute)
|
|
expired_reservations.append(reservation_id)
|
|
|
|
var discarded: Array[Dictionary] = []
|
|
var waste_lines: Array[Dictionary] = []
|
|
for owner_inventory_id: StringName in _sorted_names(_inventories.keys()):
|
|
var stack_ids := _sorted_names((_inventories[owner_inventory_id] as Dictionary).keys())
|
|
for stack_id: StringName in stack_ids:
|
|
var location := _find_stack(stack_id)
|
|
if not bool(location[&"found"]):
|
|
continue
|
|
var stack: Dictionary = location[&"stack"]
|
|
if not _stack_is_expired(stack, absolute_minute):
|
|
continue
|
|
var disposable := _available_in_stack(stack)
|
|
if disposable <= 0:
|
|
continue
|
|
stack[&"quantity"] = int(stack[&"quantity"]) - disposable
|
|
_set_or_remove_stack(owner_inventory_id, stack)
|
|
var line := {
|
|
&"owner_inventory_id": owner_inventory_id,
|
|
&"stack_id": stack_id,
|
|
&"item_id": stack[&"item_id"],
|
|
&"quantity": disposable,
|
|
}
|
|
discarded.append(line)
|
|
if waste_inventory_id != &"":
|
|
waste_lines.append(line)
|
|
for line: Dictionary in waste_lines:
|
|
_insert_stack(_make_stack(
|
|
_next_stack_id(),
|
|
waste_inventory_id,
|
|
StringName("waste/%s" % String(line[&"item_id"])),
|
|
int(line[&"quantity"]),
|
|
0,
|
|
-1,
|
|
absolute_minute,
|
|
DEFAULT_UNIT_WEIGHT
|
|
))
|
|
return {
|
|
&"ok": true,
|
|
&"expired_reservation_ids": expired_reservations,
|
|
&"discarded": discarded,
|
|
&"discarded_quantity": _sum_lines(discarded),
|
|
}
|
|
|
|
|
|
func is_stack_expired(stack_id: StringName, absolute_minute: int = -1) -> bool:
|
|
var location := _find_stack(stack_id)
|
|
if not bool(location[&"found"]):
|
|
return false
|
|
return _stack_is_expired(
|
|
location[&"stack"],
|
|
current_minute if absolute_minute < 0 else absolute_minute
|
|
)
|
|
|
|
|
|
func available_quantity(
|
|
owner_inventory_id: StringName,
|
|
item_id: StringName,
|
|
absolute_minute: int = -1
|
|
) -> int:
|
|
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
|
var total := 0
|
|
for stack: Dictionary in _available_stacks(owner_inventory_id, item_id, minute):
|
|
total += _available_in_stack(stack)
|
|
return total
|
|
|
|
|
|
func total_quantity(owner_inventory_id: StringName, item_id: StringName) -> int:
|
|
var total := 0
|
|
if not _inventories.has(owner_inventory_id):
|
|
return total
|
|
for stack: Dictionary in (_inventories[owner_inventory_id] as Dictionary).values():
|
|
if stack[&"item_id"] == item_id:
|
|
total += int(stack[&"quantity"])
|
|
return total
|
|
|
|
|
|
func get_stack(stack_id: StringName) -> Dictionary:
|
|
var location := _find_stack(stack_id)
|
|
if not bool(location[&"found"]):
|
|
return {}
|
|
return (location[&"stack"] as Dictionary).duplicate(true)
|
|
|
|
|
|
func get_stacks(owner_inventory_id: StringName = &"") -> Array[Dictionary]:
|
|
var result: Array[Dictionary] = []
|
|
var owners := _sorted_names(_inventories.keys()) if owner_inventory_id == &"" else [owner_inventory_id]
|
|
for owner: StringName in owners:
|
|
if not _inventories.has(owner):
|
|
continue
|
|
for stack_id: StringName in _sorted_names((_inventories[owner] as Dictionary).keys()):
|
|
result.append(((_inventories[owner] as Dictionary)[stack_id] as Dictionary).duplicate(true))
|
|
return result
|
|
|
|
|
|
func get_reservation(reservation_id: StringName) -> InventoryReservation:
|
|
return _reservations.get(reservation_id) as InventoryReservation
|
|
|
|
|
|
func get_reservations(active_only := false) -> Array[InventoryReservation]:
|
|
var result: Array[InventoryReservation] = []
|
|
for reservation_id: StringName in _sorted_names(_reservations.keys()):
|
|
var reservation: InventoryReservation = _reservations[reservation_id]
|
|
if not active_only or reservation.is_active():
|
|
result.append(reservation)
|
|
return result
|
|
|
|
|
|
func audit_and_repair_reservations(absolute_minute: int = -1) -> Dictionary:
|
|
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
|
var report := {
|
|
&"ok": true,
|
|
&"scanned_reservations": _reservations.size(),
|
|
&"orphan_count": 0,
|
|
&"capacity_violation_count": 0,
|
|
&"released_reservation_ids": [],
|
|
&"corrected_stacks": [],
|
|
}
|
|
var reservations_by_stack: Dictionary = {}
|
|
for reservation_id: StringName in _sorted_names(_reservations.keys()):
|
|
var reservation: InventoryReservation = _reservations[reservation_id]
|
|
if not reservation.is_active():
|
|
continue
|
|
var location := _find_stack(reservation.stack_id)
|
|
var orphan: bool = (
|
|
not bool(location[&"found"])
|
|
or location[&"owner_inventory_id"] != reservation.owner_inventory_id
|
|
or (bool(location[&"found"]) and location[&"stack"][&"item_id"] != reservation.item_id)
|
|
or reservation.quantity <= 0
|
|
)
|
|
if orphan:
|
|
reservation.mark_released(minute)
|
|
report[&"orphan_count"] = int(report[&"orphan_count"]) + 1
|
|
report[&"released_reservation_ids"].append(reservation_id)
|
|
continue
|
|
if not reservations_by_stack.has(reservation.stack_id):
|
|
reservations_by_stack[reservation.stack_id] = []
|
|
(reservations_by_stack[reservation.stack_id] as Array).append(reservation_id)
|
|
|
|
for stack_id: StringName in _sorted_names(reservations_by_stack.keys()):
|
|
var location := _find_stack(stack_id)
|
|
if not bool(location[&"found"]):
|
|
continue
|
|
var stack: Dictionary = location[&"stack"]
|
|
var reservation_ids: Array = reservations_by_stack[stack_id]
|
|
var expected := 0
|
|
for reservation_id: StringName in reservation_ids:
|
|
expected += (_reservations[reservation_id] as InventoryReservation).quantity
|
|
if expected > int(stack[&"quantity"]):
|
|
reservation_ids.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) > String(b))
|
|
for reservation_id: StringName in reservation_ids:
|
|
if expected <= int(stack[&"quantity"]):
|
|
break
|
|
var reservation: InventoryReservation = _reservations[reservation_id]
|
|
expected -= reservation.quantity
|
|
reservation.mark_released(minute)
|
|
report[&"capacity_violation_count"] = int(report[&"capacity_violation_count"]) + 1
|
|
report[&"released_reservation_ids"].append(reservation_id)
|
|
|
|
var expected_by_stack: Dictionary = {}
|
|
for reservation_id: StringName in _sorted_names(_reservations.keys()):
|
|
var reservation: InventoryReservation = _reservations[reservation_id]
|
|
if reservation.is_active():
|
|
expected_by_stack[reservation.stack_id] = int(expected_by_stack.get(reservation.stack_id, 0)) + reservation.quantity
|
|
for owner_inventory_id: StringName in _sorted_names(_inventories.keys()):
|
|
for stack_id: StringName in _sorted_names((_inventories[owner_inventory_id] as Dictionary).keys()):
|
|
var stack: Dictionary = (_inventories[owner_inventory_id] as Dictionary)[stack_id]
|
|
var expected := int(expected_by_stack.get(stack_id, 0))
|
|
if int(stack[&"reserved_quantity"]) == expected:
|
|
continue
|
|
report[&"corrected_stacks"].append({
|
|
&"stack_id": stack_id,
|
|
&"before": int(stack[&"reserved_quantity"]),
|
|
&"after": expected,
|
|
})
|
|
stack[&"reserved_quantity"] = expected
|
|
_set_stack(owner_inventory_id, stack)
|
|
last_repair_report = report.duplicate(true)
|
|
return report
|
|
|
|
|
|
func repair_orphan_reservations(absolute_minute: int = -1) -> Dictionary:
|
|
return audit_and_repair_reservations(absolute_minute)
|
|
|
|
|
|
func validate_invariants() -> Dictionary:
|
|
var error := _validate_state(_inventories, _reservations)
|
|
return {
|
|
&"ok": error.is_empty(),
|
|
&"error": error,
|
|
}
|
|
|
|
|
|
func create_snapshot() -> Dictionary:
|
|
var inventories: Array[Dictionary] = []
|
|
for owner_inventory_id: StringName in _sorted_names(_inventories.keys()):
|
|
var stacks: Array[Dictionary] = []
|
|
for stack_id: StringName in _sorted_names((_inventories[owner_inventory_id] as Dictionary).keys()):
|
|
var stack: Dictionary = (_inventories[owner_inventory_id] as Dictionary)[stack_id]
|
|
stacks.append(_serialize_stack(stack))
|
|
inventories.append({
|
|
&"owner_inventory_id": String(owner_inventory_id),
|
|
&"stacks": stacks,
|
|
})
|
|
var reservations: Array[Dictionary] = []
|
|
for reservation_id: StringName in _sorted_names(_reservations.keys()):
|
|
reservations.append((_reservations[reservation_id] as InventoryReservation).create_snapshot())
|
|
var idempotency_results: Array[Dictionary] = []
|
|
var operation_keys := _idempotency_results.keys()
|
|
operation_keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
|
|
for operation_key: Variant in operation_keys:
|
|
var record: Dictionary = _idempotency_results[operation_key]
|
|
var serialized_record := {
|
|
&"operation_id": String(operation_key),
|
|
&"action": String(record[&"action"]),
|
|
&"input_fingerprint": String(record[&"input_fingerprint"]),
|
|
&"result": (record[&"result"] as Dictionary).duplicate(true),
|
|
}
|
|
if bool(record.get(&"legacy_unbound", false)):
|
|
serialized_record[&"legacy_unbound"] = true
|
|
idempotency_results.append(serialized_record)
|
|
return {
|
|
&"version": SNAPSHOT_VERSION,
|
|
&"current_minute": current_minute,
|
|
&"next_stack_sequence": _next_stack_sequence,
|
|
&"next_reservation_sequence": _next_reservation_sequence,
|
|
&"known_stack_ids": _string_array(_sorted_names(_known_stack_ids.keys())),
|
|
&"known_reservation_ids": _string_array(_sorted_names(_known_reservation_ids.keys())),
|
|
&"inventories": inventories,
|
|
&"reservations": reservations,
|
|
&"idempotency_results": idempotency_results,
|
|
}
|
|
|
|
|
|
func restore_snapshot(snapshot: Dictionary) -> bool:
|
|
last_restore_error = ""
|
|
var version := int(snapshot.get(&"version", -1))
|
|
if version != SNAPSHOT_VERSION and version != LEGACY_SNAPSHOT_VERSION:
|
|
last_restore_error = "Unsupported inventory snapshot version."
|
|
return false
|
|
var next_current_minute := int(snapshot.get(&"current_minute", -1))
|
|
if next_current_minute < 0:
|
|
last_restore_error = "Inventory snapshot current minute is invalid."
|
|
return false
|
|
if not snapshot.get(&"inventories", null) is Array or not snapshot.get(&"reservations", null) is Array:
|
|
last_restore_error = "Inventory snapshot collections are malformed."
|
|
return false
|
|
|
|
var next_inventories: Dictionary = {}
|
|
var live_stack_ids: Dictionary = {}
|
|
for raw_inventory: Variant in snapshot[&"inventories"]:
|
|
if not raw_inventory is Dictionary:
|
|
last_restore_error = "Inventory entry must be a Dictionary."
|
|
return false
|
|
var owner_inventory_id := StringName(raw_inventory.get(&"owner_inventory_id", ""))
|
|
var raw_stacks: Variant = raw_inventory.get(&"stacks", null)
|
|
if owner_inventory_id == &"" or not raw_stacks is Array or next_inventories.has(owner_inventory_id):
|
|
last_restore_error = "Inventory owner entry is invalid or duplicated."
|
|
return false
|
|
var stacks: Dictionary = {}
|
|
for raw_stack: Variant in raw_stacks:
|
|
var stack := _parse_stack(raw_stack, owner_inventory_id)
|
|
if stack.is_empty():
|
|
last_restore_error = "Inventory stack entry is invalid."
|
|
return false
|
|
var stack_id: StringName = stack[&"stack_id"]
|
|
if live_stack_ids.has(stack_id):
|
|
last_restore_error = "Inventory stack id is duplicated."
|
|
return false
|
|
live_stack_ids[stack_id] = true
|
|
stacks[stack_id] = stack
|
|
next_inventories[owner_inventory_id] = stacks
|
|
|
|
var next_reservations: Dictionary = {}
|
|
var live_reservation_ids: Dictionary = {}
|
|
for raw_reservation: Variant in snapshot[&"reservations"]:
|
|
if not raw_reservation is Dictionary:
|
|
last_restore_error = "Reservation entry must be a Dictionary."
|
|
return false
|
|
if not _reservation_timestamps_are_valid(raw_reservation):
|
|
last_restore_error = "Reservation timestamps are invalid."
|
|
return false
|
|
var reservation := InventoryReservationType.from_snapshot(raw_reservation)
|
|
if reservation == null or next_reservations.has(reservation.reservation_id):
|
|
last_restore_error = "Reservation entry is invalid or duplicated."
|
|
return false
|
|
next_reservations[reservation.reservation_id] = reservation
|
|
live_reservation_ids[reservation.reservation_id] = true
|
|
|
|
var next_known_stack_ids: Dictionary = {}
|
|
var next_known_reservation_ids: Dictionary = {}
|
|
if version == SNAPSHOT_VERSION:
|
|
var raw_known_stack_ids: Variant = snapshot.get(&"known_stack_ids", null)
|
|
var raw_known_reservation_ids: Variant = snapshot.get(&"known_reservation_ids", null)
|
|
if not raw_known_stack_ids is Array or not raw_known_reservation_ids is Array:
|
|
last_restore_error = "Inventory known-id collections are malformed."
|
|
return false
|
|
for raw_id: Variant in raw_known_stack_ids:
|
|
var known_id := StringName(raw_id)
|
|
if known_id == &"" or next_known_stack_ids.has(known_id):
|
|
last_restore_error = "Known stack id is invalid or duplicated."
|
|
return false
|
|
next_known_stack_ids[known_id] = true
|
|
for raw_id: Variant in raw_known_reservation_ids:
|
|
var known_id := StringName(raw_id)
|
|
if known_id == &"" or next_known_reservation_ids.has(known_id):
|
|
last_restore_error = "Known reservation id is invalid or duplicated."
|
|
return false
|
|
next_known_reservation_ids[known_id] = true
|
|
else:
|
|
next_known_stack_ids = live_stack_ids.duplicate(true)
|
|
next_known_reservation_ids = live_reservation_ids.duplicate(true)
|
|
for stack_id: Variant in live_stack_ids:
|
|
if not next_known_stack_ids.has(stack_id):
|
|
last_restore_error = "Live stack id is missing from known-id history."
|
|
return false
|
|
for reservation_id: Variant in live_reservation_ids:
|
|
if not next_known_reservation_ids.has(reservation_id):
|
|
last_restore_error = "Live reservation id is missing from known-id history."
|
|
return false
|
|
|
|
var invariant_error := _validate_state(next_inventories, next_reservations)
|
|
if not invariant_error.is_empty():
|
|
last_restore_error = invariant_error
|
|
return false
|
|
|
|
var next_idempotency: Dictionary = {}
|
|
var raw_results: Variant = snapshot.get(&"idempotency_results", [])
|
|
if not raw_results is Array:
|
|
last_restore_error = "Idempotency result collection is malformed."
|
|
return false
|
|
for raw_result: Variant in raw_results:
|
|
if not raw_result is Dictionary or not raw_result.get(&"result", null) is Dictionary:
|
|
last_restore_error = "Idempotency result entry is malformed."
|
|
return false
|
|
var operation_id := StringName(raw_result.get(&"operation_id", ""))
|
|
var action := StringName(raw_result.get(&"action", ""))
|
|
var input_fingerprint := String(raw_result.get(&"input_fingerprint", ""))
|
|
var legacy_unbound := bool(raw_result.get(&"legacy_unbound", false))
|
|
if version == LEGACY_SNAPSHOT_VERSION:
|
|
var legacy_key := String(raw_result.get(&"operation_key", ""))
|
|
var separator := legacy_key.find("|")
|
|
if separator <= 0 or separator >= legacy_key.length() - 1:
|
|
last_restore_error = "Legacy idempotency result key is invalid."
|
|
return false
|
|
action = StringName(legacy_key.substr(0, separator))
|
|
operation_id = StringName(legacy_key.substr(separator + 1))
|
|
input_fingerprint = ""
|
|
legacy_unbound = true
|
|
if (
|
|
operation_id == &""
|
|
or action == &""
|
|
or (input_fingerprint.is_empty() and not legacy_unbound)
|
|
):
|
|
last_restore_error = "Idempotency result key or binding is invalid or duplicated."
|
|
return false
|
|
if next_idempotency.has(operation_id):
|
|
if version != LEGACY_SNAPSHOT_VERSION:
|
|
last_restore_error = "Idempotency operation id is duplicated."
|
|
return false
|
|
# V1 namespaced ids by action, so two otherwise-valid legacy records can
|
|
# share one operation id. Preserve both payloads but fail closed on any
|
|
# replay until the caller issues a fresh operation id under V2 semantics.
|
|
var prior_record: Dictionary = next_idempotency[operation_id]
|
|
var legacy_results: Array[Dictionary] = []
|
|
if prior_record[&"result"].has(&"legacy_ambiguous_results"):
|
|
legacy_results.assign(prior_record[&"result"][&"legacy_ambiguous_results"])
|
|
else:
|
|
legacy_results.append({
|
|
&"action": String(prior_record[&"action"]),
|
|
&"result": (prior_record[&"result"] as Dictionary).duplicate(true),
|
|
})
|
|
legacy_results.append({
|
|
&"action": String(action),
|
|
&"result": (raw_result[&"result"] as Dictionary).duplicate(true),
|
|
})
|
|
next_idempotency[operation_id] = {
|
|
&"action": &"__legacy_ambiguous__",
|
|
&"input_fingerprint": "",
|
|
&"legacy_unbound": true,
|
|
&"result": {&"legacy_ambiguous_results": legacy_results},
|
|
}
|
|
continue
|
|
next_idempotency[operation_id] = {
|
|
&"action": action,
|
|
&"input_fingerprint": input_fingerprint,
|
|
&"legacy_unbound": legacy_unbound,
|
|
&"result": (raw_result[&"result"] as Dictionary).duplicate(true),
|
|
}
|
|
|
|
var next_stack_sequence := int(snapshot.get(&"next_stack_sequence", 0))
|
|
var next_reservation_sequence := int(snapshot.get(&"next_reservation_sequence", 0))
|
|
if next_stack_sequence < 1 or next_reservation_sequence < 1:
|
|
last_restore_error = "Inventory snapshot sequence is invalid."
|
|
return false
|
|
if version == LEGACY_SNAPSHOT_VERSION:
|
|
# V1 only persisted the next counters. Reconstruct the consumed canonical
|
|
# ranges once so the V2 migration cannot later reuse a deleted legacy id.
|
|
for sequence: int in range(1, next_stack_sequence):
|
|
next_known_stack_ids[StringName("stack_%08d" % sequence)] = true
|
|
for sequence: int in range(1, next_reservation_sequence):
|
|
next_known_reservation_ids[StringName("reservation_%08d" % sequence)] = true
|
|
if (
|
|
next_stack_sequence <= _max_canonical_sequence(next_known_stack_ids.keys(), "stack_")
|
|
or next_reservation_sequence <= _max_canonical_sequence(next_known_reservation_ids.keys(), "reservation_")
|
|
):
|
|
last_restore_error = "Inventory snapshot sequence would reuse a known canonical id."
|
|
return false
|
|
|
|
_inventories = next_inventories
|
|
_rebuild_stack_location_index()
|
|
_reservations = next_reservations
|
|
_known_stack_ids = next_known_stack_ids
|
|
_known_reservation_ids = next_known_reservation_ids
|
|
_idempotency_results = next_idempotency
|
|
_next_stack_sequence = next_stack_sequence
|
|
_next_reservation_sequence = next_reservation_sequence
|
|
current_minute = next_current_minute
|
|
last_restore_error = ""
|
|
return true
|
|
|
|
|
|
func _validate_active_reservation(reservation: InventoryReservation, absolute_minute: int) -> Dictionary:
|
|
if reservation.is_expired(absolute_minute):
|
|
return _failure(&"RESERVATION_EXPIRED", "Reservation has expired.")
|
|
var location := _find_stack(reservation.stack_id)
|
|
if not bool(location[&"found"]):
|
|
return _failure(&"ORPHAN_RESERVATION", "Reserved stack is missing.")
|
|
var stack: Dictionary = location[&"stack"]
|
|
if location[&"owner_inventory_id"] != reservation.owner_inventory_id:
|
|
return _failure(&"ORPHAN_RESERVATION", "Reserved stack belongs to another inventory.")
|
|
if _stack_is_expired(stack, absolute_minute):
|
|
return _failure(&"STACK_EXPIRED", "Reserved stock has expired.")
|
|
if int(stack[&"quantity"]) < reservation.quantity or int(stack[&"reserved_quantity"]) < reservation.quantity:
|
|
return _failure(&"INVARIANT_VIOLATION", "Reserved stack no longer contains the reserved quantity.")
|
|
return {&"ok": true, &"location": location}
|
|
|
|
|
|
func _available_stacks(owner_inventory_id: StringName, item_id: StringName, absolute_minute: int) -> Array[Dictionary]:
|
|
var result: Array[Dictionary] = []
|
|
if not _inventories.has(owner_inventory_id):
|
|
return result
|
|
for stack: Dictionary in (_inventories[owner_inventory_id] as Dictionary).values():
|
|
if stack[&"item_id"] == item_id and not _stack_is_expired(stack, absolute_minute) and _available_in_stack(stack) > 0:
|
|
result.append(stack.duplicate(true))
|
|
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
|
var a_expiry := int(a[&"expires_at"])
|
|
var b_expiry := int(b[&"expires_at"])
|
|
var a_order := 9223372036854775807 if a_expiry < 0 else a_expiry
|
|
var b_order := 9223372036854775807 if b_expiry < 0 else b_expiry
|
|
if a_order != b_order:
|
|
return a_order < b_order
|
|
if int(a[&"quality"]) != int(b[&"quality"]):
|
|
return int(a[&"quality"]) > int(b[&"quality"])
|
|
return String(a[&"stack_id"]) < String(b[&"stack_id"])
|
|
)
|
|
return result
|
|
|
|
|
|
func _find_stack(stack_id: StringName) -> Dictionary:
|
|
if _stack_owner_by_id.has(stack_id):
|
|
var indexed_owner_id: StringName = _stack_owner_by_id[stack_id]
|
|
if _inventories.has(indexed_owner_id):
|
|
var indexed_stacks: Dictionary = _inventories[indexed_owner_id]
|
|
if indexed_stacks.has(stack_id):
|
|
return {
|
|
&"found": true,
|
|
&"owner_inventory_id": indexed_owner_id,
|
|
&"stack": (indexed_stacks[stack_id] as Dictionary).duplicate(true),
|
|
}
|
|
# A stale derived entry must never mask an externally corrupted state.
|
|
_stack_owner_by_id.erase(stack_id)
|
|
for owner_inventory_id: Variant in _inventories:
|
|
var stacks: Dictionary = _inventories[owner_inventory_id]
|
|
if stacks.has(stack_id):
|
|
_stack_owner_by_id[stack_id] = StringName(owner_inventory_id)
|
|
return {
|
|
&"found": true,
|
|
&"owner_inventory_id": StringName(owner_inventory_id),
|
|
&"stack": (stacks[stack_id] as Dictionary).duplicate(true),
|
|
}
|
|
return {&"found": false, &"owner_inventory_id": &"", &"stack": {}}
|
|
|
|
|
|
func _insert_stack(stack: Dictionary) -> void:
|
|
var owner_inventory_id: StringName = stack[&"owner_inventory_id"]
|
|
var stack_id: StringName = stack[&"stack_id"]
|
|
if not _inventories.has(owner_inventory_id):
|
|
_inventories[owner_inventory_id] = {}
|
|
var stacks: Dictionary = _inventories[owner_inventory_id]
|
|
stacks[stack_id] = stack.duplicate(true)
|
|
_inventories[owner_inventory_id] = stacks
|
|
_stack_owner_by_id[stack_id] = owner_inventory_id
|
|
_known_stack_ids[stack_id] = true
|
|
var canonical_sequence := _canonical_sequence(stack_id, "stack_")
|
|
if canonical_sequence >= _next_stack_sequence:
|
|
_next_stack_sequence = canonical_sequence + 1
|
|
|
|
|
|
func _set_stack(owner_inventory_id: StringName, stack: Dictionary) -> void:
|
|
if not _inventories.has(owner_inventory_id):
|
|
_inventories[owner_inventory_id] = {}
|
|
var stacks: Dictionary = _inventories[owner_inventory_id]
|
|
stacks[stack[&"stack_id"]] = stack.duplicate(true)
|
|
_inventories[owner_inventory_id] = stacks
|
|
_stack_owner_by_id[stack[&"stack_id"]] = owner_inventory_id
|
|
_known_stack_ids[stack[&"stack_id"]] = true
|
|
|
|
|
|
func _set_or_remove_stack(owner_inventory_id: StringName, stack: Dictionary) -> void:
|
|
if int(stack[&"quantity"]) > 0:
|
|
_set_stack(owner_inventory_id, stack)
|
|
return
|
|
if not _inventories.has(owner_inventory_id):
|
|
return
|
|
var stacks: Dictionary = _inventories[owner_inventory_id]
|
|
stacks.erase(stack[&"stack_id"])
|
|
_stack_owner_by_id.erase(stack[&"stack_id"])
|
|
if stacks.is_empty():
|
|
_inventories.erase(owner_inventory_id)
|
|
else:
|
|
_inventories[owner_inventory_id] = stacks
|
|
|
|
|
|
func _restore_standalone_capture(capture: Dictionary) -> void:
|
|
if _atomic_batch_depth == 0 and not capture.is_empty():
|
|
restore_snapshot(capture)
|
|
|
|
|
|
func _rebuild_stack_location_index() -> void:
|
|
_stack_owner_by_id.clear()
|
|
for owner_inventory_id: Variant in _inventories:
|
|
var stacks: Dictionary = _inventories[owner_inventory_id]
|
|
for stack_id: Variant in stacks:
|
|
_stack_owner_by_id[StringName(stack_id)] = StringName(owner_inventory_id)
|
|
|
|
|
|
func _next_stack_id() -> StringName:
|
|
while true:
|
|
var value := StringName("stack_%08d" % _next_stack_sequence)
|
|
_next_stack_sequence += 1
|
|
# Generated ids are fully represented by the maintained index. Falling
|
|
# back to an all-inventory diagnostic scan here made every destination
|
|
# insertion O(owner count) during large delivery waves.
|
|
if not _known_stack_ids.has(value):
|
|
_known_stack_ids[value] = true
|
|
return value
|
|
return &""
|
|
|
|
|
|
func _next_reservation_id() -> StringName:
|
|
while true:
|
|
var value := StringName("reservation_%08d" % _next_reservation_sequence)
|
|
_next_reservation_sequence += 1
|
|
if not _known_reservation_ids.has(value):
|
|
_known_reservation_ids[value] = true
|
|
return value
|
|
return &""
|
|
|
|
|
|
func _get_replay(
|
|
action: StringName,
|
|
operation_id: StringName,
|
|
input_fingerprint: String,
|
|
bind_legacy: bool = true
|
|
) -> Dictionary:
|
|
if operation_id == &"":
|
|
return {}
|
|
if not _idempotency_results.has(operation_id):
|
|
return {}
|
|
var record: Dictionary = _idempotency_results[operation_id]
|
|
if (
|
|
StringName(record[&"action"]) != action
|
|
or (
|
|
not bool(record.get(&"legacy_unbound", false))
|
|
and String(record[&"input_fingerprint"]) != input_fingerprint
|
|
)
|
|
):
|
|
return _failure(
|
|
&"IDEMPOTENCY_CONFLICT",
|
|
"Operation id is already bound to a different action or normalized input.",
|
|
{&"operation_id": operation_id}
|
|
)
|
|
if bool(record.get(&"legacy_unbound", false)) and bind_legacy:
|
|
# Version-1 snapshots did not retain request fingerprints. Preserve their
|
|
# first valid replay, then permanently bind that id to the observed input.
|
|
record[&"input_fingerprint"] = input_fingerprint
|
|
record[&"legacy_unbound"] = false
|
|
_idempotency_results[operation_id] = record
|
|
var result: Dictionary = (record[&"result"] as Dictionary).duplicate(true)
|
|
result[&"replay"] = true
|
|
return result
|
|
|
|
|
|
func _remember(
|
|
action: StringName,
|
|
operation_id: StringName,
|
|
result: Dictionary,
|
|
input_fingerprint: String
|
|
) -> Dictionary:
|
|
if operation_id != &"":
|
|
if _idempotency_results.has(operation_id):
|
|
return _get_replay(action, operation_id, input_fingerprint)
|
|
_idempotency_results[operation_id] = {
|
|
&"action": action,
|
|
&"input_fingerprint": input_fingerprint,
|
|
&"legacy_unbound": false,
|
|
&"result": result.duplicate(true),
|
|
}
|
|
return result
|
|
|
|
|
|
func _remember_failure(
|
|
action: StringName,
|
|
operation_id: StringName,
|
|
input_fingerprint: String,
|
|
code: StringName,
|
|
message: String,
|
|
details: Dictionary = {}
|
|
) -> Dictionary:
|
|
return _remember(action, operation_id, _failure(code, message, details), input_fingerprint)
|
|
|
|
|
|
func _preflight_available_batch(transfers: Array[Dictionary], minute: int) -> Dictionary:
|
|
var seen_operation_ids: Dictionary = {}
|
|
for index: int in range(transfers.size()):
|
|
var entry: Dictionary = transfers[index]
|
|
var operation_id := StringName(entry.get(&"operation_id", entry.get("operation_id", "")))
|
|
var fingerprint := _transfer_fingerprint(
|
|
StringName(entry.get(&"source_inventory_id", entry.get("source_inventory_id", ""))),
|
|
StringName(entry.get(&"destination_inventory_id", entry.get("destination_inventory_id", ""))),
|
|
StringName(entry.get(&"item_id", entry.get("item_id", ""))),
|
|
int(entry.get(&"quantity", entry.get("quantity", 0))),
|
|
minute
|
|
)
|
|
var validation := _preflight_operation_id(
|
|
&"transfer", operation_id, fingerprint, seen_operation_ids, index
|
|
)
|
|
if not bool(validation[&"ok"]):
|
|
return validation
|
|
return {&"ok": true}
|
|
|
|
|
|
func _preflight_reserved_batch(transfers: Array[Dictionary], minute: int) -> Dictionary:
|
|
var seen_operation_ids: Dictionary = {}
|
|
for index: int in range(transfers.size()):
|
|
var entry: Dictionary = transfers[index]
|
|
var reservation_ids: Array[StringName] = []
|
|
var raw_ids: Variant = entry.get(&"reservation_ids", entry.get("reservation_ids", []))
|
|
if raw_ids is Array:
|
|
for raw_id: Variant in raw_ids:
|
|
reservation_ids.append(StringName(raw_id))
|
|
var operation_id := StringName(entry.get(&"operation_id", entry.get("operation_id", "")))
|
|
var fingerprint := _reserved_transfer_fingerprint(
|
|
_sorted_names(reservation_ids),
|
|
StringName(entry.get(&"destination_inventory_id", entry.get("destination_inventory_id", ""))),
|
|
minute
|
|
)
|
|
var validation := _preflight_operation_id(
|
|
&"transfer_reserved", operation_id, fingerprint, seen_operation_ids, index
|
|
)
|
|
if not bool(validation[&"ok"]):
|
|
return validation
|
|
return {&"ok": true}
|
|
|
|
|
|
func _preflight_operation_id(
|
|
action: StringName,
|
|
operation_id: StringName,
|
|
input_fingerprint: String,
|
|
seen_operation_ids: Dictionary,
|
|
index: int
|
|
) -> Dictionary:
|
|
if operation_id == &"":
|
|
return {&"ok": true}
|
|
if seen_operation_ids.has(operation_id):
|
|
return _failure(
|
|
&"IDEMPOTENCY_CONFLICT",
|
|
"A non-empty operation id may occur only once in an atomic batch.",
|
|
{&"operation_id": operation_id, &"failed_index": index}
|
|
)
|
|
seen_operation_ids[operation_id] = true
|
|
var existing := _get_replay(action, operation_id, input_fingerprint, false)
|
|
if (
|
|
not existing.is_empty()
|
|
and not bool(existing.get(&"ok", false))
|
|
and existing.get(&"code", &"") == &"IDEMPOTENCY_CONFLICT"
|
|
):
|
|
existing[&"failed_index"] = index
|
|
return existing
|
|
return {&"ok": true}
|
|
|
|
|
|
static func _transfer_fingerprint(
|
|
source_inventory_id: StringName,
|
|
destination_inventory_id: StringName,
|
|
item_id: StringName,
|
|
quantity: int,
|
|
minute: int
|
|
) -> String:
|
|
return _canonical_fingerprint([
|
|
source_inventory_id,
|
|
destination_inventory_id,
|
|
item_id,
|
|
quantity,
|
|
minute,
|
|
])
|
|
|
|
|
|
static func _reserved_transfer_fingerprint(
|
|
reservation_ids: Array[StringName],
|
|
destination_inventory_id: StringName,
|
|
minute: int
|
|
) -> String:
|
|
return _canonical_fingerprint([reservation_ids, destination_inventory_id, minute])
|
|
|
|
|
|
static func _canonical_fingerprint(values: Array) -> String:
|
|
var encoded := ""
|
|
for value: Variant in values:
|
|
var token := _canonical_fingerprint_token(value)
|
|
encoded += "%d:%s" % [token.length(), token]
|
|
return encoded
|
|
|
|
|
|
static func _canonical_fingerprint_token(value: Variant) -> String:
|
|
if value is Array:
|
|
var items := ""
|
|
for item: Variant in value:
|
|
var token := _canonical_fingerprint_token(item)
|
|
items += "%d:%s" % [token.length(), token]
|
|
return "a%d:%s" % [value.size(), items]
|
|
if value is int:
|
|
return "i:%d" % value
|
|
var text := String(value)
|
|
return "s%d:%s" % [text.length(), text]
|
|
|
|
|
|
static func _success(details: Dictionary = {}) -> Dictionary:
|
|
var result := {&"ok": true, &"code": &"OK", &"replay": false}
|
|
result.merge(details, true)
|
|
return result
|
|
|
|
|
|
static func _failure(code: StringName, message: String, details: Dictionary = {}) -> Dictionary:
|
|
var result := {
|
|
&"ok": false,
|
|
&"code": code,
|
|
&"message": message,
|
|
&"replay": false,
|
|
}
|
|
result.merge(details, true)
|
|
return result
|
|
|
|
|
|
static func _make_stack(
|
|
stack_id: StringName,
|
|
owner_inventory_id: StringName,
|
|
item_id: StringName,
|
|
quantity: int,
|
|
quality: int,
|
|
expires_at: int,
|
|
created_at: int,
|
|
unit_weight: int
|
|
) -> Dictionary:
|
|
return {
|
|
&"stack_id": stack_id,
|
|
&"owner_inventory_id": owner_inventory_id,
|
|
&"item_id": item_id,
|
|
&"quality": quality,
|
|
&"quantity": quantity,
|
|
&"reserved_quantity": 0,
|
|
&"created_at": maxi(created_at, 0),
|
|
&"expires_at": expires_at,
|
|
&"unit_weight": unit_weight,
|
|
}
|
|
|
|
|
|
static func _serialize_stack(stack: Dictionary) -> Dictionary:
|
|
var serialized := stack.duplicate(true)
|
|
serialized[&"stack_id"] = String(stack[&"stack_id"])
|
|
serialized[&"owner_inventory_id"] = String(stack[&"owner_inventory_id"])
|
|
serialized[&"item_id"] = String(stack[&"item_id"])
|
|
return serialized
|
|
|
|
|
|
static func _parse_stack(raw_stack: Variant, expected_owner_id: StringName) -> Dictionary:
|
|
if not raw_stack is Dictionary:
|
|
return {}
|
|
var stack_id := StringName(raw_stack.get(&"stack_id", ""))
|
|
var owner_inventory_id := StringName(raw_stack.get(&"owner_inventory_id", ""))
|
|
var item_id := StringName(raw_stack.get(&"item_id", ""))
|
|
var quantity := int(raw_stack.get(&"quantity", -1))
|
|
var reserved_quantity := int(raw_stack.get(&"reserved_quantity", -1))
|
|
var unit_weight := int(raw_stack.get(&"unit_weight", 0))
|
|
var created_at := int(raw_stack.get(&"created_at", -1))
|
|
var expires_at := int(raw_stack.get(&"expires_at", -2))
|
|
if (
|
|
stack_id == &""
|
|
or owner_inventory_id == &""
|
|
or owner_inventory_id != expected_owner_id
|
|
or item_id == &""
|
|
or quantity <= 0
|
|
or reserved_quantity < 0
|
|
or reserved_quantity > quantity
|
|
or unit_weight <= 0
|
|
or created_at < 0
|
|
or expires_at < -1
|
|
):
|
|
return {}
|
|
return {
|
|
&"stack_id": stack_id,
|
|
&"owner_inventory_id": owner_inventory_id,
|
|
&"item_id": item_id,
|
|
&"quality": int(raw_stack.get(&"quality", DEFAULT_QUALITY)),
|
|
&"quantity": quantity,
|
|
&"reserved_quantity": reserved_quantity,
|
|
&"created_at": created_at,
|
|
&"expires_at": expires_at,
|
|
&"unit_weight": unit_weight,
|
|
}
|
|
|
|
|
|
static func _validate_state(inventories: Dictionary, reservations: Dictionary) -> String:
|
|
var stack_locations: Dictionary = {}
|
|
for owner_inventory_id: Variant in inventories:
|
|
var stacks: Dictionary = inventories[owner_inventory_id]
|
|
for stack_id: Variant in stacks:
|
|
var stack: Dictionary = stacks[stack_id]
|
|
if stack_locations.has(stack_id):
|
|
return "Stack id is duplicated across inventories."
|
|
if int(stack[&"quantity"]) <= 0:
|
|
return "Empty stack remained in inventory state."
|
|
if int(stack[&"reserved_quantity"]) < 0 or int(stack[&"reserved_quantity"]) > int(stack[&"quantity"]):
|
|
return "Stack available quantity invariant is violated."
|
|
stack_locations[stack_id] = {
|
|
&"owner_inventory_id": StringName(owner_inventory_id),
|
|
&"stack": stack,
|
|
}
|
|
var expected_reserved: Dictionary = {}
|
|
for reservation_id: Variant in reservations:
|
|
var reservation: InventoryReservation = reservations[reservation_id]
|
|
if not reservation.is_active():
|
|
continue
|
|
if not stack_locations.has(reservation.stack_id):
|
|
return "Active reservation refers to a missing stack."
|
|
var location: Dictionary = stack_locations[reservation.stack_id]
|
|
if location[&"owner_inventory_id"] != reservation.owner_inventory_id:
|
|
return "Active reservation owner does not match its stack."
|
|
var stack: Dictionary = location[&"stack"]
|
|
if stack[&"item_id"] != reservation.item_id:
|
|
return "Active reservation item does not match its stack."
|
|
expected_reserved[reservation.stack_id] = int(expected_reserved.get(reservation.stack_id, 0)) + reservation.quantity
|
|
for stack_id: Variant in stack_locations:
|
|
var stack: Dictionary = stack_locations[stack_id][&"stack"]
|
|
if int(stack[&"reserved_quantity"]) != int(expected_reserved.get(stack_id, 0)):
|
|
return "Stack reserved quantity does not match active reservations."
|
|
return ""
|
|
|
|
|
|
static func _available_in_stack(stack: Dictionary) -> int:
|
|
return int(stack[&"quantity"]) - int(stack[&"reserved_quantity"])
|
|
|
|
|
|
static func _stack_is_expired(stack: Dictionary, absolute_minute: int) -> bool:
|
|
var expires_at := int(stack[&"expires_at"])
|
|
return expires_at >= 0 and absolute_minute >= expires_at
|
|
|
|
|
|
static func _unique_sorted_names(values: Array[StringName]) -> Array[StringName]:
|
|
var seen: Dictionary = {}
|
|
var result: Array[StringName] = []
|
|
for value: StringName in values:
|
|
if value == &"" or seen.has(value):
|
|
continue
|
|
seen[value] = true
|
|
result.append(value)
|
|
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
|
return result
|
|
|
|
|
|
static func _sorted_names(values: Variant) -> Array[StringName]:
|
|
var result: Array[StringName] = []
|
|
for value: Variant in values:
|
|
result.append(StringName(value))
|
|
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
|
return result
|
|
|
|
|
|
static func _string_array(values: Array[StringName]) -> Array[String]:
|
|
var result: Array[String] = []
|
|
for value: StringName in values:
|
|
result.append(String(value))
|
|
return result
|
|
|
|
|
|
static func _max_canonical_sequence(values: Variant, prefix: String) -> int:
|
|
var maximum := 0
|
|
for value: Variant in values:
|
|
var sequence := _canonical_sequence(value, prefix)
|
|
if sequence > maximum:
|
|
maximum = sequence
|
|
return maximum
|
|
|
|
|
|
static func _canonical_sequence(value: Variant, prefix: String) -> int:
|
|
var text := String(value)
|
|
if not text.begins_with(prefix):
|
|
return 0
|
|
var suffix := text.substr(prefix.length())
|
|
if suffix.is_empty() or not suffix.is_valid_int():
|
|
return 0
|
|
return maxi(int(suffix), 0)
|
|
|
|
|
|
static func _reservation_timestamps_are_valid(snapshot: Dictionary) -> bool:
|
|
var created_at := int(snapshot.get(&"created_at", -1))
|
|
var expires_at := int(snapshot.get(&"expires_at", -2))
|
|
var terminal_at := int(snapshot.get(&"terminal_at", -2))
|
|
var state_name := String(snapshot.get(&"state", ""))
|
|
if created_at < 0 or expires_at < -1 or terminal_at < -1:
|
|
return false
|
|
if state_name == "ACTIVE":
|
|
return terminal_at == -1
|
|
return state_name in ["CONSUMED", "RELEASED", "EXPIRED"] and terminal_at >= 0
|
|
|
|
|
|
static func _sum_lines(lines: Array[Dictionary]) -> int:
|
|
var total := 0
|
|
for line: Dictionary in lines:
|
|
total += int(line.get(&"quantity", 0))
|
|
return total
|