|
@@ -110,6 +110,15 @@ def _canonical_hash(value: Any) -> str:
|
|
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _bounded_object(
|
|
|
|
|
+ value: Any, label: str, *, maximum_bytes: int = 131_072
|
|
|
|
|
+) -> dict[str, Any]:
|
|
|
|
|
+ normalized = copy.deepcopy(_object(value, label))
|
|
|
|
|
+ if len(_json(normalized).encode("utf-8")) > maximum_bytes:
|
|
|
|
|
+ raise ValueError(f"{label} is too large")
|
|
|
|
|
+ return normalized
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
class DataRuleRepository:
|
|
class DataRuleRepository:
|
|
|
"""Persist versions and enforce all lifecycle transitions server-side."""
|
|
"""Persist versions and enforce all lifecycle transitions server-side."""
|
|
|
|
|
|
|
@@ -195,6 +204,8 @@ class DataRuleRepository:
|
|
|
receipt: dict[str, Any],
|
|
receipt: dict[str, Any],
|
|
|
*,
|
|
*,
|
|
|
actor_uid: str,
|
|
actor_uid: str,
|
|
|
|
|
+ create_request: dict[str, Any],
|
|
|
|
|
+ create_intent: dict[str, Any],
|
|
|
lease_seconds: int = 60,
|
|
lease_seconds: int = 60,
|
|
|
) -> dict[str, Any]:
|
|
) -> dict[str, Any]:
|
|
|
"""Atomically claim a create attempt or replay its committed result."""
|
|
"""Atomically claim a create attempt or replay its committed result."""
|
|
@@ -206,6 +217,15 @@ class DataRuleRepository:
|
|
|
):
|
|
):
|
|
|
raise ValueError("dataflow create lease is invalid")
|
|
raise ValueError("dataflow create lease is invalid")
|
|
|
values = self._draft_receipt(receipt, actor_uid=actor_uid)
|
|
values = self._draft_receipt(receipt, actor_uid=actor_uid)
|
|
|
|
|
+ request_value = _bounded_object(
|
|
|
|
|
+ create_request, "dataflow create request"
|
|
|
|
|
+ )
|
|
|
|
|
+ intent_value = _bounded_object(create_intent, "dataflow create intent")
|
|
|
|
|
+ if request_value.get("intent") != intent_value:
|
|
|
|
|
+ raise ValueError("dataflow create request intent is invalid")
|
|
|
|
|
+ if intent_value.get("dataflow_uid") != values["dataflow_uid"]:
|
|
|
|
|
+ raise ValueError("dataflow create intent UID does not match receipt")
|
|
|
|
|
+ request_digest = _canonical_hash(request_value)
|
|
|
lease_token = new_governance_uid()
|
|
lease_token = new_governance_uid()
|
|
|
claimed = (
|
|
claimed = (
|
|
|
self.session.execute(
|
|
self.session.execute(
|
|
@@ -215,6 +235,11 @@ class DataRuleRepository:
|
|
|
"lease_expires_at = CURRENT_TIMESTAMP + "
|
|
"lease_expires_at = CURRENT_TIMESTAMP + "
|
|
|
"(:lease_seconds * INTERVAL '1 second'), "
|
|
"(:lease_seconds * INTERVAL '1 second'), "
|
|
|
"attempt_count = attempt_count + 1, "
|
|
"attempt_count = attempt_count + 1, "
|
|
|
|
|
+ "create_request = COALESCE("
|
|
|
|
|
+ "create_request, CAST(:create_request AS jsonb)), "
|
|
|
|
|
+ "create_intent = COALESCE("
|
|
|
|
|
+ "create_intent, CAST(:create_intent AS jsonb)), "
|
|
|
|
|
+ "request_digest = COALESCE(request_digest, :request_digest), "
|
|
|
"create_started_at = COALESCE("
|
|
"create_started_at = COALESCE("
|
|
|
"create_started_at, CURRENT_TIMESTAMP), "
|
|
"create_started_at, CURRENT_TIMESTAMP), "
|
|
|
"error_code = NULL, updated_at = CURRENT_TIMESTAMP "
|
|
"error_code = NULL, updated_at = CURRENT_TIMESTAMP "
|
|
@@ -222,17 +247,23 @@ class DataRuleRepository:
|
|
|
"AND dataflow_uid = CAST(:dataflow_uid AS uuid) "
|
|
"AND dataflow_uid = CAST(:dataflow_uid AS uuid) "
|
|
|
"AND actor_uid = CAST(:actor_uid AS uuid) "
|
|
"AND actor_uid = CAST(:actor_uid AS uuid) "
|
|
|
"AND nonce_hash = :nonce_hash "
|
|
"AND nonce_hash = :nonce_hash "
|
|
|
|
|
+ "AND (request_digest IS NULL "
|
|
|
|
|
+ "OR request_digest = :request_digest) "
|
|
|
"AND consumed_at IS NULL "
|
|
"AND consumed_at IS NULL "
|
|
|
"AND (expires_at > CURRENT_TIMESTAMP OR attempt_count > 0) "
|
|
"AND (expires_at > CURRENT_TIMESTAMP OR attempt_count > 0) "
|
|
|
"AND (state IN ('reserved', 'failed') OR "
|
|
"AND (state IN ('reserved', 'failed') OR "
|
|
|
"(state = 'creating' AND lease_expires_at <= CURRENT_TIMESTAMP)) "
|
|
"(state = 'creating' AND lease_expires_at <= CURRENT_TIMESTAMP)) "
|
|
|
- "RETURNING dataflow_uid::text, attempt_count "
|
|
|
|
|
|
|
+ "RETURNING dataflow_uid::text, attempt_count, "
|
|
|
|
|
+ "create_intent, request_digest "
|
|
|
"/* begin_dataflow_create */"
|
|
"/* begin_dataflow_create */"
|
|
|
),
|
|
),
|
|
|
{
|
|
{
|
|
|
**values,
|
|
**values,
|
|
|
"lease_token": lease_token,
|
|
"lease_token": lease_token,
|
|
|
"lease_seconds": lease_seconds,
|
|
"lease_seconds": lease_seconds,
|
|
|
|
|
+ "create_request": _json(request_value),
|
|
|
|
|
+ "create_intent": _json(intent_value),
|
|
|
|
|
+ "request_digest": request_digest,
|
|
|
},
|
|
},
|
|
|
)
|
|
)
|
|
|
.mappings()
|
|
.mappings()
|
|
@@ -244,13 +275,18 @@ class DataRuleRepository:
|
|
|
"dataflow_uid": str(claimed["dataflow_uid"]),
|
|
"dataflow_uid": str(claimed["dataflow_uid"]),
|
|
|
"lease_token": lease_token,
|
|
"lease_token": lease_token,
|
|
|
"attempt": int(claimed["attempt_count"]),
|
|
"attempt": int(claimed["attempt_count"]),
|
|
|
|
|
+ "create_intent": _object(
|
|
|
|
|
+ claimed["create_intent"], "dataflow create intent"
|
|
|
|
|
+ ),
|
|
|
|
|
+ "request_digest": str(claimed["request_digest"]),
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
row = (
|
|
row = (
|
|
|
self.session.execute(
|
|
self.session.execute(
|
|
|
text(
|
|
text(
|
|
|
"SELECT state, dataflow_uid::text, result, "
|
|
"SELECT state, dataflow_uid::text, result, "
|
|
|
- "result_digest, lease_expires_at, expires_at "
|
|
|
|
|
|
|
+ "result_digest, lease_expires_at, expires_at, "
|
|
|
|
|
+ "request_digest, create_intent "
|
|
|
"FROM public.dataflow_draft_reservations "
|
|
"FROM public.dataflow_draft_reservations "
|
|
|
"WHERE id = CAST(:id AS uuid) "
|
|
"WHERE id = CAST(:id AS uuid) "
|
|
|
"AND dataflow_uid = CAST(:dataflow_uid AS uuid) "
|
|
"AND dataflow_uid = CAST(:dataflow_uid AS uuid) "
|
|
@@ -263,7 +299,14 @@ class DataRuleRepository:
|
|
|
.mappings()
|
|
.mappings()
|
|
|
.one_or_none()
|
|
.one_or_none()
|
|
|
)
|
|
)
|
|
|
|
|
+ if row is not None and row["request_digest"] not in {
|
|
|
|
|
+ None,
|
|
|
|
|
+ request_digest,
|
|
|
|
|
+ }:
|
|
|
|
|
+ raise ValueError("dataflow_create_request_conflict")
|
|
|
if row is not None and row["state"] == "completed":
|
|
if row is not None and row["state"] == "completed":
|
|
|
|
|
+ if str(row["request_digest"]) != request_digest:
|
|
|
|
|
+ raise ValueError("dataflow_create_request_conflict")
|
|
|
result = _object(row["result"], "dataflow create result")
|
|
result = _object(row["result"], "dataflow create result")
|
|
|
if _canonical_hash(result) != str(row["result_digest"]):
|
|
if _canonical_hash(result) != str(row["result_digest"]):
|
|
|
raise RuntimeError("dataflow create result integrity check failed")
|
|
raise RuntimeError("dataflow create result integrity check failed")
|
|
@@ -271,6 +314,7 @@ class DataRuleRepository:
|
|
|
"status": "completed",
|
|
"status": "completed",
|
|
|
"dataflow_uid": str(row["dataflow_uid"]),
|
|
"dataflow_uid": str(row["dataflow_uid"]),
|
|
|
"result": copy.deepcopy(result),
|
|
"result": copy.deepcopy(result),
|
|
|
|
|
+ "request_digest": request_digest,
|
|
|
}
|
|
}
|
|
|
if row is not None and row["state"] == "creating":
|
|
if row is not None and row["state"] == "creating":
|
|
|
raise ValueError("dataflow_create_in_progress")
|
|
raise ValueError("dataflow_create_in_progress")
|
|
@@ -282,12 +326,14 @@ class DataRuleRepository:
|
|
|
reservation_id: str,
|
|
reservation_id: str,
|
|
|
dataflow_uid: str,
|
|
dataflow_uid: str,
|
|
|
lease_token: str,
|
|
lease_token: str,
|
|
|
|
|
+ request_digest: str,
|
|
|
dataflow_node_id: int,
|
|
dataflow_node_id: int,
|
|
|
result: dict[str, Any],
|
|
result: dict[str, Any],
|
|
|
) -> dict[str, Any]:
|
|
) -> dict[str, Any]:
|
|
|
reservation = _uid(reservation_id, "reservation_id")
|
|
reservation = _uid(reservation_id, "reservation_id")
|
|
|
flow_uid = _uid(dataflow_uid, "dataflow_uid")
|
|
flow_uid = _uid(dataflow_uid, "dataflow_uid")
|
|
|
lease = _uid(lease_token, "lease_token")
|
|
lease = _uid(lease_token, "lease_token")
|
|
|
|
|
+ request_hash = _digest(request_digest, "request_digest")
|
|
|
if (
|
|
if (
|
|
|
isinstance(dataflow_node_id, bool)
|
|
isinstance(dataflow_node_id, bool)
|
|
|
or not isinstance(dataflow_node_id, int)
|
|
or not isinstance(dataflow_node_id, int)
|
|
@@ -310,12 +356,14 @@ class DataRuleRepository:
|
|
|
"AND dataflow_uid = CAST(:dataflow_uid AS uuid) "
|
|
"AND dataflow_uid = CAST(:dataflow_uid AS uuid) "
|
|
|
"AND state = 'creating' "
|
|
"AND state = 'creating' "
|
|
|
"AND lease_token = CAST(:lease_token AS uuid) "
|
|
"AND lease_token = CAST(:lease_token AS uuid) "
|
|
|
|
|
+ "AND request_digest = :request_digest "
|
|
|
"RETURNING result /* complete_dataflow_create */"
|
|
"RETURNING result /* complete_dataflow_create */"
|
|
|
),
|
|
),
|
|
|
{
|
|
{
|
|
|
"id": reservation,
|
|
"id": reservation,
|
|
|
"dataflow_uid": flow_uid,
|
|
"dataflow_uid": flow_uid,
|
|
|
"lease_token": lease,
|
|
"lease_token": lease,
|
|
|
|
|
+ "request_digest": request_hash,
|
|
|
"result": _json(normalized_result),
|
|
"result": _json(normalized_result),
|
|
|
"result_digest": result_digest,
|
|
"result_digest": result_digest,
|
|
|
"dataflow_node_id": dataflow_node_id,
|
|
"dataflow_node_id": dataflow_node_id,
|
|
@@ -328,14 +376,19 @@ class DataRuleRepository:
|
|
|
replay = (
|
|
replay = (
|
|
|
self.session.execute(
|
|
self.session.execute(
|
|
|
text(
|
|
text(
|
|
|
- "SELECT result, result_digest "
|
|
|
|
|
|
|
+ "SELECT result, result_digest, request_digest "
|
|
|
"FROM public.dataflow_draft_reservations "
|
|
"FROM public.dataflow_draft_reservations "
|
|
|
"WHERE id = CAST(:id AS uuid) "
|
|
"WHERE id = CAST(:id AS uuid) "
|
|
|
"AND dataflow_uid = CAST(:dataflow_uid AS uuid) "
|
|
"AND dataflow_uid = CAST(:dataflow_uid AS uuid) "
|
|
|
"AND state = 'completed' "
|
|
"AND state = 'completed' "
|
|
|
|
|
+ "AND request_digest = :request_digest "
|
|
|
"/* replay_completed_dataflow_create */"
|
|
"/* replay_completed_dataflow_create */"
|
|
|
),
|
|
),
|
|
|
- {"id": reservation, "dataflow_uid": flow_uid},
|
|
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": reservation,
|
|
|
|
|
+ "dataflow_uid": flow_uid,
|
|
|
|
|
+ "request_digest": request_hash,
|
|
|
|
|
+ },
|
|
|
)
|
|
)
|
|
|
.mappings()
|
|
.mappings()
|
|
|
.one_or_none()
|
|
.one_or_none()
|
|
@@ -383,6 +436,147 @@ class DataRuleRepository:
|
|
|
)
|
|
)
|
|
|
self.session.commit()
|
|
self.session.commit()
|
|
|
|
|
|
|
|
|
|
+ def list_reconcilable_dataflow_creates(
|
|
|
|
|
+ self, *, limit: int = 100
|
|
|
|
|
+ ) -> list[dict[str, Any]]:
|
|
|
|
|
+ if (
|
|
|
|
|
+ isinstance(limit, bool)
|
|
|
|
|
+ or not isinstance(limit, int)
|
|
|
|
|
+ or limit < 1
|
|
|
|
|
+ or limit > 500
|
|
|
|
|
+ ):
|
|
|
|
|
+ raise ValueError("reconciliation limit is invalid")
|
|
|
|
|
+ rows = (
|
|
|
|
|
+ self.session.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "SELECT id::text AS reservation_id, "
|
|
|
|
|
+ "dataflow_uid::text, state, attempt_count, "
|
|
|
|
|
+ "request_digest, error_code, lease_expires_at, updated_at "
|
|
|
|
|
+ "FROM public.dataflow_draft_reservations "
|
|
|
|
|
+ "WHERE request_digest IS NOT NULL "
|
|
|
|
|
+ "AND create_request IS NOT NULL "
|
|
|
|
|
+ "AND create_intent IS NOT NULL "
|
|
|
|
|
+ "AND error_code IS DISTINCT FROM "
|
|
|
|
|
+ "'create_intent_integrity_failed' "
|
|
|
|
|
+ "AND (state = 'failed' OR (state = 'creating' "
|
|
|
|
|
+ "AND lease_expires_at <= CURRENT_TIMESTAMP)) "
|
|
|
|
|
+ "ORDER BY updated_at, id LIMIT :limit "
|
|
|
|
|
+ "/* list_reconcilable_dataflow_creates */"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {"limit": limit},
|
|
|
|
|
+ )
|
|
|
|
|
+ .mappings()
|
|
|
|
|
+ .all()
|
|
|
|
|
+ )
|
|
|
|
|
+ return [
|
|
|
|
|
+ {
|
|
|
|
|
+ "reservation_id": str(row["reservation_id"]),
|
|
|
|
|
+ "dataflow_uid": str(row["dataflow_uid"]),
|
|
|
|
|
+ "state": str(row["state"]),
|
|
|
|
|
+ "attempt_count": int(row["attempt_count"]),
|
|
|
|
|
+ "request_digest": str(row["request_digest"]),
|
|
|
|
|
+ "error_code": (
|
|
|
|
|
+ str(row["error_code"])
|
|
|
|
|
+ if row["error_code"] is not None
|
|
|
|
|
+ else None
|
|
|
|
|
+ ),
|
|
|
|
|
+ "lease_expires_at": (
|
|
|
|
|
+ row["lease_expires_at"].isoformat()
|
|
|
|
|
+ if hasattr(row["lease_expires_at"], "isoformat")
|
|
|
|
|
+ else row["lease_expires_at"]
|
|
|
|
|
+ ),
|
|
|
|
|
+ "updated_at": (
|
|
|
|
|
+ row["updated_at"].isoformat()
|
|
|
|
|
+ if hasattr(row["updated_at"], "isoformat")
|
|
|
|
|
+ else row["updated_at"]
|
|
|
|
|
+ ),
|
|
|
|
|
+ }
|
|
|
|
|
+ for row in rows
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ def claim_reconcilable_dataflow_creates(
|
|
|
|
|
+ self, *, limit: int = 100, lease_seconds: int = 60
|
|
|
|
|
+ ) -> list[dict[str, Any]]:
|
|
|
|
|
+ if (
|
|
|
|
|
+ isinstance(limit, bool)
|
|
|
|
|
+ or not isinstance(limit, int)
|
|
|
|
|
+ or limit < 1
|
|
|
|
|
+ or limit > 500
|
|
|
|
|
+ ):
|
|
|
|
|
+ raise ValueError("reconciliation limit is invalid")
|
|
|
|
|
+ if (
|
|
|
|
|
+ isinstance(lease_seconds, bool)
|
|
|
|
|
+ or not isinstance(lease_seconds, int)
|
|
|
|
|
+ or lease_seconds < 10
|
|
|
|
|
+ or lease_seconds > 300
|
|
|
|
|
+ ):
|
|
|
|
|
+ raise ValueError("reconciliation lease is invalid")
|
|
|
|
|
+ lease_token = new_governance_uid()
|
|
|
|
|
+ rows = (
|
|
|
|
|
+ self.session.execute(
|
|
|
|
|
+ text(
|
|
|
|
|
+ "WITH candidates AS (SELECT id "
|
|
|
|
|
+ "FROM public.dataflow_draft_reservations "
|
|
|
|
|
+ "WHERE request_digest IS NOT NULL "
|
|
|
|
|
+ "AND create_request IS NOT NULL "
|
|
|
|
|
+ "AND create_intent IS NOT NULL "
|
|
|
|
|
+ "AND error_code IS DISTINCT FROM "
|
|
|
|
|
+ "'create_intent_integrity_failed' "
|
|
|
|
|
+ "AND (state = 'failed' OR (state = 'creating' "
|
|
|
|
|
+ "AND lease_expires_at <= CURRENT_TIMESTAMP)) "
|
|
|
|
|
+ "ORDER BY updated_at, id "
|
|
|
|
|
+ "FOR UPDATE SKIP LOCKED LIMIT :limit) "
|
|
|
|
|
+ "UPDATE public.dataflow_draft_reservations value "
|
|
|
|
|
+ "SET state = 'creating', "
|
|
|
|
|
+ "lease_token = CAST(:lease_token AS uuid), "
|
|
|
|
|
+ "lease_expires_at = CURRENT_TIMESTAMP + "
|
|
|
|
|
+ "(:lease_seconds * INTERVAL '1 second'), "
|
|
|
|
|
+ "attempt_count = attempt_count + 1, "
|
|
|
|
|
+ "error_code = NULL, updated_at = CURRENT_TIMESTAMP "
|
|
|
|
|
+ "FROM candidates WHERE value.id = candidates.id "
|
|
|
|
|
+ "RETURNING value.id::text AS reservation_id, "
|
|
|
|
|
+ "value.dataflow_uid::text, value.create_request, "
|
|
|
|
|
+ "value.create_intent, value.request_digest, "
|
|
|
|
|
+ "value.attempt_count "
|
|
|
|
|
+ "/* claim_reconcilable_dataflow_creates */"
|
|
|
|
|
+ ),
|
|
|
|
|
+ {
|
|
|
|
|
+ "limit": limit,
|
|
|
|
|
+ "lease_token": lease_token,
|
|
|
|
|
+ "lease_seconds": lease_seconds,
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ .mappings()
|
|
|
|
|
+ .all()
|
|
|
|
|
+ )
|
|
|
|
|
+ claimed = []
|
|
|
|
|
+ for row in rows:
|
|
|
|
|
+ create_request = _bounded_object(
|
|
|
|
|
+ row["create_request"], "dataflow create request"
|
|
|
|
|
+ )
|
|
|
|
|
+ create_intent = _bounded_object(
|
|
|
|
|
+ row["create_intent"], "dataflow create intent"
|
|
|
|
|
+ )
|
|
|
|
|
+ request_digest = str(row["request_digest"])
|
|
|
|
|
+ integrity_valid = not (
|
|
|
|
|
+ _canonical_hash(create_request) != request_digest
|
|
|
|
|
+ or create_request.get("intent") != create_intent
|
|
|
|
|
+ or create_intent.get("dataflow_uid")
|
|
|
|
|
+ != str(row["dataflow_uid"])
|
|
|
|
|
+ )
|
|
|
|
|
+ claimed.append(
|
|
|
|
|
+ {
|
|
|
|
|
+ "reservation_id": str(row["reservation_id"]),
|
|
|
|
|
+ "dataflow_uid": str(row["dataflow_uid"]),
|
|
|
|
|
+ "lease_token": lease_token,
|
|
|
|
|
+ "request_digest": request_digest,
|
|
|
|
|
+ "create_intent": create_intent,
|
|
|
|
|
+ "attempt": int(row["attempt_count"]),
|
|
|
|
|
+ "integrity_valid": integrity_valid,
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+ return claimed
|
|
|
|
|
+
|
|
|
@staticmethod
|
|
@staticmethod
|
|
|
def candidate_hash(candidate: dict[str, Any]) -> str:
|
|
def candidate_hash(candidate: dict[str, Any]) -> str:
|
|
|
return _canonical_hash(validate_rule_candidate(candidate))
|
|
return _canonical_hash(validate_rule_candidate(candidate))
|