brawny 0.1.13__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 (141) hide show
  1. brawny/__init__.py +106 -0
  2. brawny/_context.py +232 -0
  3. brawny/_rpc/__init__.py +38 -0
  4. brawny/_rpc/broadcast.py +172 -0
  5. brawny/_rpc/clients.py +98 -0
  6. brawny/_rpc/context.py +49 -0
  7. brawny/_rpc/errors.py +252 -0
  8. brawny/_rpc/gas.py +158 -0
  9. brawny/_rpc/manager.py +982 -0
  10. brawny/_rpc/selector.py +156 -0
  11. brawny/accounts.py +534 -0
  12. brawny/alerts/__init__.py +132 -0
  13. brawny/alerts/abi_resolver.py +530 -0
  14. brawny/alerts/base.py +152 -0
  15. brawny/alerts/context.py +271 -0
  16. brawny/alerts/contracts.py +635 -0
  17. brawny/alerts/encoded_call.py +201 -0
  18. brawny/alerts/errors.py +267 -0
  19. brawny/alerts/events.py +680 -0
  20. brawny/alerts/function_caller.py +364 -0
  21. brawny/alerts/health.py +185 -0
  22. brawny/alerts/routing.py +118 -0
  23. brawny/alerts/send.py +364 -0
  24. brawny/api.py +660 -0
  25. brawny/chain.py +93 -0
  26. brawny/cli/__init__.py +16 -0
  27. brawny/cli/app.py +17 -0
  28. brawny/cli/bootstrap.py +37 -0
  29. brawny/cli/commands/__init__.py +41 -0
  30. brawny/cli/commands/abi.py +93 -0
  31. brawny/cli/commands/accounts.py +632 -0
  32. brawny/cli/commands/console.py +495 -0
  33. brawny/cli/commands/contract.py +139 -0
  34. brawny/cli/commands/health.py +112 -0
  35. brawny/cli/commands/init_project.py +86 -0
  36. brawny/cli/commands/intents.py +130 -0
  37. brawny/cli/commands/job_dev.py +254 -0
  38. brawny/cli/commands/jobs.py +308 -0
  39. brawny/cli/commands/logs.py +87 -0
  40. brawny/cli/commands/maintenance.py +182 -0
  41. brawny/cli/commands/migrate.py +51 -0
  42. brawny/cli/commands/networks.py +253 -0
  43. brawny/cli/commands/run.py +249 -0
  44. brawny/cli/commands/script.py +209 -0
  45. brawny/cli/commands/signer.py +248 -0
  46. brawny/cli/helpers.py +265 -0
  47. brawny/cli_templates.py +1445 -0
  48. brawny/config/__init__.py +74 -0
  49. brawny/config/models.py +404 -0
  50. brawny/config/parser.py +633 -0
  51. brawny/config/routing.py +55 -0
  52. brawny/config/validation.py +246 -0
  53. brawny/daemon/__init__.py +14 -0
  54. brawny/daemon/context.py +69 -0
  55. brawny/daemon/core.py +702 -0
  56. brawny/daemon/loops.py +327 -0
  57. brawny/db/__init__.py +78 -0
  58. brawny/db/base.py +986 -0
  59. brawny/db/base_new.py +165 -0
  60. brawny/db/circuit_breaker.py +97 -0
  61. brawny/db/global_cache.py +298 -0
  62. brawny/db/mappers.py +182 -0
  63. brawny/db/migrate.py +349 -0
  64. brawny/db/migrations/001_init.sql +186 -0
  65. brawny/db/migrations/002_add_included_block.sql +7 -0
  66. brawny/db/migrations/003_add_broadcast_at.sql +10 -0
  67. brawny/db/migrations/004_broadcast_binding.sql +20 -0
  68. brawny/db/migrations/005_add_retry_after.sql +9 -0
  69. brawny/db/migrations/006_add_retry_count_column.sql +11 -0
  70. brawny/db/migrations/007_add_gap_tracking.sql +18 -0
  71. brawny/db/migrations/008_add_transactions.sql +72 -0
  72. brawny/db/migrations/009_add_intent_metadata.sql +5 -0
  73. brawny/db/migrations/010_add_nonce_gap_index.sql +9 -0
  74. brawny/db/migrations/011_add_job_logs.sql +24 -0
  75. brawny/db/migrations/012_add_claimed_by.sql +5 -0
  76. brawny/db/ops/__init__.py +29 -0
  77. brawny/db/ops/attempts.py +108 -0
  78. brawny/db/ops/blocks.py +83 -0
  79. brawny/db/ops/cache.py +93 -0
  80. brawny/db/ops/intents.py +296 -0
  81. brawny/db/ops/jobs.py +110 -0
  82. brawny/db/ops/logs.py +97 -0
  83. brawny/db/ops/nonces.py +322 -0
  84. brawny/db/postgres.py +2535 -0
  85. brawny/db/postgres_new.py +196 -0
  86. brawny/db/queries.py +584 -0
  87. brawny/db/sqlite.py +2733 -0
  88. brawny/db/sqlite_new.py +191 -0
  89. brawny/history.py +126 -0
  90. brawny/interfaces.py +136 -0
  91. brawny/invariants.py +155 -0
  92. brawny/jobs/__init__.py +26 -0
  93. brawny/jobs/base.py +287 -0
  94. brawny/jobs/discovery.py +233 -0
  95. brawny/jobs/job_validation.py +111 -0
  96. brawny/jobs/kv.py +125 -0
  97. brawny/jobs/registry.py +283 -0
  98. brawny/keystore.py +484 -0
  99. brawny/lifecycle.py +551 -0
  100. brawny/logging.py +290 -0
  101. brawny/metrics.py +594 -0
  102. brawny/model/__init__.py +53 -0
  103. brawny/model/contexts.py +319 -0
  104. brawny/model/enums.py +70 -0
  105. brawny/model/errors.py +194 -0
  106. brawny/model/events.py +93 -0
  107. brawny/model/startup.py +20 -0
  108. brawny/model/types.py +483 -0
  109. brawny/networks/__init__.py +96 -0
  110. brawny/networks/config.py +269 -0
  111. brawny/networks/manager.py +423 -0
  112. brawny/obs/__init__.py +67 -0
  113. brawny/obs/emit.py +158 -0
  114. brawny/obs/health.py +175 -0
  115. brawny/obs/heartbeat.py +133 -0
  116. brawny/reconciliation.py +108 -0
  117. brawny/scheduler/__init__.py +19 -0
  118. brawny/scheduler/poller.py +472 -0
  119. brawny/scheduler/reorg.py +632 -0
  120. brawny/scheduler/runner.py +708 -0
  121. brawny/scheduler/shutdown.py +371 -0
  122. brawny/script_tx.py +297 -0
  123. brawny/scripting.py +251 -0
  124. brawny/startup.py +76 -0
  125. brawny/telegram.py +393 -0
  126. brawny/testing.py +108 -0
  127. brawny/tx/__init__.py +41 -0
  128. brawny/tx/executor.py +1071 -0
  129. brawny/tx/fees.py +50 -0
  130. brawny/tx/intent.py +423 -0
  131. brawny/tx/monitor.py +628 -0
  132. brawny/tx/nonce.py +498 -0
  133. brawny/tx/replacement.py +456 -0
  134. brawny/tx/utils.py +26 -0
  135. brawny/utils.py +205 -0
  136. brawny/validation.py +69 -0
  137. brawny-0.1.13.dist-info/METADATA +156 -0
  138. brawny-0.1.13.dist-info/RECORD +141 -0
  139. brawny-0.1.13.dist-info/WHEEL +5 -0
  140. brawny-0.1.13.dist-info/entry_points.txt +2 -0
  141. brawny-0.1.13.dist-info/top_level.txt +1 -0
