fix: persist uncertain cancel outcomes
This commit is contained in:
@@ -24,6 +24,8 @@ import httpx
|
||||
from trex_core.application.broker_port import (
|
||||
BalanceItem,
|
||||
BrokerBalanceError,
|
||||
CancelOutcome,
|
||||
CancelState,
|
||||
FillStatus,
|
||||
OrderAck,
|
||||
OrderRequest,
|
||||
@@ -756,7 +758,7 @@ class KISBroker:
|
||||
|
||||
def cancel_order(
|
||||
self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX"
|
||||
) -> bool:
|
||||
) -> CancelOutcome:
|
||||
"""Cancel a previously submitted order (REQ-118).
|
||||
|
||||
venue: exchange venue of the original order. Must match the EXCG_ID_DVSN_CD
|
||||
@@ -764,7 +766,10 @@ class KISBroker:
|
||||
Real-account NXT cancel (EXCG=NXT) confirmed rt_cd=0 on 2026-07-06.
|
||||
"""
|
||||
if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
|
||||
return False
|
||||
return CancelOutcome(
|
||||
CancelState.UNKNOWN,
|
||||
"submission_outcome_unknown",
|
||||
)
|
||||
cano, product = self._account()
|
||||
parts = broker_order_id.split(":")
|
||||
orgno, odno = (parts[0], parts[1]) if len(parts) == 2 else ("", parts[-1])
|
||||
@@ -789,6 +794,22 @@ class KISBroker:
|
||||
json=body,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
return str(resp.json().get("rt_cd")) == "0"
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return False
|
||||
except httpx.HTTPError:
|
||||
return CancelOutcome(CancelState.UNKNOWN, "transport_failed")
|
||||
if getattr(resp, "status_code", 200) >= 500:
|
||||
return CancelOutcome(CancelState.UNKNOWN, "broker_unavailable")
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError:
|
||||
return CancelOutcome(CancelState.UNKNOWN, "malformed_response")
|
||||
rt_cd = payload.get("rt_cd")
|
||||
if str(rt_cd) == "0":
|
||||
return CancelOutcome(CancelState.REQUESTED)
|
||||
if rt_cd is None:
|
||||
if getattr(resp, "status_code", 200) >= 400:
|
||||
return CancelOutcome(CancelState.REJECTED, "http_rejected")
|
||||
return CancelOutcome(CancelState.UNKNOWN, "missing_result_code")
|
||||
return CancelOutcome(
|
||||
CancelState.REJECTED,
|
||||
str(payload.get("msg_cd") or "broker_rejected"),
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ from threading import Lock
|
||||
from trex_core.application.broker_port import (
|
||||
BalanceItem,
|
||||
BrokerBalanceError,
|
||||
CancelOutcome,
|
||||
FillStatus,
|
||||
OrderAck,
|
||||
OrderRequest,
|
||||
@@ -146,7 +147,7 @@ class OwnershipAwareBroker:
|
||||
symbol: str,
|
||||
qty: int,
|
||||
venue: str = "KRX",
|
||||
) -> bool:
|
||||
) -> CancelOutcome:
|
||||
return self._delegate.cancel_order(
|
||||
broker_order_id,
|
||||
side=side,
|
||||
|
||||
@@ -14,6 +14,8 @@ from typing import Optional
|
||||
from trex_core.adapters.broker.seq_store import SeqStore
|
||||
from trex_core.application.broker_port import (
|
||||
BalanceItem,
|
||||
CancelOutcome,
|
||||
CancelState,
|
||||
FillStatus,
|
||||
OrderAck,
|
||||
OrderRequest,
|
||||
@@ -105,16 +107,24 @@ class PaperBroker:
|
||||
)
|
||||
return FillStatus(state="partially_filled", filled_qty=o.filled, avg_price=o.price)
|
||||
|
||||
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX") -> bool:
|
||||
def cancel_order(
|
||||
self,
|
||||
broker_order_id: str,
|
||||
*,
|
||||
side: str,
|
||||
symbol: str,
|
||||
qty: int,
|
||||
venue: str = "KRX",
|
||||
) -> CancelOutcome:
|
||||
"""Cancel a paper order. venue is accepted for BrokerPort protocol conformance
|
||||
(REQ-118) but ignored — paper has no exchange concept. Existing behavior preserved.
|
||||
"""
|
||||
with self._lock:
|
||||
o = self._orders.get(broker_order_id)
|
||||
if o is None or o.filled >= o.qty:
|
||||
return False
|
||||
return CancelOutcome(CancelState.REJECTED, "order_not_cancellable")
|
||||
o.cancelled = True
|
||||
return True
|
||||
return CancelOutcome(CancelState.REQUESTED)
|
||||
|
||||
def get_balance(self) -> list[BalanceItem]:
|
||||
"""Deterministic per-symbol fill aggregate (REQ-031 AC-3).
|
||||
|
||||
@@ -37,7 +37,8 @@ _SLOT_COLUMNS = (
|
||||
"order_id, strategy_id, broker_order_id, portfolio_id, "
|
||||
"sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, "
|
||||
"created_at, updated_at, instance_key, retired_at, "
|
||||
"timeout_sec, slippage_pct, occupancy_seq, venue"
|
||||
"timeout_sec, slippage_pct, occupancy_seq, venue, "
|
||||
"cancel_state, cancel_requested_at"
|
||||
)
|
||||
|
||||
|
||||
@@ -216,7 +217,8 @@ class PostgreSQLSlotRepository:
|
||||
"broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, "
|
||||
"forced_close_at=%s, stop_loss_price=%s, take_profit_price=%s, "
|
||||
"instance_key=%s, retired_at=%s, timeout_sec=%s, slippage_pct=%s, "
|
||||
"occupancy_seq=%s, venue=%s, updated_at=%s WHERE slot_id=%s",
|
||||
"occupancy_seq=%s, venue=%s, cancel_state=%s, "
|
||||
"cancel_requested_at=%s, updated_at=%s WHERE slot_id=%s",
|
||||
(
|
||||
slot.status.value,
|
||||
slot.symbol,
|
||||
@@ -237,6 +239,8 @@ class PostgreSQLSlotRepository:
|
||||
slot.slippage_pct,
|
||||
slot.occupancy_seq,
|
||||
slot.venue,
|
||||
slot.cancel_state,
|
||||
slot.cancel_requested_at,
|
||||
slot.updated_at or datetime.now(tz=timezone.utc),
|
||||
slot.slot_id,
|
||||
),
|
||||
@@ -332,6 +336,26 @@ class PostgreSQLSlotRepository:
|
||||
conn.commit()
|
||||
return ok
|
||||
|
||||
def mark_cancel_outcome(
|
||||
self,
|
||||
slot_id: UUID,
|
||||
broker_order_id: str,
|
||||
state: str,
|
||||
) -> bool:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE daily_slot SET cancel_state=%s, "
|
||||
"cancel_requested_at=%s, updated_at=%s "
|
||||
"WHERE slot_id=%s AND broker_order_id=%s "
|
||||
"AND status IN ('pending_buy','pending_sell')",
|
||||
(state, now, now, slot_id, broker_order_id),
|
||||
)
|
||||
ok = cur.rowcount == 1
|
||||
conn.commit()
|
||||
return ok
|
||||
|
||||
def list_dispatched_pending(self, instance_key: str = "default") -> list[Slot]:
|
||||
with self._psycopg.connect(self._dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
@@ -347,7 +371,8 @@ class PostgreSQLSlotRepository:
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE daily_slot SET broker_order_id=NULL, updated_at=%s WHERE slot_id=%s",
|
||||
"UPDATE daily_slot SET broker_order_id=NULL, cancel_state=NULL, "
|
||||
"cancel_requested_at=NULL, updated_at=%s WHERE slot_id=%s",
|
||||
(datetime.now(tz=timezone.utc), slot_id),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -374,7 +399,8 @@ class PostgreSQLSlotRepository:
|
||||
# forced_close_at(11) stop_loss_price(12) take_profit_price(13)
|
||||
# created_at(14) updated_at(15) instance_key(16) retired_at(17)
|
||||
# timeout_sec(18) slippage_pct(19) [dcy-022/REQ-068]
|
||||
# occupancy_seq(20) [dcy-028/REQ-081], venue(21)
|
||||
# occupancy_seq(20) [dcy-028/REQ-081], venue(21),
|
||||
# cancel_state(22), cancel_requested_at(23)
|
||||
return Slot(
|
||||
slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])),
|
||||
status=SlotState(row[1]),
|
||||
@@ -398,6 +424,8 @@ class PostgreSQLSlotRepository:
|
||||
slippage_pct=f(row[19]),
|
||||
occupancy_seq=int(row[20]) if len(row) > 20 and row[20] is not None else 0,
|
||||
venue=str(row[21]) if len(row) > 21 and row[21] is not None else "KRX",
|
||||
cancel_state=str(row[22]) if len(row) > 22 and row[22] is not None else None,
|
||||
cancel_requested_at=row[23] if len(row) > 23 else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- dcy-041 / GitHub #44: cancellation request acceptance is distinct from
|
||||
-- final order cancellation. Persist the immediate result to prevent blind
|
||||
-- automatic retries across execution ticks and daemon restarts.
|
||||
ALTER TABLE daily_slot
|
||||
ADD COLUMN IF NOT EXISTS cancel_state TEXT;
|
||||
|
||||
ALTER TABLE daily_slot
|
||||
ADD COLUMN IF NOT EXISTS cancel_requested_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE daily_slot
|
||||
DROP CONSTRAINT IF EXISTS daily_slot_cancel_state_check;
|
||||
|
||||
ALTER TABLE daily_slot
|
||||
ADD CONSTRAINT daily_slot_cancel_state_check
|
||||
CHECK (
|
||||
cancel_state IS NULL
|
||||
OR cancel_state IN ('requested', 'rejected', 'unknown')
|
||||
);
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
|
||||
@@ -29,6 +30,30 @@ class BrokerBalanceError(RuntimeError):
|
||||
super().__init__(f"broker balance unavailable ({code})")
|
||||
|
||||
|
||||
class CancelState(str, Enum):
|
||||
"""Immediate result of a cancel request, not the final order state."""
|
||||
|
||||
REQUESTED = "requested"
|
||||
REJECTED = "rejected"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CancelOutcome:
|
||||
"""Sanitized cancel request outcome.
|
||||
|
||||
REQUESTED only means that the broker accepted the cancellation request.
|
||||
Final cancelled/filled state is established exclusively by poll_fill().
|
||||
"""
|
||||
|
||||
state: CancelState
|
||||
reason: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_requested(self) -> bool:
|
||||
return self.state is CancelState.REQUESTED
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrderRequest:
|
||||
slot_id: str # originating slot (audit/correlation)
|
||||
@@ -114,12 +139,23 @@ class BrokerPort(Protocol):
|
||||
"""Observe current fill status for a previously accepted order."""
|
||||
...
|
||||
|
||||
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX") -> bool:
|
||||
"""Best-effort cancel. Returns True if the broker accepted the cancel.
|
||||
def cancel_order(
|
||||
self,
|
||||
broker_order_id: str,
|
||||
*,
|
||||
side: str,
|
||||
symbol: str,
|
||||
qty: int,
|
||||
venue: str = "KRX",
|
||||
) -> CancelOutcome:
|
||||
"""Request cancellation and classify the immediate broker outcome.
|
||||
|
||||
venue: exchange venue of the original order (REQ-118). Cancel must use
|
||||
the same EXCG_ID_DVSN_CD as the original submit. Default "KRX" preserves
|
||||
backward compatibility for existing callers (orb/tsl).
|
||||
|
||||
REQUESTED is not a final cancellation. poll_fill() remains the only
|
||||
authority for cancelled/filled terminal state.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@@ -29,7 +29,12 @@ from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
|
||||
from trex_core.application.alarm_emitter import AlarmEmitter
|
||||
from trex_core.application.broker_port import BrokerPort, OrderRequest
|
||||
from trex_core.application.broker_port import (
|
||||
BrokerPort,
|
||||
CancelOutcome,
|
||||
CancelState,
|
||||
OrderRequest,
|
||||
)
|
||||
from trex_core.application.ledger_outbox import LedgerOutbox
|
||||
from trex_core.application.ledger_port import LedgerTrade
|
||||
from trex_core.domain.pricing import ceil_to_tick, floor_to_tick
|
||||
@@ -546,12 +551,28 @@ class ExecutionService:
|
||||
"""
|
||||
if str(slot.broker_order_id or "").startswith("submission-unknown:"):
|
||||
return
|
||||
if slot.cancel_state is not None:
|
||||
return
|
||||
if slot.timeout_sec is None or slot.updated_at is None:
|
||||
return
|
||||
age = (now - slot.updated_at).total_seconds()
|
||||
if age < slot.timeout_sec:
|
||||
return
|
||||
qty = int(slot.qty or 0)
|
||||
# Write UNKNOWN before external I/O. If the process dies after KIS
|
||||
# receives the request but before the response is persisted, restart
|
||||
# polls the order instead of issuing a blind second cancellation.
|
||||
if not self._repo.mark_cancel_outcome(
|
||||
slot.slot_id,
|
||||
str(slot.broker_order_id),
|
||||
CancelState.UNKNOWN.value,
|
||||
):
|
||||
_log.error(
|
||||
"cancel attempt reservation conflict: slot=%s broker_order_id=%s",
|
||||
slot.slot_id,
|
||||
slot.broker_order_id,
|
||||
)
|
||||
return
|
||||
try:
|
||||
cancel_kwargs = {
|
||||
"side": side,
|
||||
@@ -560,25 +581,40 @@ class ExecutionService:
|
||||
}
|
||||
if slot.venue != "KRX":
|
||||
cancel_kwargs["venue"] = slot.venue
|
||||
accepted = self._broker.cancel_order(slot.broker_order_id, **cancel_kwargs)
|
||||
outcome = self._broker.cancel_order(slot.broker_order_id, **cancel_kwargs)
|
||||
except Exception: # noqa: BLE001 - adapter failure becomes durable unknown
|
||||
outcome = CancelOutcome(CancelState.UNKNOWN, "adapter_exception")
|
||||
_log.warning(
|
||||
"timeout cancel outcome unknown: slot=%s broker_order_id=%s",
|
||||
slot.slot_id,
|
||||
slot.broker_order_id,
|
||||
)
|
||||
persisted = self._repo.mark_cancel_outcome(
|
||||
slot.slot_id,
|
||||
str(slot.broker_order_id),
|
||||
outcome.state.value,
|
||||
)
|
||||
if not persisted:
|
||||
_log.error(
|
||||
"cancel outcome persistence conflict: slot=%s broker_order_id=%s "
|
||||
"outcome=%s",
|
||||
slot.slot_id,
|
||||
slot.broker_order_id,
|
||||
outcome.state.value,
|
||||
)
|
||||
return
|
||||
if outcome.is_requested:
|
||||
r.timeout_cancelled += 1
|
||||
_log.warning(
|
||||
"timeout cancel sent: slot=%s status=%s broker_order_id=%s "
|
||||
"age_sec=%.0f timeout_sec=%d broker_accepted=%s",
|
||||
slot.slot_id,
|
||||
slot.status.value,
|
||||
slot.broker_order_id,
|
||||
age,
|
||||
slot.timeout_sec,
|
||||
accepted,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - best-effort; do not block the loop
|
||||
_log.warning(
|
||||
"timeout cancel failed (exception): slot=%s broker_order_id=%s "
|
||||
"(will retry next tick if still pending)",
|
||||
slot.slot_id,
|
||||
slot.broker_order_id,
|
||||
)
|
||||
_log.warning(
|
||||
"timeout cancel result: slot=%s status=%s broker_order_id=%s "
|
||||
"age_sec=%.0f timeout_sec=%d outcome=%s",
|
||||
slot.slot_id,
|
||||
slot.status.value,
|
||||
slot.broker_order_id,
|
||||
age,
|
||||
slot.timeout_sec,
|
||||
outcome.state.value,
|
||||
)
|
||||
|
||||
def _emit_event(self, event_type: str, slot, routing_strategy_id: Optional[str] = None) -> None:
|
||||
if self._event_emitter is None:
|
||||
@@ -655,9 +691,9 @@ class ExecutionService:
|
||||
def cancel_pending_slot(self, slot_id) -> str:
|
||||
"""Operator backstop: send cancel_order for a pending slot (dcy-022/REQ-071).
|
||||
|
||||
Returns "ok" (cancel sent), "not_pending" (slot is HOLDING/INACTIVE),
|
||||
or "slot_not_found". Does NOT transition the slot — the execution loop's
|
||||
next poll handles the rollback via the normal is_terminal_reject path.
|
||||
Returns "ok" (request accepted), "cancel_rejected", "cancel_unknown",
|
||||
"not_pending", "not_dispatched", or "slot_not_found". Does NOT
|
||||
transition the slot — fill polling alone confirms cancelled/filled.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
@@ -680,21 +716,40 @@ class ExecutionService:
|
||||
}
|
||||
if slot.venue != "KRX":
|
||||
cancel_kwargs["venue"] = slot.venue
|
||||
self._broker.cancel_order(slot.broker_order_id, **cancel_kwargs)
|
||||
except Exception: # noqa: BLE001 - best-effort
|
||||
# Durable unknown reservation closes the crash window between the
|
||||
# outbound request and saving its immediate result.
|
||||
if not self._repo.mark_cancel_outcome(
|
||||
slot.slot_id,
|
||||
str(slot.broker_order_id),
|
||||
CancelState.UNKNOWN.value,
|
||||
):
|
||||
return "cancel_state_conflict"
|
||||
outcome = self._broker.cancel_order(slot.broker_order_id, **cancel_kwargs)
|
||||
except Exception: # noqa: BLE001 - adapter failure becomes durable unknown
|
||||
outcome = CancelOutcome(CancelState.UNKNOWN, "adapter_exception")
|
||||
_log.warning(
|
||||
"operator cancel failed: slot=%s broker_order_id=%s",
|
||||
"operator cancel outcome unknown: slot=%s broker_order_id=%s",
|
||||
slot.slot_id,
|
||||
slot.broker_order_id,
|
||||
)
|
||||
return "cancel_failed"
|
||||
if not self._repo.mark_cancel_outcome(
|
||||
slot.slot_id,
|
||||
str(slot.broker_order_id),
|
||||
outcome.state.value,
|
||||
):
|
||||
return "cancel_state_conflict"
|
||||
_log.warning(
|
||||
"operator cancel sent: slot=%s status=%s broker_order_id=%s",
|
||||
"operator cancel result: slot=%s status=%s broker_order_id=%s outcome=%s",
|
||||
slot.slot_id,
|
||||
slot.status.value,
|
||||
slot.broker_order_id,
|
||||
outcome.state.value,
|
||||
)
|
||||
return "ok"
|
||||
if outcome.state is CancelState.REQUESTED:
|
||||
return "ok"
|
||||
if outcome.state is CancelState.REJECTED:
|
||||
return "cancel_rejected"
|
||||
return "cancel_unknown"
|
||||
|
||||
def _rollback(self, slot, *, reason: str) -> None:
|
||||
total = self._balance_map().get(slot.symbol or "", 0)
|
||||
|
||||
@@ -219,6 +219,29 @@ class InMemorySlotRepository:
|
||||
)
|
||||
return True
|
||||
|
||||
def mark_cancel_outcome(
|
||||
self,
|
||||
slot_id: UUID,
|
||||
broker_order_id: str,
|
||||
state: str,
|
||||
) -> bool:
|
||||
"""Persist one cancel attempt iff the same order is still pending."""
|
||||
s = self._slots.get(slot_id)
|
||||
if (
|
||||
s is None
|
||||
or s.broker_order_id != broker_order_id
|
||||
or s.status not in self._PENDING
|
||||
):
|
||||
return False
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
self._slots[slot_id] = replace(
|
||||
s,
|
||||
cancel_state=state,
|
||||
cancel_requested_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
return True
|
||||
|
||||
def list_dispatched_pending(self, instance_key: str = "default") -> list[Slot]:
|
||||
"""Pending slots already dispatched (broker_order_id NOT NULL) — poll targets."""
|
||||
return [
|
||||
@@ -236,7 +259,11 @@ class InMemorySlotRepository:
|
||||
s = self._slots.get(slot_id)
|
||||
if s is not None:
|
||||
self._slots[slot_id] = replace(
|
||||
s, broker_order_id=None, updated_at=datetime.now(tz=timezone.utc)
|
||||
s,
|
||||
broker_order_id=None,
|
||||
cancel_state=None,
|
||||
cancel_requested_at=None,
|
||||
updated_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
|
||||
# --- bulk counts ---
|
||||
|
||||
@@ -59,6 +59,11 @@ class Slot:
|
||||
# Order venue carried from the admitted intent through dispatch/cancel.
|
||||
# It is reset to KRX when the permanent asset slot returns to INACTIVE.
|
||||
venue: str = "KRX"
|
||||
# Immediate cancel-request result. The slot remains pending until fill
|
||||
# polling confirms cancelled/filled. Persisted to prevent blind retries
|
||||
# across ticks and restarts.
|
||||
cancel_state: Optional[str] = None
|
||||
cancel_requested_at: Optional[datetime] = None
|
||||
|
||||
|
||||
# Canonical column contract for `Slot` (dcy-001 OQ-2 / FR-5).
|
||||
@@ -88,6 +93,8 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({
|
||||
"slippage_pct", # dcy-022 / REQ-068 (BAT-032)
|
||||
"occupancy_seq", # dcy-028 / REQ-081 (BAT-042)
|
||||
"venue",
|
||||
"cancel_state",
|
||||
"cancel_requested_at",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -205,6 +205,8 @@ class SlotService:
|
||||
timeout_sec=None,
|
||||
slippage_pct=None,
|
||||
venue="KRX",
|
||||
cancel_state=None,
|
||||
cancel_requested_at=None,
|
||||
updated_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
|
||||
@@ -277,6 +279,8 @@ class SlotService:
|
||||
slippage_pct=new_slippage_pct,
|
||||
occupancy_seq=new_occupancy_seq,
|
||||
venue=new_venue,
|
||||
cancel_state=None,
|
||||
cancel_requested_at=None,
|
||||
updated_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user