|
@@ -18,6 +18,8 @@ from cyber_agent.application.candidate import (
|
|
|
CandidateRepository,
|
|
CandidateRepository,
|
|
|
CandidateReviewAction,
|
|
CandidateReviewAction,
|
|
|
CandidateVersionRequest,
|
|
CandidateVersionRequest,
|
|
|
|
|
+ MAX_CANDIDATES_PER_ROOT,
|
|
|
|
|
+ MAX_CANDIDATE_OPERATIONS_PER_ROOT,
|
|
|
)
|
|
)
|
|
|
from cyber_agent.application.models import canonical_json
|
|
from cyber_agent.application.models import canonical_json
|
|
|
from cyber_agent.application.quality import (
|
|
from cyber_agent.application.quality import (
|
|
@@ -121,7 +123,20 @@ class CandidateService:
|
|
|
raise CandidateStateError(
|
|
raise CandidateStateError(
|
|
|
existing_operation.error or "Candidate operation failed"
|
|
existing_operation.error or "Candidate operation failed"
|
|
|
)
|
|
)
|
|
|
|
|
+ if self._reserved_candidate_slots(ledger) > MAX_CANDIDATES_PER_ROOT:
|
|
|
|
|
+ raise CandidateStateError(
|
|
|
|
|
+ "Candidate ledger candidate reservations are corrupt"
|
|
|
|
|
+ )
|
|
|
else:
|
|
else:
|
|
|
|
|
+ # Every supported version command publishes exactly one new
|
|
|
|
|
+ # immutable CandidateRef. Reject a full aggregate before we
|
|
|
|
|
+ # persist an intent or call the application Repository; doing
|
|
|
|
|
+ # this only in CandidateLedger validation would leave an
|
|
|
|
|
+ # external orphan after the Repository has committed.
|
|
|
|
|
+ if self._reserved_candidate_slots(ledger) >= MAX_CANDIDATES_PER_ROOT:
|
|
|
|
|
+ raise CandidateStateError(
|
|
|
|
|
+ "Candidate ledger candidate limit exceeded before execution"
|
|
|
|
|
+ )
|
|
|
try:
|
|
try:
|
|
|
pointer = self._allocate_pointer(
|
|
pointer = self._allocate_pointer(
|
|
|
operation,
|
|
operation,
|
|
@@ -142,10 +157,7 @@ class CandidateService:
|
|
|
ledger = ledger.model_copy(update={
|
|
ledger = ledger.model_copy(update={
|
|
|
"operations": (*ledger.operations, operation_record),
|
|
"operations": (*ledger.operations, operation_record),
|
|
|
})
|
|
})
|
|
|
- await self.store.replace_candidate_ledger(
|
|
|
|
|
- root_trace_id,
|
|
|
|
|
- ledger.model_dump(mode="json"),
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ await self._persist_ledger(root_trace_id, ledger)
|
|
|
existing_operation = operation_record
|
|
existing_operation = operation_record
|
|
|
|
|
|
|
|
assert existing_operation.candidate is not None
|
|
assert existing_operation.candidate is not None
|
|
@@ -201,10 +213,7 @@ class CandidateService:
|
|
|
)
|
|
)
|
|
|
except Exception as exc:
|
|
except Exception as exc:
|
|
|
failed = self._fail_operation(ledger, operation_id, str(exc))
|
|
failed = self._fail_operation(ledger, operation_id, str(exc))
|
|
|
- await self.store.replace_candidate_ledger(
|
|
|
|
|
- root_trace_id,
|
|
|
|
|
- failed.model_dump(mode="json"),
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ await self._persist_ledger(root_trace_id, failed)
|
|
|
raise
|
|
raise
|
|
|
completed = self._complete_operation(
|
|
completed = self._complete_operation(
|
|
|
ledger,
|
|
ledger,
|
|
@@ -214,10 +223,7 @@ class CandidateService:
|
|
|
# If this atomic publish fails after the Repository committed, the
|
|
# If this atomic publish fails after the Repository committed, the
|
|
|
# pending operation remains recoverable. Re-execution uses the same
|
|
# pending operation remains recoverable. Re-execution uses the same
|
|
|
# operation_id and the Repository's idempotency contract.
|
|
# operation_id and the Repository's idempotency contract.
|
|
|
- await self.store.replace_candidate_ledger(
|
|
|
|
|
- root_trace_id,
|
|
|
|
|
- completed.model_dump(mode="json"),
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ await self._persist_ledger(root_trace_id, completed)
|
|
|
await self._emit_candidate_registered(candidate_ref, operation_id)
|
|
await self._emit_candidate_registered(candidate_ref, operation_id)
|
|
|
return candidate_ref
|
|
return candidate_ref
|
|
|
|
|
|
|
@@ -343,10 +349,7 @@ class CandidateService:
|
|
|
checkpoint.model_dump(mode="json"),
|
|
checkpoint.model_dump(mode="json"),
|
|
|
),
|
|
),
|
|
|
})
|
|
})
|
|
|
- await self.store.replace_candidate_ledger(
|
|
|
|
|
- root.trace_id,
|
|
|
|
|
- updated.model_dump(mode="json"),
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ await self._persist_ledger(root.trace_id, updated)
|
|
|
return checkpoint
|
|
return checkpoint
|
|
|
|
|
|
|
|
async def update_validation_checkpoint(
|
|
async def update_validation_checkpoint(
|
|
@@ -398,10 +401,7 @@ class CandidateService:
|
|
|
updated = ledger.model_copy(update={
|
|
updated = ledger.model_copy(update={
|
|
|
"validation_checkpoints": tuple(checkpoints),
|
|
"validation_checkpoints": tuple(checkpoints),
|
|
|
})
|
|
})
|
|
|
- await self.store.replace_candidate_ledger(
|
|
|
|
|
- root.trace_id,
|
|
|
|
|
- updated.model_dump(mode="json"),
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ await self._persist_ledger(root.trace_id, updated)
|
|
|
return result
|
|
return result
|
|
|
|
|
|
|
|
async def record_validation(
|
|
async def record_validation(
|
|
@@ -450,10 +450,7 @@ class CandidateService:
|
|
|
updated = updated.model_copy(update={
|
|
updated = updated.model_copy(update={
|
|
|
"validation_checkpoints": tuple(checkpoints),
|
|
"validation_checkpoints": tuple(checkpoints),
|
|
|
})
|
|
})
|
|
|
- await self.store.replace_candidate_ledger(
|
|
|
|
|
- root.trace_id,
|
|
|
|
|
- updated.model_dump(mode="json"),
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ await self._persist_ledger(root.trace_id, updated)
|
|
|
if self.event_service is not None:
|
|
if self.event_service is not None:
|
|
|
await self.event_service.emit_after_commit(
|
|
await self.event_service.emit_after_commit(
|
|
|
source_trace_id=record.candidate_ref.owner_trace_id,
|
|
source_trace_id=record.candidate_ref.owner_trace_id,
|
|
@@ -531,6 +528,28 @@ class CandidateService:
|
|
|
"Candidate review batch payload changed during recovery"
|
|
"Candidate review batch payload changed during recovery"
|
|
|
)
|
|
)
|
|
|
else:
|
|
else:
|
|
|
|
|
+ existing_operation_ids = {
|
|
|
|
|
+ item.operation_id for item in ledger.operations
|
|
|
|
|
+ }
|
|
|
|
|
+ new_action_operations = sum(
|
|
|
|
|
+ self._review_operation_id(
|
|
|
|
|
+ parent_trace_id,
|
|
|
|
|
+ child_trace_id,
|
|
|
|
|
+ action,
|
|
|
|
|
+ command_id=command_id,
|
|
|
|
|
+ effective_at_sequence=effective_at_sequence,
|
|
|
|
|
+ action_index=action_index,
|
|
|
|
|
+ ) not in existing_operation_ids
|
|
|
|
|
+ for action_index, action in enumerate(actions)
|
|
|
|
|
+ )
|
|
|
|
|
+ required_slots = 1 + new_action_operations
|
|
|
|
|
+ if (
|
|
|
|
|
+ len(ledger.operations) + required_slots
|
|
|
|
|
+ > MAX_CANDIDATE_OPERATIONS_PER_ROOT
|
|
|
|
|
+ ):
|
|
|
|
|
+ raise CandidateStateError(
|
|
|
|
|
+ "Candidate review operation limit exceeded before execution"
|
|
|
|
|
+ )
|
|
|
batch_operation = CandidateOperationRecord(
|
|
batch_operation = CandidateOperationRecord(
|
|
|
operation_id=batch_operation_id,
|
|
operation_id=batch_operation_id,
|
|
|
command_id=command_id,
|
|
command_id=command_id,
|
|
@@ -543,10 +562,7 @@ class CandidateService:
|
|
|
# Persist the complete ordered batch envelope before validating
|
|
# Persist the complete ordered batch envelope before validating
|
|
|
# or executing any individual action. A crash/replay can then
|
|
# or executing any individual action. A crash/replay can then
|
|
|
# neither append nor remove tail actions under the same call.
|
|
# neither append nor remove tail actions under the same call.
|
|
|
- await self.store.replace_candidate_ledger(
|
|
|
|
|
- root.trace_id,
|
|
|
|
|
- ledger.model_dump(mode="json"),
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ await self._persist_ledger(root.trace_id, ledger)
|
|
|
validation_by_key: dict[tuple[str, int], CandidateValidationRecord] = {}
|
|
validation_by_key: dict[tuple[str, int], CandidateValidationRecord] = {}
|
|
|
for raw in ledger.validations:
|
|
for raw in ledger.validations:
|
|
|
record = CandidateValidationRecord.model_validate(raw)
|
|
record = CandidateValidationRecord.model_validate(raw)
|
|
@@ -625,10 +641,7 @@ class CandidateService:
|
|
|
for item in ledger.operations
|
|
for item in ledger.operations
|
|
|
)
|
|
)
|
|
|
ledger = ledger.model_copy(update={"operations": operations})
|
|
ledger = ledger.model_copy(update={"operations": operations})
|
|
|
- await self.store.replace_candidate_ledger(
|
|
|
|
|
- root.trace_id,
|
|
|
|
|
- ledger.model_dump(mode="json"),
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ await self._persist_ledger(root.trace_id, ledger)
|
|
|
return applied
|
|
return applied
|
|
|
|
|
|
|
|
async def _apply_review_action(
|
|
async def _apply_review_action(
|
|
@@ -698,10 +711,7 @@ class CandidateService:
|
|
|
),
|
|
),
|
|
|
)
|
|
)
|
|
|
ledger = self._finish_review_operation(ledger, operation_id, record)
|
|
ledger = self._finish_review_operation(ledger, operation_id, record)
|
|
|
- await self.store.replace_candidate_ledger(
|
|
|
|
|
- root_trace_id,
|
|
|
|
|
- ledger.model_dump(mode="json"),
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ await self._persist_ledger(root_trace_id, ledger)
|
|
|
await self._emit_lifecycle(record, root_trace_id)
|
|
await self._emit_lifecycle(record, root_trace_id)
|
|
|
return ledger, record
|
|
return ledger, record
|
|
|
|
|
|
|
@@ -717,10 +727,7 @@ class CandidateService:
|
|
|
ledger = ledger.model_copy(update={
|
|
ledger = ledger.model_copy(update={
|
|
|
"lifecycle": (*ledger.lifecycle, pending),
|
|
"lifecycle": (*ledger.lifecycle, pending),
|
|
|
})
|
|
})
|
|
|
- await self.store.replace_candidate_ledger(
|
|
|
|
|
- root_trace_id,
|
|
|
|
|
- ledger.model_dump(mode="json"),
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ await self._persist_ledger(root_trace_id, ledger)
|
|
|
await self._emit_lifecycle(pending, root_trace_id)
|
|
await self._emit_lifecycle(pending, root_trace_id)
|
|
|
try:
|
|
try:
|
|
|
receipt = CandidateAdoptionReceipt.model_validate(
|
|
receipt = CandidateAdoptionReceipt.model_validate(
|
|
@@ -746,10 +753,7 @@ class CandidateService:
|
|
|
failed,
|
|
failed,
|
|
|
error=str(exc),
|
|
error=str(exc),
|
|
|
)
|
|
)
|
|
|
- await self.store.replace_candidate_ledger(
|
|
|
|
|
- root_trace_id,
|
|
|
|
|
- ledger.model_dump(mode="json"),
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ await self._persist_ledger(root_trace_id, ledger)
|
|
|
await self._emit_lifecycle(failed, root_trace_id)
|
|
await self._emit_lifecycle(failed, root_trace_id)
|
|
|
raise
|
|
raise
|
|
|
adopted = CandidateLifecycleRecord(
|
|
adopted = CandidateLifecycleRecord(
|
|
@@ -764,10 +768,7 @@ class CandidateService:
|
|
|
ledger = self._finish_review_operation(ledger, operation_id, adopted)
|
|
ledger = self._finish_review_operation(ledger, operation_id, adopted)
|
|
|
# A failure here intentionally leaves the durable pending intent. The
|
|
# A failure here intentionally leaves the durable pending intent. The
|
|
|
# Repository sees the same operation_id when the review is replayed.
|
|
# Repository sees the same operation_id when the review is replayed.
|
|
|
- await self.store.replace_candidate_ledger(
|
|
|
|
|
- root_trace_id,
|
|
|
|
|
- ledger.model_dump(mode="json"),
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ await self._persist_ledger(root_trace_id, ledger)
|
|
|
await self._emit_lifecycle(adopted, root_trace_id)
|
|
await self._emit_lifecycle(adopted, root_trace_id)
|
|
|
return ledger, adopted
|
|
return ledger, adopted
|
|
|
|
|
|
|
@@ -1062,6 +1063,20 @@ class CandidateService:
|
|
|
raw = await self.store.get_candidate_ledger(root_trace_id)
|
|
raw = await self.store.get_candidate_ledger(root_trace_id)
|
|
|
return CandidateLedger.model_validate(raw or {})
|
|
return CandidateLedger.model_validate(raw or {})
|
|
|
|
|
|
|
|
|
|
+ async def _persist_ledger(
|
|
|
|
|
+ self,
|
|
|
|
|
+ root_trace_id: str,
|
|
|
|
|
+ ledger: CandidateLedger,
|
|
|
|
|
+ ) -> None:
|
|
|
|
|
+ """Validate the complete immutable aggregate before atomic replacement."""
|
|
|
|
|
+ validated = CandidateLedger.model_validate(
|
|
|
|
|
+ ledger.model_dump(mode="json")
|
|
|
|
|
+ )
|
|
|
|
|
+ await self.store.replace_candidate_ledger(
|
|
|
|
|
+ root_trace_id,
|
|
|
|
|
+ validated.model_dump(mode="json"),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
def _allocate_pointer(
|
|
def _allocate_pointer(
|
|
|
self,
|
|
self,
|
|
|
operation: str,
|
|
operation: str,
|
|
@@ -1095,6 +1110,21 @@ class CandidateService:
|
|
|
return CandidatePointer(candidate_id=f"cand_{digest[:24]}", revision=1)
|
|
return CandidatePointer(candidate_id=f"cand_{digest[:24]}", revision=1)
|
|
|
raise CandidateStateError(f"Unsupported candidate operation: {operation}")
|
|
raise CandidateStateError(f"Unsupported candidate operation: {operation}")
|
|
|
|
|
|
|
|
|
|
+ @staticmethod
|
|
|
|
|
+ def _reserved_candidate_slots(ledger: CandidateLedger) -> int:
|
|
|
|
|
+ """Count published revisions plus recoverable version intents.
|
|
|
|
|
+
|
|
|
|
|
+ A pending create/fork/merge owns its eventual aggregate slot. Counting
|
|
|
|
|
+ the reservation prevents a later command from consuming the final slot
|
|
|
|
|
+ and making the earlier Repository side effect impossible to finalize.
|
|
|
|
|
+ """
|
|
|
|
|
+ pending_versions = sum(
|
|
|
|
|
+ item.status == "pending"
|
|
|
|
|
+ and item.operation in {"create", "fork", "merge"}
|
|
|
|
|
+ for item in ledger.operations
|
|
|
|
|
+ )
|
|
|
|
|
+ return len(ledger.candidates) + pending_versions
|
|
|
|
|
+
|
|
|
def _validate_parents(self, trace, root, parents: list[CandidateRef]) -> None:
|
|
def _validate_parents(self, trace, root, parents: list[CandidateRef]) -> None:
|
|
|
for parent in parents:
|
|
for parent in parents:
|
|
|
self._validate_candidate_owner(parent, trace, root)
|
|
self._validate_candidate_owner(parent, trace, root)
|