brawny 0.1.13__py3-none-any.whl → 0.1.22__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brawny/__init__.py +2 -0
- brawny/_context.py +5 -5
- brawny/_rpc/__init__.py +36 -12
- brawny/_rpc/broadcast.py +14 -13
- brawny/_rpc/caller.py +243 -0
- brawny/_rpc/client.py +539 -0
- brawny/_rpc/clients.py +11 -11
- brawny/_rpc/context.py +23 -0
- brawny/_rpc/errors.py +465 -31
- brawny/_rpc/gas.py +7 -6
- brawny/_rpc/pool.py +18 -0
- brawny/_rpc/retry.py +266 -0
- brawny/_rpc/retry_policy.py +81 -0
- brawny/accounts.py +28 -9
- brawny/alerts/__init__.py +15 -18
- brawny/alerts/abi_resolver.py +212 -36
- brawny/alerts/base.py +2 -2
- brawny/alerts/contracts.py +77 -10
- brawny/alerts/errors.py +30 -3
- brawny/alerts/events.py +38 -5
- brawny/alerts/health.py +19 -13
- brawny/alerts/send.py +513 -55
- brawny/api.py +39 -11
- brawny/assets/AGENTS.md +325 -0
- brawny/async_runtime.py +48 -0
- brawny/chain.py +3 -3
- brawny/cli/commands/__init__.py +2 -0
- brawny/cli/commands/console.py +69 -19
- brawny/cli/commands/contract.py +2 -2
- brawny/cli/commands/controls.py +121 -0
- brawny/cli/commands/health.py +2 -2
- brawny/cli/commands/job_dev.py +6 -5
- brawny/cli/commands/jobs.py +99 -2
- brawny/cli/commands/maintenance.py +13 -29
- brawny/cli/commands/migrate.py +1 -0
- brawny/cli/commands/run.py +10 -3
- brawny/cli/commands/script.py +8 -3
- brawny/cli/commands/signer.py +143 -26
- brawny/cli/helpers.py +0 -3
- brawny/cli_templates.py +25 -349
- brawny/config/__init__.py +4 -1
- brawny/config/models.py +43 -57
- brawny/config/parser.py +268 -57
- brawny/config/validation.py +52 -15
- brawny/daemon/context.py +4 -2
- brawny/daemon/core.py +185 -63
- brawny/daemon/loops.py +166 -98
- brawny/daemon/supervisor.py +261 -0
- brawny/db/__init__.py +14 -26
- brawny/db/base.py +248 -151
- brawny/db/global_cache.py +11 -1
- brawny/db/migrate.py +175 -28
- brawny/db/migrations/001_init.sql +4 -3
- brawny/db/migrations/010_add_nonce_gap_index.sql +1 -1
- brawny/db/migrations/011_add_job_logs.sql +1 -2
- brawny/db/migrations/012_add_claimed_by.sql +2 -2
- brawny/db/migrations/013_attempt_unique.sql +10 -0
- brawny/db/migrations/014_add_lease_expires_at.sql +5 -0
- brawny/db/migrations/015_add_signer_alias.sql +14 -0
- brawny/db/migrations/016_runtime_controls_and_quarantine.sql +32 -0
- brawny/db/migrations/017_add_job_drain.sql +6 -0
- brawny/db/migrations/018_add_nonce_reset_audit.sql +20 -0
- brawny/db/migrations/019_add_job_cooldowns.sql +8 -0
- brawny/db/migrations/020_attempt_unique_initial.sql +7 -0
- brawny/db/ops/__init__.py +3 -25
- brawny/db/ops/logs.py +1 -2
- brawny/db/queries.py +47 -91
- brawny/db/serialized.py +65 -0
- brawny/db/sqlite/__init__.py +1001 -0
- brawny/db/sqlite/connection.py +231 -0
- brawny/db/sqlite/execute.py +116 -0
- brawny/db/sqlite/mappers.py +190 -0
- brawny/db/sqlite/repos/attempts.py +372 -0
- brawny/db/sqlite/repos/block_state.py +102 -0
- brawny/db/sqlite/repos/cache.py +104 -0
- brawny/db/sqlite/repos/intents.py +1021 -0
- brawny/db/sqlite/repos/jobs.py +200 -0
- brawny/db/sqlite/repos/maintenance.py +182 -0
- brawny/db/sqlite/repos/signers_nonces.py +566 -0
- brawny/db/sqlite/tx.py +119 -0
- brawny/http.py +194 -0
- brawny/invariants.py +11 -24
- brawny/jobs/base.py +8 -0
- brawny/jobs/job_validation.py +2 -1
- brawny/keystore.py +83 -7
- brawny/lifecycle.py +64 -12
- brawny/logging.py +0 -2
- brawny/metrics.py +84 -12
- brawny/model/contexts.py +111 -9
- brawny/model/enums.py +1 -0
- brawny/model/errors.py +18 -0
- brawny/model/types.py +47 -131
- brawny/network_guard.py +133 -0
- brawny/networks/__init__.py +5 -5
- brawny/networks/config.py +1 -7
- brawny/networks/manager.py +14 -11
- brawny/runtime_controls.py +74 -0
- brawny/scheduler/poller.py +11 -7
- brawny/scheduler/reorg.py +95 -39
- brawny/scheduler/runner.py +442 -168
- brawny/scheduler/shutdown.py +3 -3
- brawny/script_tx.py +3 -3
- brawny/telegram.py +53 -7
- brawny/testing.py +1 -0
- brawny/timeout.py +38 -0
- brawny/tx/executor.py +922 -308
- brawny/tx/intent.py +54 -16
- brawny/tx/monitor.py +31 -12
- brawny/tx/nonce.py +212 -90
- brawny/tx/replacement.py +69 -18
- brawny/tx/retry_policy.py +24 -0
- brawny/tx/stages/types.py +75 -0
- brawny/types.py +18 -0
- brawny/utils.py +41 -0
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/METADATA +3 -3
- brawny-0.1.22.dist-info/RECORD +163 -0
- brawny/_rpc/manager.py +0 -982
- brawny/_rpc/selector.py +0 -156
- brawny/db/base_new.py +0 -165
- brawny/db/mappers.py +0 -182
- brawny/db/migrations/008_add_transactions.sql +0 -72
- brawny/db/ops/attempts.py +0 -108
- brawny/db/ops/blocks.py +0 -83
- brawny/db/ops/cache.py +0 -93
- brawny/db/ops/intents.py +0 -296
- brawny/db/ops/jobs.py +0 -110
- brawny/db/ops/nonces.py +0 -322
- brawny/db/postgres.py +0 -2535
- brawny/db/postgres_new.py +0 -196
- brawny/db/sqlite.py +0 -2733
- brawny/db/sqlite_new.py +0 -191
- brawny-0.1.13.dist-info/RECORD +0 -141
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/WHEEL +0 -0
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/entry_points.txt +0 -0
- {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1021 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import UUID, uuid4
|
|
9
|
+
|
|
10
|
+
from brawny.db.sqlite import mappers, tx
|
|
11
|
+
from brawny.model.enums import IntentStatus
|
|
12
|
+
from brawny.model.errors import DatabaseError, InvariantViolation
|
|
13
|
+
from brawny.model.types import TxIntent
|
|
14
|
+
from brawny.types import ClaimedIntent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_inflight_intent_count(db: Any, chain_id: int, job_id: str, signer_address: str) -> int:
|
|
18
|
+
signer_address = db._normalize_address(signer_address)
|
|
19
|
+
row = db.execute_one(
|
|
20
|
+
"""
|
|
21
|
+
SELECT COUNT(*) as count
|
|
22
|
+
FROM tx_intents
|
|
23
|
+
WHERE chain_id = ?
|
|
24
|
+
AND job_id = ?
|
|
25
|
+
AND signer_address = ?
|
|
26
|
+
AND status IN ('created', 'claimed', 'sending', 'pending')
|
|
27
|
+
""",
|
|
28
|
+
(chain_id, job_id, signer_address),
|
|
29
|
+
)
|
|
30
|
+
return int(row["count"]) if row else 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_inflight_intents_for_scope(
|
|
34
|
+
db: Any,
|
|
35
|
+
chain_id: int,
|
|
36
|
+
job_id: str,
|
|
37
|
+
signer_address: str,
|
|
38
|
+
to_address: str,
|
|
39
|
+
) -> list[dict[str, Any]]:
|
|
40
|
+
signer_address = db._normalize_address(signer_address)
|
|
41
|
+
to_address = db._normalize_address(to_address)
|
|
42
|
+
rows = db.execute_returning(
|
|
43
|
+
"""
|
|
44
|
+
SELECT intent_id, status, claimed_at, created_at
|
|
45
|
+
FROM tx_intents
|
|
46
|
+
WHERE chain_id = ?
|
|
47
|
+
AND job_id = ?
|
|
48
|
+
AND signer_address = ?
|
|
49
|
+
AND to_address = ?
|
|
50
|
+
AND status IN ('created', 'claimed', 'sending', 'pending')
|
|
51
|
+
ORDER BY created_at ASC
|
|
52
|
+
""",
|
|
53
|
+
(chain_id, job_id, signer_address, to_address),
|
|
54
|
+
)
|
|
55
|
+
return [dict(row) for row in rows]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def create_intent(
|
|
59
|
+
db: Any,
|
|
60
|
+
intent_id: UUID,
|
|
61
|
+
job_id: str,
|
|
62
|
+
chain_id: int,
|
|
63
|
+
signer_address: str,
|
|
64
|
+
idempotency_key: str,
|
|
65
|
+
to_address: str,
|
|
66
|
+
data: str | None,
|
|
67
|
+
value_wei: str,
|
|
68
|
+
gas_limit: int | None,
|
|
69
|
+
max_fee_per_gas: str | None,
|
|
70
|
+
max_priority_fee_per_gas: str | None,
|
|
71
|
+
min_confirmations: int,
|
|
72
|
+
deadline_ts: datetime | None,
|
|
73
|
+
signer_alias: str | None = None,
|
|
74
|
+
broadcast_group: str | None = None,
|
|
75
|
+
broadcast_endpoints: list[str] | None = None,
|
|
76
|
+
metadata: dict | None = None,
|
|
77
|
+
) -> TxIntent | None:
|
|
78
|
+
signer_address = db._normalize_address(signer_address)
|
|
79
|
+
to_address = db._normalize_address(to_address)
|
|
80
|
+
try:
|
|
81
|
+
db.execute(
|
|
82
|
+
"""
|
|
83
|
+
INSERT INTO tx_intents (
|
|
84
|
+
intent_id, job_id, chain_id, signer_address, signer_alias, idempotency_key,
|
|
85
|
+
to_address, data, value_wei, gas_limit, max_fee_per_gas,
|
|
86
|
+
max_priority_fee_per_gas, min_confirmations, deadline_ts,
|
|
87
|
+
broadcast_group, broadcast_endpoints_json, retry_after, status,
|
|
88
|
+
metadata_json
|
|
89
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'created', ?)
|
|
90
|
+
""",
|
|
91
|
+
(
|
|
92
|
+
str(intent_id),
|
|
93
|
+
job_id,
|
|
94
|
+
chain_id,
|
|
95
|
+
signer_address,
|
|
96
|
+
signer_alias,
|
|
97
|
+
idempotency_key,
|
|
98
|
+
to_address,
|
|
99
|
+
data,
|
|
100
|
+
value_wei,
|
|
101
|
+
gas_limit,
|
|
102
|
+
max_fee_per_gas,
|
|
103
|
+
max_priority_fee_per_gas,
|
|
104
|
+
min_confirmations,
|
|
105
|
+
deadline_ts,
|
|
106
|
+
broadcast_group,
|
|
107
|
+
json.dumps(broadcast_endpoints) if broadcast_endpoints else None,
|
|
108
|
+
json.dumps(metadata) if metadata else None,
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
return get_intent(db, intent_id)
|
|
112
|
+
except sqlite3.IntegrityError:
|
|
113
|
+
return None
|
|
114
|
+
except DatabaseError as e:
|
|
115
|
+
if "UNIQUE constraint failed" in str(e):
|
|
116
|
+
return None
|
|
117
|
+
raise
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_intent(db: Any, intent_id: UUID) -> TxIntent | None:
|
|
121
|
+
row = db.execute_one(
|
|
122
|
+
"SELECT * FROM tx_intents WHERE intent_id = ?",
|
|
123
|
+
(str(intent_id),),
|
|
124
|
+
)
|
|
125
|
+
if not row:
|
|
126
|
+
return None
|
|
127
|
+
return mappers._row_to_intent(row)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_intent_by_idempotency_key(
|
|
131
|
+
db: Any,
|
|
132
|
+
chain_id: int,
|
|
133
|
+
signer_address: str,
|
|
134
|
+
idempotency_key: str,
|
|
135
|
+
) -> TxIntent | None:
|
|
136
|
+
signer_address = db._normalize_address(signer_address)
|
|
137
|
+
row = db.execute_one(
|
|
138
|
+
"SELECT * FROM tx_intents WHERE chain_id = ? AND signer_address = ? AND idempotency_key = ?",
|
|
139
|
+
(chain_id, signer_address, idempotency_key),
|
|
140
|
+
)
|
|
141
|
+
if not row:
|
|
142
|
+
return None
|
|
143
|
+
return mappers._row_to_intent(row)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_intents_by_status(
|
|
147
|
+
db: Any,
|
|
148
|
+
status: str | list[str],
|
|
149
|
+
chain_id: int | None = None,
|
|
150
|
+
job_id: str | None = None,
|
|
151
|
+
limit: int = 100,
|
|
152
|
+
) -> list[TxIntent]:
|
|
153
|
+
if isinstance(status, str):
|
|
154
|
+
status = [status]
|
|
155
|
+
|
|
156
|
+
placeholders = ",".join("?" * len(status))
|
|
157
|
+
query = f"SELECT * FROM tx_intents WHERE status IN ({placeholders})"
|
|
158
|
+
params: list[Any] = list(status)
|
|
159
|
+
|
|
160
|
+
if chain_id is not None:
|
|
161
|
+
query += " AND chain_id = ?"
|
|
162
|
+
params.append(chain_id)
|
|
163
|
+
if job_id is not None:
|
|
164
|
+
query += " AND job_id = ?"
|
|
165
|
+
params.append(job_id)
|
|
166
|
+
|
|
167
|
+
query += " ORDER BY created_at ASC LIMIT ?"
|
|
168
|
+
params.append(limit)
|
|
169
|
+
|
|
170
|
+
rows = db.execute_returning(query, tuple(params))
|
|
171
|
+
return [mappers._row_to_intent(row) for row in rows]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def list_intents_filtered(
|
|
175
|
+
db: Any,
|
|
176
|
+
status: str | None = None,
|
|
177
|
+
job_id: str | None = None,
|
|
178
|
+
limit: int = 50,
|
|
179
|
+
) -> list[dict[str, Any]]:
|
|
180
|
+
query = "SELECT * FROM tx_intents WHERE 1=1"
|
|
181
|
+
params: list[Any] = []
|
|
182
|
+
|
|
183
|
+
if status is not None:
|
|
184
|
+
query += " AND status = ?"
|
|
185
|
+
params.append(status)
|
|
186
|
+
if job_id is not None:
|
|
187
|
+
query += " AND job_id = ?"
|
|
188
|
+
params.append(job_id)
|
|
189
|
+
|
|
190
|
+
query += " ORDER BY created_at DESC LIMIT ?"
|
|
191
|
+
params.append(limit)
|
|
192
|
+
|
|
193
|
+
return db.execute_returning(query, tuple(params))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_active_intent_count(db: Any, job_id: str, chain_id: int | None = None) -> int:
|
|
197
|
+
statuses = [
|
|
198
|
+
IntentStatus.CREATED.value,
|
|
199
|
+
IntentStatus.CLAIMED.value,
|
|
200
|
+
IntentStatus.SENDING.value,
|
|
201
|
+
IntentStatus.PENDING.value,
|
|
202
|
+
]
|
|
203
|
+
placeholders = ",".join("?" * len(statuses))
|
|
204
|
+
query = (
|
|
205
|
+
f"SELECT COUNT(*) AS count FROM tx_intents WHERE status IN ({placeholders}) AND job_id = ?"
|
|
206
|
+
)
|
|
207
|
+
params: list[Any] = list(statuses)
|
|
208
|
+
params.append(job_id)
|
|
209
|
+
if chain_id is not None:
|
|
210
|
+
query += " AND chain_id = ?"
|
|
211
|
+
params.append(chain_id)
|
|
212
|
+
row = db.execute_one(query, tuple(params))
|
|
213
|
+
return int(row["count"]) if row else 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def get_pending_intent_count(db: Any, chain_id: int | None = None) -> int:
|
|
217
|
+
statuses = [
|
|
218
|
+
IntentStatus.CREATED.value,
|
|
219
|
+
IntentStatus.CLAIMED.value,
|
|
220
|
+
IntentStatus.SENDING.value,
|
|
221
|
+
IntentStatus.PENDING.value,
|
|
222
|
+
]
|
|
223
|
+
placeholders = ",".join("?" * len(statuses))
|
|
224
|
+
query = f"SELECT COUNT(*) AS count FROM tx_intents WHERE status IN ({placeholders})"
|
|
225
|
+
params: list[Any] = list(statuses)
|
|
226
|
+
if chain_id is not None:
|
|
227
|
+
query += " AND chain_id = ?"
|
|
228
|
+
params.append(chain_id)
|
|
229
|
+
row = db.execute_one(query, tuple(params))
|
|
230
|
+
return int(row["count"]) if row else 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def get_backing_off_intent_count(db: Any, chain_id: int | None = None) -> int:
|
|
234
|
+
query = "SELECT COUNT(*) AS count FROM tx_intents WHERE retry_after > CURRENT_TIMESTAMP"
|
|
235
|
+
params: list[Any] = []
|
|
236
|
+
if chain_id is not None:
|
|
237
|
+
query += " AND chain_id = ?"
|
|
238
|
+
params.append(chain_id)
|
|
239
|
+
row = db.execute_one(query, tuple(params))
|
|
240
|
+
return int(row["count"]) if row else 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def get_oldest_pending_intent_age(db: Any, chain_id: int) -> float | None:
|
|
244
|
+
query = """
|
|
245
|
+
SELECT (julianday('now') - julianday(MIN(created_at))) * 86400 AS age_seconds
|
|
246
|
+
FROM tx_intents
|
|
247
|
+
WHERE chain_id = ?
|
|
248
|
+
AND status IN ('created', 'pending', 'claimed', 'sending')
|
|
249
|
+
"""
|
|
250
|
+
result = db.execute_one(query, (chain_id,))
|
|
251
|
+
if result and result.get("age_seconds") is not None:
|
|
252
|
+
return result["age_seconds"]
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def list_intent_inconsistencies(
|
|
257
|
+
db: Any,
|
|
258
|
+
max_age_seconds: int,
|
|
259
|
+
limit: int = 100,
|
|
260
|
+
chain_id: int | None = None,
|
|
261
|
+
) -> list[dict[str, Any]]:
|
|
262
|
+
chain_clause = ""
|
|
263
|
+
chain_params: list[Any] = []
|
|
264
|
+
if chain_id is not None:
|
|
265
|
+
chain_clause = " AND chain_id = ?"
|
|
266
|
+
chain_params = [chain_id] * 5
|
|
267
|
+
|
|
268
|
+
query = f"""
|
|
269
|
+
SELECT intent_id, status, 'pending_no_attempt' AS reason
|
|
270
|
+
FROM tx_intents
|
|
271
|
+
WHERE status = 'pending'
|
|
272
|
+
{chain_clause}
|
|
273
|
+
AND NOT EXISTS (
|
|
274
|
+
SELECT 1 FROM tx_attempts
|
|
275
|
+
WHERE tx_attempts.intent_id = tx_intents.intent_id
|
|
276
|
+
AND tx_attempts.tx_hash IS NOT NULL
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
UNION ALL
|
|
280
|
+
SELECT intent_id, status, 'confirmed_no_confirmed_attempt' AS reason
|
|
281
|
+
FROM tx_intents
|
|
282
|
+
WHERE status = 'confirmed'
|
|
283
|
+
{chain_clause}
|
|
284
|
+
AND NOT EXISTS (
|
|
285
|
+
SELECT 1 FROM tx_attempts
|
|
286
|
+
WHERE tx_attempts.intent_id = tx_intents.intent_id
|
|
287
|
+
AND tx_attempts.status = 'confirmed'
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
UNION ALL
|
|
291
|
+
SELECT intent_id, status, 'claimed_missing_claim' AS reason
|
|
292
|
+
FROM tx_intents
|
|
293
|
+
WHERE status = 'claimed'
|
|
294
|
+
{chain_clause}
|
|
295
|
+
AND (claim_token IS NULL OR claimed_at IS NULL OR lease_expires_at IS NULL)
|
|
296
|
+
|
|
297
|
+
UNION ALL
|
|
298
|
+
SELECT intent_id, status, 'nonclaimed_with_claim' AS reason
|
|
299
|
+
FROM tx_intents
|
|
300
|
+
WHERE status != 'claimed'
|
|
301
|
+
{chain_clause}
|
|
302
|
+
AND (claim_token IS NOT NULL OR claimed_at IS NOT NULL OR lease_expires_at IS NOT NULL)
|
|
303
|
+
|
|
304
|
+
UNION ALL
|
|
305
|
+
SELECT intent_id, status, 'sending_stuck' AS reason
|
|
306
|
+
FROM tx_intents
|
|
307
|
+
WHERE status = 'sending'
|
|
308
|
+
{chain_clause}
|
|
309
|
+
AND updated_at < datetime('now', ? || ' seconds')
|
|
310
|
+
|
|
311
|
+
LIMIT ?
|
|
312
|
+
"""
|
|
313
|
+
params_with_age = chain_params + [f"-{max_age_seconds}", limit]
|
|
314
|
+
rows = db.execute_returning(query, tuple(params_with_age))
|
|
315
|
+
return [dict(row) for row in rows]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def list_sending_intents_older_than(
|
|
319
|
+
db: Any,
|
|
320
|
+
max_age_seconds: int,
|
|
321
|
+
limit: int = 100,
|
|
322
|
+
chain_id: int | None = None,
|
|
323
|
+
) -> list[TxIntent]:
|
|
324
|
+
query = """
|
|
325
|
+
SELECT * FROM tx_intents
|
|
326
|
+
WHERE status = 'sending'
|
|
327
|
+
AND updated_at < datetime('now', ? || ' seconds')
|
|
328
|
+
"""
|
|
329
|
+
params: list[Any] = [f"-{max_age_seconds}"]
|
|
330
|
+
if chain_id is not None:
|
|
331
|
+
query += " AND chain_id = ?"
|
|
332
|
+
params.append(chain_id)
|
|
333
|
+
query += " ORDER BY updated_at ASC LIMIT ?"
|
|
334
|
+
params.append(limit)
|
|
335
|
+
rows = db.execute_returning(query, tuple(params))
|
|
336
|
+
return [mappers._row_to_intent(row) for row in rows]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def list_claimed_intents_older_than(
|
|
340
|
+
db: Any,
|
|
341
|
+
max_age_seconds: int,
|
|
342
|
+
limit: int = 100,
|
|
343
|
+
chain_id: int | None = None,
|
|
344
|
+
) -> list[TxIntent]:
|
|
345
|
+
query = """
|
|
346
|
+
SELECT * FROM tx_intents
|
|
347
|
+
WHERE status = 'claimed'
|
|
348
|
+
AND COALESCE(lease_expires_at, datetime(claimed_at, ? || ' seconds')) < CURRENT_TIMESTAMP
|
|
349
|
+
AND EXISTS (
|
|
350
|
+
SELECT 1 FROM tx_attempts WHERE tx_attempts.intent_id = tx_intents.intent_id
|
|
351
|
+
)
|
|
352
|
+
"""
|
|
353
|
+
params: list[Any] = [f"+{max_age_seconds}"]
|
|
354
|
+
if chain_id is not None:
|
|
355
|
+
query += " AND chain_id = ?"
|
|
356
|
+
params.append(chain_id)
|
|
357
|
+
query += " ORDER BY updated_at ASC LIMIT ?"
|
|
358
|
+
params.append(limit)
|
|
359
|
+
rows = db.execute_returning(query, tuple(params))
|
|
360
|
+
return [mappers._row_to_intent(row) for row in rows]
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def claim_next_intent(
|
|
364
|
+
db: Any,
|
|
365
|
+
claim_token: str,
|
|
366
|
+
claimed_by: str | None = None,
|
|
367
|
+
lease_seconds: int | None = None,
|
|
368
|
+
) -> ClaimedIntent | None:
|
|
369
|
+
if lease_seconds is None:
|
|
370
|
+
raise DatabaseError("lease_seconds is required for claim_next_intent")
|
|
371
|
+
if lease_seconds <= 0:
|
|
372
|
+
raise DatabaseError("lease_seconds must be positive for claim_next_intent")
|
|
373
|
+
with tx.transaction_conn(db, tx.SQLiteBeginMode.IMMEDIATE) as conn:
|
|
374
|
+
cursor = conn.cursor()
|
|
375
|
+
try:
|
|
376
|
+
cursor.execute(
|
|
377
|
+
"""
|
|
378
|
+
UPDATE tx_intents
|
|
379
|
+
SET status = 'claimed', claim_token = ?, claimed_at = CURRENT_TIMESTAMP,
|
|
380
|
+
claimed_by = ?,
|
|
381
|
+
lease_expires_at = datetime('now', ? || ' seconds'),
|
|
382
|
+
retry_after = NULL,
|
|
383
|
+
updated_at = CURRENT_TIMESTAMP
|
|
384
|
+
WHERE intent_id = (
|
|
385
|
+
SELECT intent_id FROM tx_intents
|
|
386
|
+
WHERE status = 'created'
|
|
387
|
+
AND (deadline_ts IS NULL OR deadline_ts > CURRENT_TIMESTAMP)
|
|
388
|
+
AND (retry_after IS NULL OR retry_after <= CURRENT_TIMESTAMP)
|
|
389
|
+
ORDER BY created_at ASC, intent_id ASC
|
|
390
|
+
LIMIT 1
|
|
391
|
+
)
|
|
392
|
+
AND status = 'created'
|
|
393
|
+
""",
|
|
394
|
+
(claim_token, claimed_by, f"{int(lease_seconds)}"),
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
if cursor.rowcount == 0:
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
cursor.execute(
|
|
401
|
+
"SELECT * FROM tx_intents WHERE claim_token = ? AND status = 'claimed'",
|
|
402
|
+
(claim_token,),
|
|
403
|
+
)
|
|
404
|
+
row = cursor.fetchone()
|
|
405
|
+
if row:
|
|
406
|
+
return mappers._row_to_claimed_intent(dict(row))
|
|
407
|
+
return None
|
|
408
|
+
finally:
|
|
409
|
+
cursor.close()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def update_intent_status(
|
|
413
|
+
db: Any,
|
|
414
|
+
intent_id: UUID,
|
|
415
|
+
status: str,
|
|
416
|
+
claim_token: str | None = None,
|
|
417
|
+
) -> bool:
|
|
418
|
+
with tx.transaction_conn(db) as conn:
|
|
419
|
+
cursor = conn.cursor()
|
|
420
|
+
try:
|
|
421
|
+
if claim_token:
|
|
422
|
+
cursor.execute(
|
|
423
|
+
"""
|
|
424
|
+
UPDATE tx_intents SET status = ?, claim_token = ?,
|
|
425
|
+
claimed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
|
426
|
+
WHERE intent_id = ?
|
|
427
|
+
""",
|
|
428
|
+
(status, claim_token, str(intent_id)),
|
|
429
|
+
)
|
|
430
|
+
else:
|
|
431
|
+
cursor.execute(
|
|
432
|
+
"""
|
|
433
|
+
UPDATE tx_intents SET status = ?, updated_at = CURRENT_TIMESTAMP
|
|
434
|
+
WHERE intent_id = ?
|
|
435
|
+
""",
|
|
436
|
+
(status, str(intent_id)),
|
|
437
|
+
)
|
|
438
|
+
return cursor.rowcount > 0
|
|
439
|
+
finally:
|
|
440
|
+
cursor.close()
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def update_intent_status_if(
|
|
444
|
+
db: Any,
|
|
445
|
+
intent_id: UUID,
|
|
446
|
+
status: str,
|
|
447
|
+
expected_status: str | list[str],
|
|
448
|
+
) -> bool:
|
|
449
|
+
if isinstance(expected_status, str):
|
|
450
|
+
expected_status = [expected_status]
|
|
451
|
+
placeholders = ",".join("?" * len(expected_status))
|
|
452
|
+
with tx.transaction_conn(db) as conn:
|
|
453
|
+
cursor = conn.cursor()
|
|
454
|
+
try:
|
|
455
|
+
cursor.execute(
|
|
456
|
+
f"""
|
|
457
|
+
UPDATE tx_intents SET status = ?, updated_at = CURRENT_TIMESTAMP
|
|
458
|
+
WHERE intent_id = ? AND status IN ({placeholders})
|
|
459
|
+
""",
|
|
460
|
+
(status, str(intent_id), *expected_status),
|
|
461
|
+
)
|
|
462
|
+
return cursor.rowcount > 0
|
|
463
|
+
finally:
|
|
464
|
+
cursor.close()
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def transition_intent_status_immediate(
|
|
468
|
+
db: Any,
|
|
469
|
+
intent_id: UUID,
|
|
470
|
+
from_statuses: list[str],
|
|
471
|
+
to_status: str,
|
|
472
|
+
) -> tuple[bool, str | None]:
|
|
473
|
+
placeholders = ",".join("?" * len(from_statuses))
|
|
474
|
+
with tx.transaction_conn(db, tx.SQLiteBeginMode.IMMEDIATE) as conn:
|
|
475
|
+
cursor = conn.cursor()
|
|
476
|
+
try:
|
|
477
|
+
cursor.execute(
|
|
478
|
+
"SELECT status FROM tx_intents WHERE intent_id = ?",
|
|
479
|
+
(str(intent_id),),
|
|
480
|
+
)
|
|
481
|
+
row = cursor.fetchone()
|
|
482
|
+
if not row:
|
|
483
|
+
return (False, None)
|
|
484
|
+
|
|
485
|
+
old_status = row[0]
|
|
486
|
+
|
|
487
|
+
if old_status not in from_statuses:
|
|
488
|
+
return (False, None)
|
|
489
|
+
|
|
490
|
+
should_clear_claim = old_status == "claimed" and to_status != "claimed"
|
|
491
|
+
|
|
492
|
+
if should_clear_claim:
|
|
493
|
+
cursor.execute(
|
|
494
|
+
f"""
|
|
495
|
+
UPDATE tx_intents
|
|
496
|
+
SET status = ?,
|
|
497
|
+
updated_at = CURRENT_TIMESTAMP,
|
|
498
|
+
claim_token = NULL,
|
|
499
|
+
claimed_at = NULL,
|
|
500
|
+
claimed_by = NULL,
|
|
501
|
+
lease_expires_at = NULL
|
|
502
|
+
WHERE intent_id = ? AND status IN ({placeholders})
|
|
503
|
+
""",
|
|
504
|
+
(to_status, str(intent_id), *from_statuses),
|
|
505
|
+
)
|
|
506
|
+
else:
|
|
507
|
+
cursor.execute(
|
|
508
|
+
f"""
|
|
509
|
+
UPDATE tx_intents
|
|
510
|
+
SET status = ?, updated_at = CURRENT_TIMESTAMP
|
|
511
|
+
WHERE intent_id = ? AND status IN ({placeholders})
|
|
512
|
+
""",
|
|
513
|
+
(to_status, str(intent_id), *from_statuses),
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
if cursor.rowcount == 0:
|
|
517
|
+
return (False, None)
|
|
518
|
+
|
|
519
|
+
return (True, old_status)
|
|
520
|
+
finally:
|
|
521
|
+
cursor.close()
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def update_intent_signer(db: Any, intent_id: UUID, signer_address: str) -> bool:
|
|
525
|
+
signer_address = db._normalize_address(signer_address)
|
|
526
|
+
with tx.transaction_conn(db) as conn:
|
|
527
|
+
cursor = conn.cursor()
|
|
528
|
+
try:
|
|
529
|
+
cursor.execute(
|
|
530
|
+
"""
|
|
531
|
+
UPDATE tx_intents SET signer_address = ?, updated_at = CURRENT_TIMESTAMP
|
|
532
|
+
WHERE intent_id = ?
|
|
533
|
+
""",
|
|
534
|
+
(signer_address, str(intent_id)),
|
|
535
|
+
)
|
|
536
|
+
return cursor.rowcount > 0
|
|
537
|
+
finally:
|
|
538
|
+
cursor.close()
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def release_intent_claim(db: Any, intent_id: UUID) -> bool:
|
|
542
|
+
with tx.transaction_conn(db) as conn:
|
|
543
|
+
cursor = conn.cursor()
|
|
544
|
+
try:
|
|
545
|
+
cursor.execute(
|
|
546
|
+
"""
|
|
547
|
+
UPDATE tx_intents SET status = 'created', claim_token = NULL,
|
|
548
|
+
claimed_at = NULL, lease_expires_at = NULL, updated_at = CURRENT_TIMESTAMP
|
|
549
|
+
WHERE intent_id = ? AND status = 'claimed'
|
|
550
|
+
""",
|
|
551
|
+
(str(intent_id),),
|
|
552
|
+
)
|
|
553
|
+
return cursor.rowcount > 0
|
|
554
|
+
finally:
|
|
555
|
+
cursor.close()
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def release_intent_claim_if_token(db: Any, intent_id: UUID, claim_token: str) -> bool:
|
|
559
|
+
rowcount = db.execute_returning_rowcount(
|
|
560
|
+
"""
|
|
561
|
+
UPDATE tx_intents
|
|
562
|
+
SET status = 'created',
|
|
563
|
+
claim_token = NULL,
|
|
564
|
+
claimed_at = NULL,
|
|
565
|
+
claimed_by = NULL,
|
|
566
|
+
lease_expires_at = NULL,
|
|
567
|
+
updated_at = CURRENT_TIMESTAMP
|
|
568
|
+
WHERE intent_id = ? AND claim_token = ? AND status = 'claimed'
|
|
569
|
+
""",
|
|
570
|
+
(str(intent_id), claim_token),
|
|
571
|
+
)
|
|
572
|
+
return rowcount == 1
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def release_claim_if_token_and_no_attempts(db: Any, intent_id: UUID, claim_token: str) -> bool:
|
|
576
|
+
rowcount = db.execute_returning_rowcount(
|
|
577
|
+
"""
|
|
578
|
+
UPDATE tx_intents
|
|
579
|
+
SET status = 'created',
|
|
580
|
+
claim_token = NULL,
|
|
581
|
+
claimed_at = NULL,
|
|
582
|
+
claimed_by = NULL,
|
|
583
|
+
lease_expires_at = NULL,
|
|
584
|
+
updated_at = CURRENT_TIMESTAMP
|
|
585
|
+
WHERE intent_id = ?
|
|
586
|
+
AND claim_token = ?
|
|
587
|
+
AND status = 'claimed'
|
|
588
|
+
AND NOT EXISTS (
|
|
589
|
+
SELECT 1 FROM tx_attempts
|
|
590
|
+
WHERE tx_attempts.intent_id = tx_intents.intent_id
|
|
591
|
+
)
|
|
592
|
+
""",
|
|
593
|
+
(str(intent_id), claim_token),
|
|
594
|
+
)
|
|
595
|
+
return rowcount == 1
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def clear_intent_claim(db: Any, intent_id: UUID) -> bool:
|
|
599
|
+
with tx.transaction_conn(db) as conn:
|
|
600
|
+
cursor = conn.cursor()
|
|
601
|
+
try:
|
|
602
|
+
cursor.execute(
|
|
603
|
+
"""
|
|
604
|
+
UPDATE tx_intents
|
|
605
|
+
SET claim_token = NULL, claimed_at = NULL, lease_expires_at = NULL,
|
|
606
|
+
updated_at = CURRENT_TIMESTAMP
|
|
607
|
+
WHERE intent_id = ?
|
|
608
|
+
""",
|
|
609
|
+
(str(intent_id),),
|
|
610
|
+
)
|
|
611
|
+
return cursor.rowcount > 0
|
|
612
|
+
finally:
|
|
613
|
+
cursor.close()
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def set_intent_retry_after(db: Any, intent_id: UUID, retry_after: datetime | None) -> bool:
|
|
617
|
+
with tx.transaction_conn(db) as conn:
|
|
618
|
+
cursor = conn.cursor()
|
|
619
|
+
try:
|
|
620
|
+
cursor.execute(
|
|
621
|
+
"""
|
|
622
|
+
UPDATE tx_intents
|
|
623
|
+
SET retry_after = ?, updated_at = CURRENT_TIMESTAMP
|
|
624
|
+
WHERE intent_id = ?
|
|
625
|
+
""",
|
|
626
|
+
(retry_after, str(intent_id)),
|
|
627
|
+
)
|
|
628
|
+
return cursor.rowcount > 0
|
|
629
|
+
finally:
|
|
630
|
+
cursor.close()
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def increment_intent_retry_count(db: Any, intent_id: UUID) -> int:
|
|
634
|
+
with tx.transaction_conn(db) as conn:
|
|
635
|
+
cursor = conn.cursor()
|
|
636
|
+
try:
|
|
637
|
+
cursor.execute(
|
|
638
|
+
"""
|
|
639
|
+
UPDATE tx_intents
|
|
640
|
+
SET retry_count = retry_count + 1, updated_at = CURRENT_TIMESTAMP
|
|
641
|
+
WHERE intent_id = ?
|
|
642
|
+
""",
|
|
643
|
+
(str(intent_id),),
|
|
644
|
+
)
|
|
645
|
+
if cursor.rowcount == 0:
|
|
646
|
+
return 0
|
|
647
|
+
cursor.execute(
|
|
648
|
+
"SELECT retry_count FROM tx_intents WHERE intent_id = ?",
|
|
649
|
+
(str(intent_id),),
|
|
650
|
+
)
|
|
651
|
+
row = cursor.fetchone()
|
|
652
|
+
return row[0] if row else 0
|
|
653
|
+
finally:
|
|
654
|
+
cursor.close()
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def should_create_intent(
|
|
658
|
+
db: Any,
|
|
659
|
+
cooldown_key: str,
|
|
660
|
+
now: int,
|
|
661
|
+
cooldown_seconds: int,
|
|
662
|
+
) -> tuple[bool, int | None]:
|
|
663
|
+
with tx.transaction_conn(db, tx.SQLiteBeginMode.IMMEDIATE) as conn:
|
|
664
|
+
cursor = conn.cursor()
|
|
665
|
+
try:
|
|
666
|
+
cursor.execute(
|
|
667
|
+
"SELECT last_intent_at FROM job_cooldowns WHERE cooldown_key = ?",
|
|
668
|
+
(cooldown_key,),
|
|
669
|
+
)
|
|
670
|
+
row = cursor.fetchone()
|
|
671
|
+
if row is None:
|
|
672
|
+
cursor.execute(
|
|
673
|
+
"INSERT INTO job_cooldowns (cooldown_key, last_intent_at) VALUES (?, ?)",
|
|
674
|
+
(cooldown_key, now),
|
|
675
|
+
)
|
|
676
|
+
return True, None
|
|
677
|
+
|
|
678
|
+
last_intent_at = int(row[0])
|
|
679
|
+
if now - last_intent_at >= cooldown_seconds:
|
|
680
|
+
cursor.execute(
|
|
681
|
+
"UPDATE job_cooldowns SET last_intent_at = ? WHERE cooldown_key = ?",
|
|
682
|
+
(now, cooldown_key),
|
|
683
|
+
)
|
|
684
|
+
return True, last_intent_at
|
|
685
|
+
|
|
686
|
+
return False, last_intent_at
|
|
687
|
+
finally:
|
|
688
|
+
cursor.close()
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def prune_job_cooldowns(db: Any, older_than_days: int) -> int:
|
|
692
|
+
if older_than_days <= 0:
|
|
693
|
+
return 0
|
|
694
|
+
cutoff = int(time.time()) - (older_than_days * 86400)
|
|
695
|
+
rowcount = db.execute_returning_rowcount(
|
|
696
|
+
"DELETE FROM job_cooldowns WHERE last_intent_at < ?",
|
|
697
|
+
(cutoff,),
|
|
698
|
+
)
|
|
699
|
+
return rowcount
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def requeue_expired_claims_no_attempts(
|
|
703
|
+
db: Any,
|
|
704
|
+
limit: int,
|
|
705
|
+
grace_seconds: int,
|
|
706
|
+
chain_id: int | None = None,
|
|
707
|
+
) -> int:
|
|
708
|
+
if limit <= 0:
|
|
709
|
+
return 0
|
|
710
|
+
offset = f"-{grace_seconds} seconds"
|
|
711
|
+
params: list[Any] = [offset]
|
|
712
|
+
chain_clause = ""
|
|
713
|
+
if chain_id is not None:
|
|
714
|
+
chain_clause = "AND chain_id = ?"
|
|
715
|
+
params.append(chain_id)
|
|
716
|
+
params.append(limit)
|
|
717
|
+
|
|
718
|
+
with tx.transaction_conn(db, tx.SQLiteBeginMode.IMMEDIATE) as conn:
|
|
719
|
+
cursor = conn.cursor()
|
|
720
|
+
try:
|
|
721
|
+
cursor.execute(
|
|
722
|
+
f"""
|
|
723
|
+
WITH expired AS (
|
|
724
|
+
SELECT intent_id
|
|
725
|
+
FROM tx_intents
|
|
726
|
+
WHERE status = 'claimed'
|
|
727
|
+
AND lease_expires_at IS NOT NULL
|
|
728
|
+
AND lease_expires_at < datetime('now', ?)
|
|
729
|
+
{chain_clause}
|
|
730
|
+
AND NOT EXISTS (
|
|
731
|
+
SELECT 1 FROM tx_attempts
|
|
732
|
+
WHERE tx_attempts.intent_id = tx_intents.intent_id
|
|
733
|
+
)
|
|
734
|
+
ORDER BY lease_expires_at ASC, intent_id ASC
|
|
735
|
+
LIMIT ?
|
|
736
|
+
)
|
|
737
|
+
UPDATE tx_intents
|
|
738
|
+
SET status = 'created',
|
|
739
|
+
claim_token = NULL,
|
|
740
|
+
claimed_at = NULL,
|
|
741
|
+
claimed_by = NULL,
|
|
742
|
+
lease_expires_at = NULL,
|
|
743
|
+
updated_at = CURRENT_TIMESTAMP
|
|
744
|
+
WHERE intent_id IN (SELECT intent_id FROM expired)
|
|
745
|
+
AND status = 'claimed'
|
|
746
|
+
AND NOT EXISTS (
|
|
747
|
+
SELECT 1 FROM tx_attempts
|
|
748
|
+
WHERE tx_attempts.intent_id = tx_intents.intent_id
|
|
749
|
+
)
|
|
750
|
+
""",
|
|
751
|
+
tuple(params),
|
|
752
|
+
)
|
|
753
|
+
cursor.execute("SELECT changes()")
|
|
754
|
+
row = cursor.fetchone()
|
|
755
|
+
return row[0] if row else 0
|
|
756
|
+
finally:
|
|
757
|
+
cursor.close()
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def count_expired_claims_with_attempts(
|
|
761
|
+
db: Any,
|
|
762
|
+
limit: int,
|
|
763
|
+
grace_seconds: int,
|
|
764
|
+
chain_id: int | None = None,
|
|
765
|
+
) -> int:
|
|
766
|
+
if limit <= 0:
|
|
767
|
+
return 0
|
|
768
|
+
offset = f"-{grace_seconds} seconds"
|
|
769
|
+
params: list[Any] = [offset]
|
|
770
|
+
chain_clause = ""
|
|
771
|
+
if chain_id is not None:
|
|
772
|
+
chain_clause = "AND chain_id = ?"
|
|
773
|
+
params.append(chain_id)
|
|
774
|
+
params.append(limit)
|
|
775
|
+
row = db.execute_one(
|
|
776
|
+
f"""
|
|
777
|
+
SELECT COUNT(*) AS count FROM (
|
|
778
|
+
SELECT intent_id
|
|
779
|
+
FROM tx_intents
|
|
780
|
+
WHERE status = 'claimed'
|
|
781
|
+
AND lease_expires_at IS NOT NULL
|
|
782
|
+
AND lease_expires_at < datetime('now', ?)
|
|
783
|
+
{chain_clause}
|
|
784
|
+
AND EXISTS (
|
|
785
|
+
SELECT 1 FROM tx_attempts
|
|
786
|
+
WHERE tx_attempts.intent_id = tx_intents.intent_id
|
|
787
|
+
)
|
|
788
|
+
ORDER BY lease_expires_at ASC, intent_id ASC
|
|
789
|
+
LIMIT ?
|
|
790
|
+
)
|
|
791
|
+
""",
|
|
792
|
+
tuple(params),
|
|
793
|
+
)
|
|
794
|
+
return int(row["count"]) if row else 0
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def requeue_missing_lease_claims_no_attempts(
|
|
798
|
+
db: Any,
|
|
799
|
+
limit: int,
|
|
800
|
+
cutoff_seconds: int,
|
|
801
|
+
chain_id: int | None = None,
|
|
802
|
+
) -> int:
|
|
803
|
+
if limit <= 0:
|
|
804
|
+
return 0
|
|
805
|
+
offset = f"-{cutoff_seconds} seconds"
|
|
806
|
+
params: list[Any] = [offset]
|
|
807
|
+
chain_clause = ""
|
|
808
|
+
if chain_id is not None:
|
|
809
|
+
chain_clause = "AND chain_id = ?"
|
|
810
|
+
params.append(chain_id)
|
|
811
|
+
params.append(limit)
|
|
812
|
+
|
|
813
|
+
with tx.transaction_conn(db, tx.SQLiteBeginMode.IMMEDIATE) as conn:
|
|
814
|
+
cursor = conn.cursor()
|
|
815
|
+
try:
|
|
816
|
+
cursor.execute(
|
|
817
|
+
f"""
|
|
818
|
+
WITH stale AS (
|
|
819
|
+
SELECT intent_id
|
|
820
|
+
FROM tx_intents
|
|
821
|
+
WHERE status = 'claimed'
|
|
822
|
+
AND lease_expires_at IS NULL
|
|
823
|
+
AND claimed_at IS NOT NULL
|
|
824
|
+
AND claimed_at < datetime('now', ?)
|
|
825
|
+
{chain_clause}
|
|
826
|
+
AND NOT EXISTS (
|
|
827
|
+
SELECT 1 FROM tx_attempts
|
|
828
|
+
WHERE tx_attempts.intent_id = tx_intents.intent_id
|
|
829
|
+
)
|
|
830
|
+
ORDER BY claimed_at ASC, intent_id ASC
|
|
831
|
+
LIMIT ?
|
|
832
|
+
)
|
|
833
|
+
UPDATE tx_intents
|
|
834
|
+
SET status = 'created',
|
|
835
|
+
claim_token = NULL,
|
|
836
|
+
claimed_at = NULL,
|
|
837
|
+
claimed_by = NULL,
|
|
838
|
+
lease_expires_at = NULL,
|
|
839
|
+
updated_at = CURRENT_TIMESTAMP
|
|
840
|
+
WHERE intent_id IN (SELECT intent_id FROM stale)
|
|
841
|
+
AND status = 'claimed'
|
|
842
|
+
AND lease_expires_at IS NULL
|
|
843
|
+
AND NOT EXISTS (
|
|
844
|
+
SELECT 1 FROM tx_attempts
|
|
845
|
+
WHERE tx_attempts.intent_id = tx_intents.intent_id
|
|
846
|
+
)
|
|
847
|
+
""",
|
|
848
|
+
tuple(params),
|
|
849
|
+
)
|
|
850
|
+
cursor.execute("SELECT changes()")
|
|
851
|
+
row = cursor.fetchone()
|
|
852
|
+
return row[0] if row else 0
|
|
853
|
+
finally:
|
|
854
|
+
cursor.close()
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def count_missing_lease_claims_with_attempts(
|
|
858
|
+
db: Any,
|
|
859
|
+
limit: int,
|
|
860
|
+
cutoff_seconds: int,
|
|
861
|
+
chain_id: int | None = None,
|
|
862
|
+
) -> int:
|
|
863
|
+
if limit <= 0:
|
|
864
|
+
return 0
|
|
865
|
+
offset = f"-{cutoff_seconds} seconds"
|
|
866
|
+
params: list[Any] = [offset]
|
|
867
|
+
chain_clause = ""
|
|
868
|
+
if chain_id is not None:
|
|
869
|
+
chain_clause = "AND chain_id = ?"
|
|
870
|
+
params.append(chain_id)
|
|
871
|
+
params.append(limit)
|
|
872
|
+
row = db.execute_one(
|
|
873
|
+
f"""
|
|
874
|
+
SELECT COUNT(*) AS count FROM (
|
|
875
|
+
SELECT intent_id
|
|
876
|
+
FROM tx_intents
|
|
877
|
+
WHERE status = 'claimed'
|
|
878
|
+
AND lease_expires_at IS NULL
|
|
879
|
+
AND claimed_at IS NOT NULL
|
|
880
|
+
AND claimed_at < datetime('now', ?)
|
|
881
|
+
{chain_clause}
|
|
882
|
+
AND EXISTS (
|
|
883
|
+
SELECT 1 FROM tx_attempts
|
|
884
|
+
WHERE tx_attempts.intent_id = tx_intents.intent_id
|
|
885
|
+
)
|
|
886
|
+
ORDER BY claimed_at ASC, intent_id ASC
|
|
887
|
+
LIMIT ?
|
|
888
|
+
)
|
|
889
|
+
""",
|
|
890
|
+
tuple(params),
|
|
891
|
+
)
|
|
892
|
+
return int(row["count"]) if row else 0
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def abandon_intent(db: Any, intent_id: UUID) -> bool:
|
|
896
|
+
return update_intent_status(db, intent_id, "abandoned")
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def get_pending_intents_for_signer(db: Any, chain_id: int, address: str) -> list[TxIntent]:
|
|
900
|
+
address = db._normalize_address(address)
|
|
901
|
+
rows = db.execute_returning(
|
|
902
|
+
"""
|
|
903
|
+
SELECT * FROM tx_intents
|
|
904
|
+
WHERE chain_id = ? AND signer_address = ?
|
|
905
|
+
AND status IN ('sending', 'pending')
|
|
906
|
+
ORDER BY created_at
|
|
907
|
+
""",
|
|
908
|
+
(chain_id, address),
|
|
909
|
+
)
|
|
910
|
+
return [mappers._row_to_intent(row) for row in rows]
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def bind_broadcast_endpoints(
|
|
914
|
+
db: Any,
|
|
915
|
+
intent_id: UUID,
|
|
916
|
+
group_name: str | None,
|
|
917
|
+
endpoints: list[str],
|
|
918
|
+
) -> tuple[str | None, list[str]]:
|
|
919
|
+
canonical = db._canonicalize_endpoints(endpoints)
|
|
920
|
+
if not canonical:
|
|
921
|
+
raise DatabaseError("Broadcast endpoints list is empty after canonicalization")
|
|
922
|
+
with tx.transaction_conn(db, tx.SQLiteBeginMode.IMMEDIATE) as conn:
|
|
923
|
+
cursor = conn.cursor()
|
|
924
|
+
try:
|
|
925
|
+
cursor.execute(
|
|
926
|
+
"""
|
|
927
|
+
SELECT broadcast_group, broadcast_endpoints_json
|
|
928
|
+
FROM tx_intents
|
|
929
|
+
WHERE intent_id = ?
|
|
930
|
+
""",
|
|
931
|
+
(str(intent_id),),
|
|
932
|
+
)
|
|
933
|
+
row = cursor.fetchone()
|
|
934
|
+
if not row:
|
|
935
|
+
raise DatabaseError(f"Intent {intent_id} not found")
|
|
936
|
+
|
|
937
|
+
if row["broadcast_endpoints_json"] is not None:
|
|
938
|
+
stored = json.loads(row["broadcast_endpoints_json"])
|
|
939
|
+
stored_canonical = db._canonicalize_endpoints(stored)
|
|
940
|
+
if stored_canonical == canonical and (
|
|
941
|
+
group_name is None or row["broadcast_group"] == group_name
|
|
942
|
+
):
|
|
943
|
+
return row["broadcast_group"], stored_canonical
|
|
944
|
+
raise InvariantViolation(
|
|
945
|
+
f"Intent {intent_id} already bound with different endpoints"
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
binding_id = str(uuid4())
|
|
949
|
+
cursor.execute(
|
|
950
|
+
"""
|
|
951
|
+
UPDATE tx_intents
|
|
952
|
+
SET broadcast_group = ?,
|
|
953
|
+
broadcast_endpoints_json = ?,
|
|
954
|
+
broadcast_binding_id = ?,
|
|
955
|
+
updated_at = CURRENT_TIMESTAMP
|
|
956
|
+
WHERE intent_id = ?
|
|
957
|
+
AND broadcast_endpoints_json IS NULL
|
|
958
|
+
""",
|
|
959
|
+
(group_name, json.dumps(canonical), binding_id, str(intent_id)),
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
if cursor.rowcount != 1:
|
|
963
|
+
cursor.execute(
|
|
964
|
+
"""
|
|
965
|
+
SELECT broadcast_group, broadcast_endpoints_json
|
|
966
|
+
FROM tx_intents
|
|
967
|
+
WHERE intent_id = ?
|
|
968
|
+
""",
|
|
969
|
+
(str(intent_id),),
|
|
970
|
+
)
|
|
971
|
+
row = cursor.fetchone()
|
|
972
|
+
if not row or row["broadcast_endpoints_json"] is None:
|
|
973
|
+
raise InvariantViolation(
|
|
974
|
+
f"Binding race for intent {intent_id}: no binding persisted"
|
|
975
|
+
)
|
|
976
|
+
stored = json.loads(row["broadcast_endpoints_json"])
|
|
977
|
+
stored_canonical = db._canonicalize_endpoints(stored)
|
|
978
|
+
if stored_canonical == canonical and (
|
|
979
|
+
group_name is None or row["broadcast_group"] == group_name
|
|
980
|
+
):
|
|
981
|
+
return row["broadcast_group"], stored_canonical
|
|
982
|
+
raise InvariantViolation(
|
|
983
|
+
f"Intent {intent_id} already bound with different endpoints"
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
return group_name, canonical
|
|
987
|
+
finally:
|
|
988
|
+
cursor.close()
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def get_broadcast_binding(db: Any, intent_id: UUID) -> tuple[str | None, list[str]] | None:
|
|
992
|
+
row = db.execute_one(
|
|
993
|
+
"""
|
|
994
|
+
SELECT broadcast_group, broadcast_endpoints_json
|
|
995
|
+
FROM tx_intents
|
|
996
|
+
WHERE intent_id = ?
|
|
997
|
+
""",
|
|
998
|
+
(str(intent_id),),
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
if not row:
|
|
1002
|
+
return None
|
|
1003
|
+
|
|
1004
|
+
has_endpoints = row["broadcast_endpoints_json"] is not None
|
|
1005
|
+
if not has_endpoints:
|
|
1006
|
+
return None
|
|
1007
|
+
|
|
1008
|
+
endpoints = json.loads(row["broadcast_endpoints_json"])
|
|
1009
|
+
if not isinstance(endpoints, list):
|
|
1010
|
+
raise ValueError(
|
|
1011
|
+
f"Corrupt binding for intent {intent_id}: "
|
|
1012
|
+
f"endpoints_json is {type(endpoints).__name__}, expected list"
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
canonical = db._canonicalize_endpoints(endpoints)
|
|
1016
|
+
if not canonical:
|
|
1017
|
+
raise ValueError(
|
|
1018
|
+
f"Corrupt binding for intent {intent_id}: endpoints list is empty"
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
return row["broadcast_group"], canonical
|