@@ -0,0 +1,472 @@
1
+ """Block poller for continuous block processing.
2
+
3
+ Implements the polling loop from SPEC 5:
4
+ - HTTP poll head block (eth_blockNumber)
5
+ - Process sequentially from last_processed+1 up to head
6
+ - Limit catchup blocks per iteration
7
+ - Sleep poll_interval_seconds between iterations
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from dataclasses import dataclass
14
+ from threading import Event, Thread
15
+ from typing import TYPE_CHECKING, Callable
16
+
17
+ from brawny.alerts.health import health_alert
18
+ from brawny.invariants import collect_invariants
19
+ from brawny.logging import LogEvents, get_logger
20
+ from brawny.metrics import (
21
+ BLOCK_PROCESSING_SECONDS,
22
+ BLOCKS_PROCESSED,
23
+ BLOCK_PROCESSING_LAG_SECONDS,
24
+ LAST_BLOCK_TIMESTAMP,
25
+ LAST_BLOCK_PROCESSED_TIMESTAMP,
26
+ LAST_PROCESSED_BLOCK,
27
+ OLDEST_PENDING_INTENT_AGE_SECONDS,
28
+ PENDING_INTENTS,
29
+ get_metrics,
30
+ )
31
+
32
+ # Collect invariants every N blocks to avoid overhead
33
+ INVARIANT_COLLECTION_INTERVAL_BLOCKS = 6
34
+
35
+ if TYPE_CHECKING:
36
+ from brawny.config import Config
37
+ from brawny.db.base import Database
38
+ from brawny.model.types import BlockInfo
39
+ from brawny._rpc.manager import RPCManager
40
+ from brawny.scheduler.reorg import ReorgDetector
41
+
42
+ logger = get_logger(__name__)
43
+
44
+
45
+ @dataclass
46
+ class PollResult:
47
+ """Result of a poll iteration."""
48
+
49
+ blocks_processed: int
50
+ head_block: int
51
+ last_processed: int
52
+ reorg_detected: bool = False
53
+ reorg_depth: int = 0
54
+
55
+
56
+ class BlockPoller:
57
+ """Block poller with configurable interval.
58
+
59
+ Always starts at chain head (live-head mode). No historical catchup.
60
+ Downtime means missed block evaluations by design.
61
+
62
+ Provides the main polling loop that:
63
+ 1. Gets head block from RPC
64
+ 2. Processes blocks sequentially from last processed (or head on startup)
65
+ 3. Calls block handler for each block
66
+ 4. Sleeps between iterations
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ db: Database,
72
+ rpc: RPCManager,
73
+ config: Config,
74
+ block_handler: Callable[[BlockInfo], None],
75
+ reorg_detector: "ReorgDetector | None" = None,
76
+ health_send_fn: Callable[..., None] | None = None,
77
+ health_chat_id: str | None = None,
78
+ health_cooldown: int = 1800,
79
+ ) -> None:
80
+ """Initialize block poller.
81
+
82
+ Args:
83
+ db: Database connection
84
+ rpc: RPC manager
85
+ config: Application configuration
86
+ block_handler: Callback for processing each block
87
+ reorg_detector: Optional reorg detector
88
+ health_send_fn: Optional health alert send function
89
+ health_chat_id: Optional health alert chat ID
90
+ health_cooldown: Health alert cooldown in seconds
91
+ """
92
+ self._db = db
93
+ self._rpc = rpc
94
+ self._config = config
95
+ self._block_handler = block_handler
96
+ self._reorg_detector = reorg_detector
97
+ self._chain_id = config.chain_id
98
+
99
+ # Health alerting
100
+ self._health_send_fn = health_send_fn
101
+ self._health_chat_id = health_chat_id
102
+ self._health_cooldown = health_cooldown
103
+
104
+ # Polling state
105
+ self._running = False
106
+ self._stop_event = Event()
107
+ self._poll_thread: Thread | None = None
108
+
109
+ # Session state - will be initialized on first poll
110
+ self._session_last_processed: int | None = None
111
+
112
+ # Invariant collection tick counter
113
+ self._invariant_tick_count = 0
114
+
115
+ @property
116
+ def is_running(self) -> bool:
117
+ """Check if poller is running."""
118
+ return self._running
119
+
120
+ def start(self, blocking: bool = True) -> None:
121
+ """Start the polling loop.
122
+
123
+ Args:
124
+ blocking: If True, run in current thread. Otherwise spawn thread.
125
+ """
126
+ self._running = True
127
+ self._stop_event.clear()
128
+
129
+ if blocking:
130
+ self._poll_loop()
131
+ else:
132
+ self._poll_thread = Thread(target=self._poll_loop, daemon=True)
133
+ self._poll_thread.start()
134
+
135
+ def stop(self, timeout: float | None = None) -> bool:
136
+ """Stop the polling loop.
137
+
138
+ Args:
139
+ timeout: Max seconds to wait for clean stop
140
+
141
+ Returns:
142
+ True if stopped cleanly, False if timed out
143
+ """
144
+ self._stop_event.set()
145
+ self._running = False
146
+
147
+ if self._poll_thread and self._poll_thread.is_alive():
148
+ self._poll_thread.join(timeout=timeout)
149
+ return not self._poll_thread.is_alive()
150
+
151
+ return True
152
+
153
+ def wait(self, timeout: float | None = None) -> bool:
154
+ """Wait for current block processing to complete.
155
+
156
+ Args:
157
+ timeout: Max seconds to wait
158
+
159
+ Returns:
160
+ True if completed within timeout
161
+ """
162
+ if self._poll_thread:
163
+ self._poll_thread.join(timeout=timeout)
164
+ return not self._poll_thread.is_alive()
165
+ return True
166
+
167
+ def _poll_loop(self) -> None:
168
+ """Main polling loop."""
169
+ logger.info(
170
+ "poller.started",
171
+ chain_id=self._chain_id,
172
+ poll_interval=self._config.poll_interval_seconds,
173
+ )
174
+
175
+ while not self._stop_event.is_set():
176
+ try:
177
+ result = self._poll_once()
178
+
179
+ if result.blocks_processed > 0:
180
+ logger.debug(
181
+ "poller.iteration",
182
+ blocks_processed=result.blocks_processed,
183
+ head=result.head_block,
184
+ last_processed=result.last_processed,
185
+ )
186
+
187
+ except Exception as e:
188
+ logger.error(
189
+ "poller.error",
190
+ error=str(e),
191
+ exc_info=True,
192
+ )
193
+ health_alert(
194
+ component="brawny.scheduler.poller",
195
+ chain_id=self._chain_id,
196
+ error=e,
197
+ action="Check RPC connectivity",
198
+ db_dialect=self._db.dialect,
199
+ send_fn=self._health_send_fn,
200
+ health_chat_id=self._health_chat_id,
201
+ cooldown_seconds=self._health_cooldown,
202
+ )
203
+
204
+ # Sleep between iterations
205
+ if not self._stop_event.wait(timeout=self._config.poll_interval_seconds):
206
+ continue
207
+ else:
208
+ break # Stop event was set
209
+
210
+ logger.info("poller.stopped")
211
+
212
+ def _poll_once(self) -> PollResult:
213
+ """Execute one poll iteration.
214
+
215
+ Returns:
216
+ Poll result with blocks processed
217
+ """
218
+ # Get head block (bounded by RPC timeout)
219
+ timeout = min(5.0, float(self._config.rpc_timeout_seconds))
220
+ head_block = self._rpc.get_block_number(timeout=timeout)
221
+
222
+ # Determine starting point
223
+ if self._session_last_processed is None:
224
+ # First poll of session - always start at chain head (no catchup)
225
+ last_processed = head_block - 1
226
+ logger.info(
227
+ "poller.starting_at_head",
228
+ chain_id=self._chain_id,
229
+ head_block=head_block,
230
+ )
231
+ else:
232
+ last_processed = self._session_last_processed
233
+
234
+ # Calculate how many blocks to process
235
+ blocks_to_process = head_block - last_processed
236
+
237
+ if blocks_to_process <= 0:
238
+ return PollResult(
239
+ blocks_processed=0,
240
+ head_block=head_block,
241
+ last_processed=last_processed,
242
+ )
243
+
244
+ # Process blocks sequentially
245
+ blocks_processed = 0
246
+ for block_number in range(last_processed + 1, last_processed + blocks_to_process + 1):
247
+ if self._stop_event.is_set():
248
+ break
249
+
250
+ try:
251
+ if self._reorg_detector:
252
+ reorg_result = self._reorg_detector.check(block_number)
253
+ if reorg_result.reorg_detected:
254
+ if reorg_result.pause:
255
+ logger.error(
256
+ "poller.reorg_pause",
257
+ chain_id=self._chain_id,
258
+ reason=reorg_result.rewind_reason,
259
+ )
260
+ self._stop_event.set()
261
+ self._running = False
262
+ return PollResult(
263
+ blocks_processed=0,
264
+ head_block=head_block,
265
+ last_processed=last_processed,
266
+ reorg_detected=True,
267
+ reorg_depth=reorg_result.reorg_depth,
268
+ )
269
+ if reorg_result.last_good_height is None or reorg_result.last_good_height < 0:
270
+ self._reorg_detector.handle_deep_reorg()
271
+ if self._config.deep_reorg_pause:
272
+ logger.error(
273
+ "poller.deep_reorg_pause",
274
+ chain_id=self._chain_id,
275
+ )
276
+ self._stop_event.set()
277
+ self._running = False
278
+ # Deep reorg - reset to start fresh at head on next poll
279
+ self._session_last_processed = None
280
+ return PollResult(
281
+ blocks_processed=0,
282
+ head_block=head_block,
283
+ last_processed=last_processed,
284
+ reorg_detected=True,
285
+ reorg_depth=reorg_result.reorg_depth,
286
+ )
287
+
288
+ rewind_result = self._reorg_detector.rewind(reorg_result)
289
+ new_last = (
290
+ rewind_result.last_good_height
291
+ if rewind_result.last_good_height is not None
292
+ else last_processed
293
+ )
294
+ self._session_last_processed = new_last
295
+ return PollResult(
296
+ blocks_processed=0,
297
+ head_block=head_block,
298
+ last_processed=new_last,
299
+ reorg_detected=True,
300
+ reorg_depth=reorg_result.reorg_depth,
301
+ )
302
+
303
+ # Fetch block with retries for transient RPC issues
304
+ block_info = None
305
+ for retry in range(3):
306
+ block_info = self._fetch_block_info(block_number)
307
+ if block_info is not None:
308
+ break
309
+ # Exponential backoff: 0.5s, 1s, 2s
310
+ backoff = 0.5 * (2**retry)
311
+ logger.debug(
312
+ "poller.block_fetch_retry",
313
+ block_number=block_number,
314
+ retry=retry + 1,
315
+ backoff_seconds=backoff,
316
+ )
317
+ time.sleep(backoff)
318
+
319
+ if block_info is None:
320
+ logger.warning(
321
+ "poller.block_not_found_after_retries",
322
+ block_number=block_number,
323
+ retries=3,
324
+ )
325
+ break
326
+
327
+ # Log block ingestion start
328
+ logger.debug(
329
+ LogEvents.BLOCK_INGEST_START,
330
+ block_number=block_number,
331
+ )
332
+
333
+ # Call the block handler (this is where job evaluation happens)
334
+ metrics = get_metrics()
335
+ start_time = time.perf_counter()
336
+ self._block_handler(block_info)
337
+ duration = time.perf_counter() - start_time
338
+ metrics.histogram(BLOCK_PROCESSING_SECONDS).observe(
339
+ duration,
340
+ chain_id=self._chain_id,
341
+ )
342
+ metrics.counter(BLOCKS_PROCESSED).inc(chain_id=self._chain_id)
343
+ metrics.gauge(LAST_PROCESSED_BLOCK).set(
344
+ block_info.block_number,
345
+ chain_id=self._chain_id,
346
+ )
347
+ pending_count = self._db.get_pending_intent_count(chain_id=self._chain_id)
348
+ metrics.gauge(PENDING_INTENTS).set(
349
+ pending_count,
350
+ chain_id=self._chain_id,
351
+ )
352
+ oldest_age = self._db.get_oldest_pending_intent_age(chain_id=self._chain_id)
353
+ if oldest_age is not None:
354
+ metrics.gauge(OLDEST_PENDING_INTENT_AGE_SECONDS).set(
355
+ oldest_age,
356
+ chain_id=self._chain_id,
357
+ )
358
+ else:
359
+ metrics.gauge(OLDEST_PENDING_INTENT_AGE_SECONDS).set(
360
+ 0,
361
+ chain_id=self._chain_id,
362
+ )
363
+
364
+ # Update block state and hash history
365
+ self._db.upsert_block_state(
366
+ self._chain_id,
367
+ block_number,
368
+ block_info.block_hash,
369
+ )
370
+ self._db.insert_block_hash(
371
+ self._chain_id,
372
+ block_number,
373
+ block_info.block_hash,
374
+ )
375
+
376
+ # Cleanup old block hashes
377
+ self._db.cleanup_old_block_hashes(
378
+ self._chain_id,
379
+ self._config.block_hash_history_size,
380
+ )
381
+
382
+ # Emit timestamp after all DB commits are complete
383
+ metrics.gauge(LAST_BLOCK_PROCESSED_TIMESTAMP).set(
384
+ time.time(),
385
+ chain_id=self._chain_id,
386
+ )
387
+ metrics.gauge(LAST_BLOCK_TIMESTAMP).set(
388
+ block_info.timestamp,
389
+ chain_id=self._chain_id,
390
+ )
391
+ metrics.gauge(BLOCK_PROCESSING_LAG_SECONDS).set(
392
+ time.time() - float(block_info.timestamp),
393
+ chain_id=self._chain_id,
394
+ )
395
+
396
+ # Collect invariants every N blocks
397
+ self._invariant_tick_count += 1
398
+ if self._invariant_tick_count >= INVARIANT_COLLECTION_INTERVAL_BLOCKS:
399
+ self._invariant_tick_count = 0
400
+ try:
401
+ collect_invariants(
402
+ self._db,
403
+ self._chain_id,
404
+ health_send_fn=self._health_send_fn,
405
+ health_chat_id=self._health_chat_id,
406
+ health_cooldown=self._health_cooldown,
407
+ )
408
+ except Exception as e:
409
+ logger.error(
410
+ "invariants.collection_failed",
411
+ error=str(e),
412
+ exc_info=True,
413
+ )
414
+
415
+ logger.debug(
416
+ LogEvents.BLOCK_INGEST_DONE,
417
+ block_number=block_number,
418
+ )
419
+
420
+ blocks_processed += 1
421
+
422
+ except Exception as e:
423
+ logger.error(
424
+ "poller.block_error",
425
+ block_number=block_number,
426
+ error=str(e),
427
+ )
428
+ break
429
+
430
+ # Update session state
431
+ self._session_last_processed = last_processed + blocks_processed
432
+
433
+ return PollResult(
434
+ blocks_processed=blocks_processed,
435
+ head_block=head_block,
436
+ last_processed=last_processed + blocks_processed,
437
+ )
438
+
439
+ def _fetch_block_info(self, block_number: int) -> BlockInfo | None:
440
+ """Fetch block info from RPC.
441
+
442
+ Args:
443
+ block_number: Block number to fetch
444
+
445
+ Returns:
446
+ BlockInfo or None if not found
447
+ """
448
+ from brawny.model.types import BlockInfo
449
+
450
+ try:
451
+ block = self._rpc.get_block(block_number)
452
+ if block is None:
453
+ return None
454
+
455
+ return BlockInfo(
456
+ chain_id=self._chain_id,
457
+ block_number=block["number"],
458
+ block_hash=f"0x{block['hash'].hex()}" if isinstance(block["hash"], bytes) else block["hash"],
459
+ timestamp=block["timestamp"],
460
+ )
461
+ except Exception:
462
+ return None
463
+
464
+ def poll_once(self) -> PollResult:
465
+ """Public method to run a single poll iteration.
466
+
467
+ Useful for testing and one-shot processing.
468
+
469
+ Returns:
470
+ Poll result
471
+ """
472
+ return self._poll_once()