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.
Files changed (135) hide show
  1. brawny/__init__.py +2 -0
  2. brawny/_context.py +5 -5
  3. brawny/_rpc/__init__.py +36 -12
  4. brawny/_rpc/broadcast.py +14 -13
  5. brawny/_rpc/caller.py +243 -0
  6. brawny/_rpc/client.py +539 -0
  7. brawny/_rpc/clients.py +11 -11
  8. brawny/_rpc/context.py +23 -0
  9. brawny/_rpc/errors.py +465 -31
  10. brawny/_rpc/gas.py +7 -6
  11. brawny/_rpc/pool.py +18 -0
  12. brawny/_rpc/retry.py +266 -0
  13. brawny/_rpc/retry_policy.py +81 -0
  14. brawny/accounts.py +28 -9
  15. brawny/alerts/__init__.py +15 -18
  16. brawny/alerts/abi_resolver.py +212 -36
  17. brawny/alerts/base.py +2 -2
  18. brawny/alerts/contracts.py +77 -10
  19. brawny/alerts/errors.py +30 -3
  20. brawny/alerts/events.py +38 -5
  21. brawny/alerts/health.py +19 -13
  22. brawny/alerts/send.py +513 -55
  23. brawny/api.py +39 -11
  24. brawny/assets/AGENTS.md +325 -0
  25. brawny/async_runtime.py +48 -0
  26. brawny/chain.py +3 -3
  27. brawny/cli/commands/__init__.py +2 -0
  28. brawny/cli/commands/console.py +69 -19
  29. brawny/cli/commands/contract.py +2 -2
  30. brawny/cli/commands/controls.py +121 -0
  31. brawny/cli/commands/health.py +2 -2
  32. brawny/cli/commands/job_dev.py +6 -5
  33. brawny/cli/commands/jobs.py +99 -2
  34. brawny/cli/commands/maintenance.py +13 -29
  35. brawny/cli/commands/migrate.py +1 -0
  36. brawny/cli/commands/run.py +10 -3
  37. brawny/cli/commands/script.py +8 -3
  38. brawny/cli/commands/signer.py +143 -26
  39. brawny/cli/helpers.py +0 -3
  40. brawny/cli_templates.py +25 -349
  41. brawny/config/__init__.py +4 -1
  42. brawny/config/models.py +43 -57
  43. brawny/config/parser.py +268 -57
  44. brawny/config/validation.py +52 -15
  45. brawny/daemon/context.py +4 -2
  46. brawny/daemon/core.py +185 -63
  47. brawny/daemon/loops.py +166 -98
  48. brawny/daemon/supervisor.py +261 -0
  49. brawny/db/__init__.py +14 -26
  50. brawny/db/base.py +248 -151
  51. brawny/db/global_cache.py +11 -1
  52. brawny/db/migrate.py +175 -28
  53. brawny/db/migrations/001_init.sql +4 -3
  54. brawny/db/migrations/010_add_nonce_gap_index.sql +1 -1
  55. brawny/db/migrations/011_add_job_logs.sql +1 -2
  56. brawny/db/migrations/012_add_claimed_by.sql +2 -2
  57. brawny/db/migrations/013_attempt_unique.sql +10 -0
  58. brawny/db/migrations/014_add_lease_expires_at.sql +5 -0
  59. brawny/db/migrations/015_add_signer_alias.sql +14 -0
  60. brawny/db/migrations/016_runtime_controls_and_quarantine.sql +32 -0
  61. brawny/db/migrations/017_add_job_drain.sql +6 -0
  62. brawny/db/migrations/018_add_nonce_reset_audit.sql +20 -0
  63. brawny/db/migrations/019_add_job_cooldowns.sql +8 -0
  64. brawny/db/migrations/020_attempt_unique_initial.sql +7 -0
  65. brawny/db/ops/__init__.py +3 -25
  66. brawny/db/ops/logs.py +1 -2
  67. brawny/db/queries.py +47 -91
  68. brawny/db/serialized.py +65 -0
  69. brawny/db/sqlite/__init__.py +1001 -0
  70. brawny/db/sqlite/connection.py +231 -0
  71. brawny/db/sqlite/execute.py +116 -0
  72. brawny/db/sqlite/mappers.py +190 -0
  73. brawny/db/sqlite/repos/attempts.py +372 -0
  74. brawny/db/sqlite/repos/block_state.py +102 -0
  75. brawny/db/sqlite/repos/cache.py +104 -0
  76. brawny/db/sqlite/repos/intents.py +1021 -0
  77. brawny/db/sqlite/repos/jobs.py +200 -0
  78. brawny/db/sqlite/repos/maintenance.py +182 -0
  79. brawny/db/sqlite/repos/signers_nonces.py +566 -0
  80. brawny/db/sqlite/tx.py +119 -0
  81. brawny/http.py +194 -0
  82. brawny/invariants.py +11 -24
  83. brawny/jobs/base.py +8 -0
  84. brawny/jobs/job_validation.py +2 -1
  85. brawny/keystore.py +83 -7
  86. brawny/lifecycle.py +64 -12
  87. brawny/logging.py +0 -2
  88. brawny/metrics.py +84 -12
  89. brawny/model/contexts.py +111 -9
  90. brawny/model/enums.py +1 -0
  91. brawny/model/errors.py +18 -0
  92. brawny/model/types.py +47 -131
  93. brawny/network_guard.py +133 -0
  94. brawny/networks/__init__.py +5 -5
  95. brawny/networks/config.py +1 -7
  96. brawny/networks/manager.py +14 -11
  97. brawny/runtime_controls.py +74 -0
  98. brawny/scheduler/poller.py +11 -7
  99. brawny/scheduler/reorg.py +95 -39
  100. brawny/scheduler/runner.py +442 -168
  101. brawny/scheduler/shutdown.py +3 -3
  102. brawny/script_tx.py +3 -3
  103. brawny/telegram.py +53 -7
  104. brawny/testing.py +1 -0
  105. brawny/timeout.py +38 -0
  106. brawny/tx/executor.py +922 -308
  107. brawny/tx/intent.py +54 -16
  108. brawny/tx/monitor.py +31 -12
  109. brawny/tx/nonce.py +212 -90
  110. brawny/tx/replacement.py +69 -18
  111. brawny/tx/retry_policy.py +24 -0
  112. brawny/tx/stages/types.py +75 -0
  113. brawny/types.py +18 -0
  114. brawny/utils.py +41 -0
  115. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/METADATA +3 -3
  116. brawny-0.1.22.dist-info/RECORD +163 -0
  117. brawny/_rpc/manager.py +0 -982
  118. brawny/_rpc/selector.py +0 -156
  119. brawny/db/base_new.py +0 -165
  120. brawny/db/mappers.py +0 -182
  121. brawny/db/migrations/008_add_transactions.sql +0 -72
  122. brawny/db/ops/attempts.py +0 -108
  123. brawny/db/ops/blocks.py +0 -83
  124. brawny/db/ops/cache.py +0 -93
  125. brawny/db/ops/intents.py +0 -296
  126. brawny/db/ops/jobs.py +0 -110
  127. brawny/db/ops/nonces.py +0 -322
  128. brawny/db/postgres.py +0 -2535
  129. brawny/db/postgres_new.py +0 -196
  130. brawny/db/sqlite.py +0 -2733
  131. brawny/db/sqlite_new.py +0 -191
  132. brawny-0.1.13.dist-info/RECORD +0 -141
  133. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/WHEEL +0 -0
  134. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/entry_points.txt +0 -0
  135. {brawny-0.1.13.dist-info → brawny-0.1.22.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,200 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+ from brawny.db.sqlite import mappers, tx
8
+ from brawny.model.types import JobConfig
9
+
10
+
11
+ def get_job(db: Any, job_id: str) -> JobConfig | None:
12
+ row = db.execute_one("SELECT * FROM jobs WHERE job_id = ?", (job_id,))
13
+ if not row:
14
+ return None
15
+ return mappers._row_to_job_config(row)
16
+
17
+
18
+ def get_enabled_jobs(db: Any) -> list[JobConfig]:
19
+ rows = db.execute_returning(
20
+ """
21
+ SELECT * FROM jobs
22
+ WHERE enabled = 1
23
+ AND (drain_until IS NULL OR drain_until <= CURRENT_TIMESTAMP)
24
+ ORDER BY job_id
25
+ """
26
+ )
27
+ return [mappers._row_to_job_config(row) for row in rows]
28
+
29
+
30
+ def list_all_jobs(db: Any) -> list[JobConfig]:
31
+ rows = db.execute_returning("SELECT * FROM jobs ORDER BY job_id")
32
+ return [mappers._row_to_job_config(row) for row in rows]
33
+
34
+
35
+ def upsert_job(
36
+ db: Any,
37
+ job_id: str,
38
+ job_name: str,
39
+ check_interval_blocks: int,
40
+ enabled: bool = True,
41
+ ) -> None:
42
+ db.execute(
43
+ """
44
+ INSERT INTO jobs (job_id, job_name, check_interval_blocks, enabled)
45
+ VALUES (?, ?, ?, ?)
46
+ ON CONFLICT(job_id) DO UPDATE SET
47
+ job_name = excluded.job_name,
48
+ check_interval_blocks = excluded.check_interval_blocks,
49
+ updated_at = CURRENT_TIMESTAMP
50
+ """,
51
+ (job_id, job_name, check_interval_blocks, enabled),
52
+ )
53
+
54
+
55
+ def update_job_checked(db: Any, job_id: str, block_number: int, triggered: bool = False) -> None:
56
+ if triggered:
57
+ db.execute(
58
+ """
59
+ UPDATE jobs SET
60
+ last_checked_block_number = ?,
61
+ last_triggered_block_number = ?,
62
+ updated_at = CURRENT_TIMESTAMP
63
+ WHERE job_id = ?
64
+ """,
65
+ (block_number, block_number, job_id),
66
+ )
67
+ return
68
+
69
+ db.execute(
70
+ """
71
+ UPDATE jobs SET
72
+ last_checked_block_number = ?,
73
+ updated_at = CURRENT_TIMESTAMP
74
+ WHERE job_id = ?
75
+ """,
76
+ (block_number, job_id),
77
+ )
78
+
79
+
80
+ def set_job_enabled(db: Any, job_id: str, enabled: bool) -> bool:
81
+ rowcount = db.execute_returning_rowcount(
82
+ "UPDATE jobs SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE job_id = ?",
83
+ (enabled, job_id),
84
+ )
85
+ return rowcount > 0
86
+
87
+
88
+ def set_job_drain(
89
+ db: Any,
90
+ job_id: str,
91
+ drain_until: datetime,
92
+ reason: str | None = None,
93
+ actor: str | None = None,
94
+ source: str | None = None,
95
+ ) -> bool:
96
+ with tx.transaction_conn(db) as conn:
97
+ cursor = conn.cursor()
98
+ try:
99
+ cursor.execute(
100
+ """
101
+ UPDATE jobs
102
+ SET drain_until = ?,
103
+ drain_reason = ?,
104
+ updated_at = CURRENT_TIMESTAMP
105
+ WHERE job_id = ?
106
+ """,
107
+ (drain_until.isoformat(), reason, job_id),
108
+ )
109
+ updated = cursor.rowcount > 0
110
+ finally:
111
+ cursor.close()
112
+ if updated:
113
+ db.record_mutation_audit(
114
+ entity_type="job",
115
+ entity_id=job_id,
116
+ action="drain",
117
+ actor=actor,
118
+ reason=reason,
119
+ source=source,
120
+ metadata={"drain_until": drain_until.isoformat()},
121
+ )
122
+ return updated
123
+
124
+
125
+ def clear_job_drain(
126
+ db: Any,
127
+ job_id: str,
128
+ actor: str | None = None,
129
+ source: str | None = None,
130
+ ) -> bool:
131
+ with tx.transaction_conn(db) as conn:
132
+ cursor = conn.cursor()
133
+ try:
134
+ cursor.execute(
135
+ """
136
+ UPDATE jobs
137
+ SET drain_until = NULL,
138
+ drain_reason = NULL,
139
+ updated_at = CURRENT_TIMESTAMP
140
+ WHERE job_id = ?
141
+ """,
142
+ (job_id,),
143
+ )
144
+ updated = cursor.rowcount > 0
145
+ finally:
146
+ cursor.close()
147
+ if updated:
148
+ db.record_mutation_audit(
149
+ entity_type="job",
150
+ entity_id=job_id,
151
+ action="undrain",
152
+ actor=actor,
153
+ reason=None,
154
+ source=source,
155
+ )
156
+ return updated
157
+
158
+
159
+ def delete_job(db: Any, job_id: str) -> bool:
160
+ with tx.transaction_conn(db) as conn:
161
+ cursor = conn.cursor()
162
+ try:
163
+ cursor.execute("DELETE FROM job_kv WHERE job_id = ?", (job_id,))
164
+ cursor.execute("DELETE FROM jobs WHERE job_id = ?", (job_id,))
165
+ deleted = cursor.rowcount > 0
166
+ finally:
167
+ cursor.close()
168
+ return deleted
169
+
170
+
171
+ def get_job_kv(db: Any, job_id: str, key: str) -> Any | None:
172
+ row = db.execute_one(
173
+ "SELECT value_json FROM job_kv WHERE job_id = ? AND key = ?",
174
+ (job_id, key),
175
+ )
176
+ if not row:
177
+ return None
178
+ return mappers.parse_json(row["value_json"])
179
+
180
+
181
+ def set_job_kv(db: Any, job_id: str, key: str, value: Any) -> None:
182
+ value_json = json.dumps(value)
183
+ db.execute(
184
+ """
185
+ INSERT INTO job_kv (job_id, key, value_json)
186
+ VALUES (?, ?, ?)
187
+ ON CONFLICT(job_id, key) DO UPDATE SET
188
+ value_json = excluded.value_json,
189
+ updated_at = CURRENT_TIMESTAMP
190
+ """,
191
+ (job_id, key, value_json),
192
+ )
193
+
194
+
195
+ def delete_job_kv(db: Any, job_id: str, key: str) -> bool:
196
+ rowcount = db.execute_returning_rowcount(
197
+ "DELETE FROM job_kv WHERE job_id = ? AND key = ?",
198
+ (job_id, key),
199
+ )
200
+ return rowcount > 0
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def cleanup_old_intents(
7
+ db: Any,
8
+ older_than_days: int,
9
+ statuses: list[str] | None = None,
10
+ ) -> int:
11
+ if statuses is None:
12
+ statuses = ["confirmed", "failed", "abandoned"]
13
+
14
+ placeholders = ",".join("?" * len(statuses))
15
+ return db.execute_returning_rowcount(
16
+ f"""
17
+ DELETE FROM tx_intents
18
+ WHERE status IN ({placeholders})
19
+ AND created_at < datetime('now', ? || ' days')
20
+ """,
21
+ (*statuses, f"-{older_than_days}"),
22
+ )
23
+
24
+
25
+ def get_database_stats(db: Any) -> dict[str, Any]:
26
+ stats: dict[str, Any] = {"type": "sqlite", "path": db._database_path}
27
+
28
+ rows = db.execute_returning(
29
+ "SELECT status, COUNT(*) as count FROM tx_intents GROUP BY status"
30
+ )
31
+ stats["intents_by_status"] = {row["status"]: row["count"] for row in rows}
32
+
33
+ row = db.execute_one("SELECT COUNT(*) as count FROM jobs")
34
+ stats["total_jobs"] = row["count"] if row else 0
35
+
36
+ row = db.execute_one("SELECT COUNT(*) as count FROM jobs WHERE enabled = 1")
37
+ stats["enabled_jobs"] = row["count"] if row else 0
38
+
39
+ rows = db.execute_returning("SELECT * FROM block_state")
40
+ stats["block_states"] = [
41
+ {
42
+ "chain_id": row["chain_id"],
43
+ "last_block": row["last_processed_block_number"],
44
+ }
45
+ for row in rows
46
+ ]
47
+
48
+ return stats
49
+
50
+
51
+ def clear_orphaned_claims(db: Any, chain_id: int, older_than_minutes: int = 2) -> int:
52
+ return db.execute_returning_rowcount(
53
+ """
54
+ UPDATE tx_intents
55
+ SET claim_token = NULL,
56
+ claimed_at = NULL,
57
+ claimed_by = NULL,
58
+ lease_expires_at = NULL,
59
+ updated_at = CURRENT_TIMESTAMP
60
+ WHERE chain_id = ?
61
+ AND status != 'claimed'
62
+ AND claim_token IS NOT NULL
63
+ AND claimed_at IS NOT NULL
64
+ AND claimed_at < datetime('now', ? || ' minutes')
65
+ """,
66
+ (chain_id, f"-{older_than_minutes}"),
67
+ )
68
+
69
+
70
+ def release_orphaned_nonces(db: Any, chain_id: int, older_than_minutes: int = 5) -> int:
71
+ return db.execute_returning_rowcount(
72
+ """
73
+ UPDATE nonce_reservations
74
+ SET status = 'released',
75
+ updated_at = CURRENT_TIMESTAMP
76
+ WHERE chain_id = ?
77
+ AND status = 'reserved'
78
+ AND updated_at < datetime('now', ? || ' minutes')
79
+ AND intent_id IN (
80
+ SELECT intent_id FROM tx_intents
81
+ WHERE status IN ('failed', 'abandoned', 'reverted')
82
+ AND updated_at < datetime('now', ? || ' minutes')
83
+ )
84
+ """,
85
+ (chain_id, f"-{older_than_minutes}", f"-{older_than_minutes}"),
86
+ )
87
+
88
+
89
+ def count_pending_without_attempts(db: Any, chain_id: int) -> int:
90
+ result = db.execute_one(
91
+ """
92
+ SELECT COUNT(*) as count
93
+ FROM tx_intents ti
94
+ LEFT JOIN tx_attempts ta ON ti.intent_id = ta.intent_id
95
+ WHERE ti.chain_id = ?
96
+ AND ti.status = 'pending'
97
+ AND ta.attempt_id IS NULL
98
+ """,
99
+ (chain_id,),
100
+ )
101
+ return result["count"] if result else 0
102
+
103
+
104
+ def count_stale_claims(db: Any, chain_id: int, older_than_minutes: int = 10) -> int:
105
+ result = db.execute_one(
106
+ """
107
+ SELECT COUNT(*) as count
108
+ FROM tx_intents
109
+ WHERE chain_id = ?
110
+ AND status = 'claimed'
111
+ AND claimed_at IS NOT NULL
112
+ AND COALESCE(lease_expires_at, datetime(claimed_at, ? || ' minutes')) < CURRENT_TIMESTAMP
113
+ """,
114
+ (chain_id, f"+{older_than_minutes}"),
115
+ )
116
+ return result["count"] if result else 0
117
+
118
+
119
+ def count_stuck_claimed(db: Any, chain_id: int, older_than_minutes: int = 10) -> int:
120
+ result = db.execute_one(
121
+ """
122
+ SELECT COUNT(*) as count
123
+ FROM tx_intents
124
+ WHERE chain_id = ?
125
+ AND status = 'claimed'
126
+ AND COALESCE(lease_expires_at, datetime(claimed_at, ? || ' minutes')) < CURRENT_TIMESTAMP
127
+ """,
128
+ (chain_id, f"+{older_than_minutes}"),
129
+ )
130
+ return result["count"] if result else 0
131
+
132
+
133
+ def count_orphaned_claims(db: Any, chain_id: int) -> int:
134
+ result = db.execute_one(
135
+ """
136
+ SELECT COUNT(*) as count
137
+ FROM tx_intents
138
+ WHERE chain_id = ?
139
+ AND status != 'claimed'
140
+ AND claim_token IS NOT NULL
141
+ """,
142
+ (chain_id,),
143
+ )
144
+ return result["count"] if result else 0
145
+
146
+
147
+ def count_orphaned_nonces(db: Any, chain_id: int) -> int:
148
+ result = db.execute_one(
149
+ """
150
+ SELECT COUNT(*) as count
151
+ FROM nonce_reservations nr
152
+ JOIN tx_intents ti ON nr.intent_id = ti.intent_id
153
+ WHERE nr.chain_id = ?
154
+ AND nr.status IN ('reserved', 'in_flight')
155
+ AND ti.status IN ('failed', 'abandoned', 'reverted')
156
+ """,
157
+ (chain_id,),
158
+ )
159
+ return result["count"] if result else 0
160
+
161
+
162
+ def get_oldest_nonce_gap_age_seconds(db: Any, chain_id: int) -> float:
163
+ result = db.execute_one(
164
+ """
165
+ SELECT COALESCE(
166
+ (julianday('now') - julianday(datetime(MIN(nr.created_at)))) * 86400,
167
+ 0
168
+ ) AS oldest_gap_seconds
169
+ FROM signers s
170
+ JOIN nonce_reservations nr
171
+ ON nr.chain_id = s.chain_id
172
+ AND nr.signer_address = s.signer_address
173
+ WHERE s.chain_id = ?
174
+ AND s.last_synced_chain_nonce IS NOT NULL
175
+ AND nr.status IN ('reserved', 'in_flight')
176
+ AND nr.nonce < s.last_synced_chain_nonce
177
+ """,
178
+ (chain_id,),
179
+ )
180
+ if not result:
181
+ return 0.0
182
+ return float(result["oldest_gap_seconds"])