omnibase_infra 0.2.2__py3-none-any.whl → 0.2.3__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 (77) hide show
  1. omnibase_infra/__init__.py +1 -1
  2. omnibase_infra/adapters/adapter_onex_tool_execution.py +6 -1
  3. omnibase_infra/capabilities/__init__.py +15 -0
  4. omnibase_infra/capabilities/capability_inference_rules.py +211 -0
  5. omnibase_infra/capabilities/contract_capability_extractor.py +221 -0
  6. omnibase_infra/capabilities/intent_type_extractor.py +160 -0
  7. omnibase_infra/contracts/handlers/filesystem/handler_contract.yaml +1 -1
  8. omnibase_infra/contracts/handlers/mcp/handler_contract.yaml +1 -1
  9. omnibase_infra/enums/__init__.py +6 -0
  10. omnibase_infra/enums/enum_handler_error_type.py +10 -0
  11. omnibase_infra/enums/enum_handler_source_mode.py +72 -0
  12. omnibase_infra/enums/enum_kafka_acks.py +99 -0
  13. omnibase_infra/event_bus/event_bus_kafka.py +1 -1
  14. omnibase_infra/event_bus/models/config/model_kafka_event_bus_config.py +59 -10
  15. omnibase_infra/handlers/__init__.py +8 -1
  16. omnibase_infra/handlers/handler_consul.py +7 -1
  17. omnibase_infra/handlers/handler_db.py +8 -2
  18. omnibase_infra/handlers/handler_http.py +8 -2
  19. omnibase_infra/handlers/handler_intent.py +387 -0
  20. omnibase_infra/handlers/handler_mcp.py +10 -1
  21. omnibase_infra/handlers/handler_vault.py +11 -5
  22. omnibase_infra/handlers/registration_storage/handler_registration_storage_postgres.py +7 -0
  23. omnibase_infra/handlers/service_discovery/handler_service_discovery_consul.py +7 -0
  24. omnibase_infra/mixins/mixin_node_introspection.py +18 -0
  25. omnibase_infra/models/discovery/model_introspection_config.py +11 -0
  26. omnibase_infra/models/handlers/__init__.py +38 -5
  27. omnibase_infra/models/handlers/model_bootstrap_handler_descriptor.py +4 -4
  28. omnibase_infra/models/handlers/model_contract_discovery_result.py +6 -4
  29. omnibase_infra/models/handlers/model_handler_source_config.py +220 -0
  30. omnibase_infra/models/registration/model_node_introspection_event.py +9 -0
  31. omnibase_infra/models/runtime/model_handler_contract.py +25 -9
  32. omnibase_infra/models/runtime/model_loaded_handler.py +9 -0
  33. omnibase_infra/nodes/node_registration_orchestrator/plugin.py +1 -1
  34. omnibase_infra/nodes/node_registration_orchestrator/registry/registry_infra_node_registration_orchestrator.py +7 -7
  35. omnibase_infra/nodes/node_registration_orchestrator/timeout_coordinator.py +4 -3
  36. omnibase_infra/nodes/node_registration_storage_effect/node.py +4 -1
  37. omnibase_infra/nodes/node_registration_storage_effect/registry/registry_infra_registration_storage.py +1 -1
  38. omnibase_infra/nodes/node_service_discovery_effect/registry/registry_infra_service_discovery.py +4 -1
  39. omnibase_infra/protocols/__init__.py +2 -0
  40. omnibase_infra/protocols/protocol_container_aware.py +200 -0
  41. omnibase_infra/runtime/__init__.py +39 -0
  42. omnibase_infra/runtime/handler_bootstrap_source.py +26 -33
  43. omnibase_infra/runtime/handler_contract_config_loader.py +1 -1
  44. omnibase_infra/runtime/handler_contract_source.py +10 -51
  45. omnibase_infra/runtime/handler_identity.py +81 -0
  46. omnibase_infra/runtime/handler_plugin_loader.py +15 -0
  47. omnibase_infra/runtime/handler_registry.py +11 -3
  48. omnibase_infra/runtime/handler_source_resolver.py +326 -0
  49. omnibase_infra/runtime/protocol_lifecycle_executor.py +6 -6
  50. omnibase_infra/runtime/registry/registry_protocol_binding.py +13 -13
  51. omnibase_infra/runtime/registry_contract_source.py +693 -0
  52. omnibase_infra/runtime/service_kernel.py +1 -1
  53. omnibase_infra/runtime/service_runtime_host_process.py +463 -190
  54. omnibase_infra/runtime/util_wiring.py +12 -3
  55. omnibase_infra/services/__init__.py +21 -0
  56. omnibase_infra/services/corpus_capture.py +7 -1
  57. omnibase_infra/services/mcp/mcp_server_lifecycle.py +9 -3
  58. omnibase_infra/services/registry_api/main.py +31 -13
  59. omnibase_infra/services/registry_api/service.py +10 -19
  60. omnibase_infra/services/service_timeout_emitter.py +7 -1
  61. omnibase_infra/services/service_timeout_scanner.py +7 -3
  62. omnibase_infra/services/session/__init__.py +56 -0
  63. omnibase_infra/services/session/config_consumer.py +120 -0
  64. omnibase_infra/services/session/config_store.py +139 -0
  65. omnibase_infra/services/session/consumer.py +1007 -0
  66. omnibase_infra/services/session/protocol_session_aggregator.py +117 -0
  67. omnibase_infra/services/session/store.py +997 -0
  68. omnibase_infra/utils/__init__.py +19 -0
  69. omnibase_infra/utils/util_atomic_file.py +261 -0
  70. omnibase_infra/utils/util_db_transaction.py +239 -0
  71. omnibase_infra/utils/util_retry_optimistic.py +281 -0
  72. omnibase_infra/validation/validation_exemptions.yaml +27 -0
  73. {omnibase_infra-0.2.2.dist-info → omnibase_infra-0.2.3.dist-info}/METADATA +3 -3
  74. {omnibase_infra-0.2.2.dist-info → omnibase_infra-0.2.3.dist-info}/RECORD +77 -56
  75. {omnibase_infra-0.2.2.dist-info → omnibase_infra-0.2.3.dist-info}/WHEEL +0 -0
  76. {omnibase_infra-0.2.2.dist-info → omnibase_infra-0.2.3.dist-info}/entry_points.txt +0 -0
  77. {omnibase_infra-0.2.2.dist-info → omnibase_infra-0.2.3.dist-info}/licenses/LICENSE +0 -0
@@ -4,11 +4,14 @@
4
4
 
5
5
  This package provides common utilities used across the infrastructure:
6
6
  - correlation: Correlation ID generation and propagation for distributed tracing
7
+ - util_atomic_file: Atomic file write primitives using temp-file-rename pattern
7
8
  - util_datetime: Datetime validation and timezone normalization
9
+ - util_db_transaction: Database transaction context manager for asyncpg
8
10
  - util_dsn_validation: PostgreSQL DSN validation and sanitization
9
11
  - util_env_parsing: Type-safe environment variable parsing with validation
10
12
  - util_error_sanitization: Error message sanitization for secure logging and DLQ
11
13
  - util_pydantic_validators: Shared Pydantic field validator utilities
14
+ - util_retry_optimistic: Optimistic locking retry helper with exponential backoff
12
15
  - util_semver: Semantic versioning validation utilities
13
16
  """
14
17
 
@@ -19,12 +22,19 @@ from omnibase_infra.utils.correlation import (
19
22
  get_correlation_id,
20
23
  set_correlation_id,
21
24
  )
25
+ from omnibase_infra.utils.util_atomic_file import (
26
+ write_atomic_bytes,
27
+ write_atomic_bytes_async,
28
+ )
22
29
  from omnibase_infra.utils.util_datetime import (
23
30
  ensure_timezone_aware,
24
31
  is_timezone_aware,
25
32
  validate_timezone_aware_with_context,
26
33
  warn_if_naive_datetime,
27
34
  )
35
+ from omnibase_infra.utils.util_db_transaction import (
36
+ transaction_context,
37
+ )
28
38
  from omnibase_infra.utils.util_dsn_validation import (
29
39
  parse_and_validate_dsn,
30
40
  sanitize_dsn,
@@ -50,6 +60,10 @@ from omnibase_infra.utils.util_pydantic_validators import (
50
60
  validate_timezone_aware_datetime,
51
61
  validate_timezone_aware_datetime_optional,
52
62
  )
63
+ from omnibase_infra.utils.util_retry_optimistic import (
64
+ OptimisticConflictError,
65
+ retry_on_optimistic_conflict,
66
+ )
53
67
  from omnibase_infra.utils.util_semver import (
54
68
  SEMVER_PATTERN,
55
69
  validate_semver,
@@ -58,6 +72,7 @@ from omnibase_infra.utils.util_semver import (
58
72
 
59
73
  __all__: list[str] = [
60
74
  "CorrelationContext",
75
+ "OptimisticConflictError",
61
76
  "SAFE_ERROR_PATTERNS",
62
77
  "SEMVER_PATTERN",
63
78
  "SENSITIVE_PATTERNS",
@@ -69,6 +84,7 @@ __all__: list[str] = [
69
84
  "parse_and_validate_dsn",
70
85
  "parse_env_float",
71
86
  "parse_env_int",
87
+ "retry_on_optimistic_conflict",
72
88
  "sanitize_backend_error",
73
89
  "sanitize_consul_key",
74
90
  "sanitize_dsn",
@@ -76,6 +92,7 @@ __all__: list[str] = [
76
92
  "sanitize_error_string",
77
93
  "sanitize_secret_path",
78
94
  "set_correlation_id",
95
+ "transaction_context",
79
96
  "validate_contract_type_value",
80
97
  "validate_endpoint_urls_dict",
81
98
  "validate_policy_type_value",
@@ -86,4 +103,6 @@ __all__: list[str] = [
86
103
  "validate_timezone_aware_with_context",
87
104
  "validate_version_lenient",
88
105
  "warn_if_naive_datetime",
106
+ "write_atomic_bytes",
107
+ "write_atomic_bytes_async",
89
108
  ]
@@ -0,0 +1,261 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Atomic file write utilities.
4
+
5
+ This module provides primitives for atomic file writes using the temp-file-rename
6
+ pattern. Atomic writes ensure that file contents are either completely written
7
+ or not written at all - there is no intermediate state where the file contains
8
+ partial data.
9
+
10
+ POSIX Atomicity Guarantees:
11
+ On POSIX systems, rename() is atomic within the same filesystem. This module
12
+ creates the temporary file in the same directory as the target file to ensure
13
+ the rename operation is atomic.
14
+
15
+ **IMPORTANT**: The temp file MUST be created in the same directory as the
16
+ target file (using `dir=path.parent`). If the temp file is on a different
17
+ filesystem, the rename becomes a copy-and-delete operation, losing atomicity.
18
+
19
+ NFS Caveat:
20
+ NFS provides weaker atomicity guarantees. While rename() is still atomic on
21
+ NFSv4+, there may be brief windows where both files are visible to other
22
+ clients. For applications requiring strict consistency on NFS, additional
23
+ locking mechanisms may be needed.
24
+
25
+ Windows Notes:
26
+ - os.replace() is atomic on Windows since Python 3.3
27
+ - Path.rename() on Windows will fail if the target exists; use os.replace()
28
+ - This module uses os.replace() for cross-platform atomic rename
29
+
30
+ Durability Note:
31
+ This module provides atomicity (all-or-nothing) but not durability guarantees.
32
+ For systems requiring crash-recovery durability, consider adding fsync() after
33
+ write and before rename. This comes at a performance cost.
34
+
35
+ Example:
36
+ >>> from pathlib import Path
37
+ >>> from omnibase_infra.utils import write_atomic_bytes
38
+ >>>
39
+ >>> # Write data atomically
40
+ >>> data = b"Hello, World!"
41
+ >>> bytes_written = write_atomic_bytes(Path("/tmp/myfile.txt"), data)
42
+ >>> bytes_written
43
+ 13
44
+ >>>
45
+ >>> # Async version (uses asyncio.to_thread internally)
46
+ >>> import asyncio
47
+ >>> from omnibase_infra.utils import write_atomic_bytes_async
48
+ >>> bytes_written = asyncio.run(write_atomic_bytes_async(Path("/tmp/myfile.txt"), data))
49
+ >>> bytes_written
50
+ 13
51
+
52
+ .. versionadded:: 0.10.0
53
+ Created as part of OMN-1524 atomic write utilities.
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import asyncio
59
+ import logging
60
+ import os
61
+ import tempfile
62
+ from pathlib import Path
63
+ from uuid import UUID
64
+
65
+ logger = logging.getLogger(__name__)
66
+
67
+
68
+ def write_atomic_bytes(
69
+ path: Path,
70
+ data: bytes,
71
+ *,
72
+ temp_prefix: str = "",
73
+ temp_suffix: str = ".tmp",
74
+ correlation_id: UUID | None = None,
75
+ ) -> int:
76
+ """Write bytes to a file atomically using temp-file-rename pattern.
77
+
78
+ This function provides atomic file writes by:
79
+ 1. Creating a temporary file in the same directory as the target
80
+ 2. Writing all data to the temporary file
81
+ 3. Atomically renaming the temporary file to the target path
82
+
83
+ The rename operation is atomic on POSIX systems when both files are on the
84
+ same filesystem. On Windows, os.replace() provides atomic semantics since
85
+ Python 3.3.
86
+
87
+ Args:
88
+ path: Target file path. Parent directory must exist.
89
+ data: Bytes to write to the file.
90
+ temp_prefix: Optional prefix for the temporary file name. Useful for
91
+ debugging to identify the source of temp files.
92
+ temp_suffix: Suffix for the temporary file name. Defaults to ".tmp".
93
+ correlation_id: Optional correlation ID for ONEX logging. When provided,
94
+ errors are logged with correlation context before being raised.
95
+
96
+ Returns:
97
+ Number of bytes written to the file.
98
+
99
+ Raises:
100
+ InfraConnectionError: If the file cannot be written (permissions, disk full,
101
+ etc.). The underlying OSError is chained via ``from e``. The temporary
102
+ file is cleaned up before raising.
103
+
104
+ Example:
105
+ >>> from pathlib import Path
106
+ >>> from omnibase_infra.utils.util_atomic_file import write_atomic_bytes
107
+ >>>
108
+ >>> # Basic atomic write
109
+ >>> path = Path("/tmp/test_atomic.txt")
110
+ >>> bytes_written = write_atomic_bytes(path, b"test data")
111
+ >>> bytes_written
112
+ 9
113
+ >>>
114
+ >>> # With debugging prefix
115
+ >>> bytes_written = write_atomic_bytes(
116
+ ... path,
117
+ ... b"test data",
118
+ ... temp_prefix="manifest_",
119
+ ... correlation_id=UUID("12345678-1234-5678-1234-567812345678"),
120
+ ... )
121
+
122
+ Warning:
123
+ The parent directory of ``path`` must exist. This function does not
124
+ create parent directories.
125
+
126
+ Warning:
127
+ On NFS, atomicity guarantees are weaker. See module docstring for details.
128
+
129
+ Related:
130
+ - handler_manifest_persistence.py: Original pattern implementation
131
+ """
132
+ temp_fd: int | None = None
133
+ temp_path: str | None = None
134
+
135
+ try:
136
+ # Create temp file in same directory for atomic rename guarantee
137
+ temp_fd, temp_path = tempfile.mkstemp(
138
+ suffix=temp_suffix,
139
+ prefix=temp_prefix,
140
+ dir=path.parent,
141
+ )
142
+
143
+ # Write data to temp file
144
+ with os.fdopen(temp_fd, "wb") as f:
145
+ temp_fd = None # fdopen takes ownership of fd
146
+ bytes_written = f.write(data)
147
+
148
+ # Atomic rename (Path.replace is atomic on both POSIX and Windows 3.3+)
149
+ Path(temp_path).replace(path)
150
+ temp_path = None # Rename succeeded, no cleanup needed
151
+
152
+ return bytes_written
153
+
154
+ except OSError as e:
155
+ # Log with correlation context if provided
156
+ if correlation_id is not None:
157
+ logger.exception(
158
+ "Atomic write failed for '%s'",
159
+ path,
160
+ extra={
161
+ "correlation_id": str(correlation_id),
162
+ "target_path": str(path),
163
+ "temp_prefix": temp_prefix,
164
+ "temp_suffix": temp_suffix,
165
+ "error_type": type(e).__name__,
166
+ },
167
+ )
168
+
169
+ # Cleanup: close fd if fdopen didn't take ownership
170
+ if temp_fd is not None:
171
+ try:
172
+ os.close(temp_fd)
173
+ except OSError:
174
+ pass # Best effort cleanup
175
+
176
+ # Cleanup: remove temp file if it exists
177
+ if temp_path is not None:
178
+ try:
179
+ Path(temp_path).unlink(missing_ok=True)
180
+ except OSError:
181
+ pass # Best effort cleanup
182
+
183
+ # Wrap OSError in InfraConnectionError per ONEX error handling guidelines
184
+ # Deferred import to avoid circular dependency (utils -> errors -> utils)
185
+ from omnibase_infra.enums import EnumInfraTransportType
186
+ from omnibase_infra.errors import InfraConnectionError, ModelInfraErrorContext
187
+
188
+ context = ModelInfraErrorContext.with_correlation(
189
+ correlation_id=correlation_id,
190
+ transport_type=EnumInfraTransportType.FILESYSTEM,
191
+ operation="write_atomic_bytes",
192
+ target_name=str(path),
193
+ )
194
+ raise InfraConnectionError(
195
+ f"Atomic write failed for '{path}'",
196
+ context=context,
197
+ error_type=type(e).__name__,
198
+ errno=getattr(e, "errno", None),
199
+ ) from e
200
+
201
+
202
+ async def write_atomic_bytes_async(
203
+ path: Path,
204
+ data: bytes,
205
+ *,
206
+ temp_prefix: str = "",
207
+ temp_suffix: str = ".tmp",
208
+ correlation_id: UUID | None = None,
209
+ ) -> int:
210
+ """Write bytes to a file atomically (async version).
211
+
212
+ This is a thin async wrapper around :func:`write_atomic_bytes` that uses
213
+ ``asyncio.to_thread()`` to run the synchronous implementation in a thread
214
+ pool. This prevents blocking the event loop during file I/O.
215
+
216
+ All logic is delegated to :func:`write_atomic_bytes` - this function exists
217
+ only to provide an async interface.
218
+
219
+ Args:
220
+ path: Target file path. Parent directory must exist.
221
+ data: Bytes to write to the file.
222
+ temp_prefix: Optional prefix for the temporary file name.
223
+ temp_suffix: Suffix for the temporary file name. Defaults to ".tmp".
224
+ correlation_id: Optional correlation ID for ONEX logging.
225
+
226
+ Returns:
227
+ Number of bytes written to the file.
228
+
229
+ Raises:
230
+ InfraConnectionError: If the file cannot be written (permissions, disk full,
231
+ etc.). The underlying OSError is chained via ``from e``.
232
+
233
+ Example:
234
+ >>> import asyncio
235
+ >>> from pathlib import Path
236
+ >>> from omnibase_infra.utils.util_atomic_file import write_atomic_bytes_async
237
+ >>>
238
+ >>> async def example():
239
+ ... path = Path("/tmp/test_async.txt")
240
+ ... return await write_atomic_bytes_async(path, b"async data")
241
+ >>>
242
+ >>> asyncio.run(example())
243
+ 10
244
+
245
+ See Also:
246
+ :func:`write_atomic_bytes`: The synchronous canonical implementation.
247
+ """
248
+ return await asyncio.to_thread(
249
+ write_atomic_bytes,
250
+ path,
251
+ data,
252
+ temp_prefix=temp_prefix,
253
+ temp_suffix=temp_suffix,
254
+ correlation_id=correlation_id,
255
+ )
256
+
257
+
258
+ __all__: list[str] = [
259
+ "write_atomic_bytes",
260
+ "write_atomic_bytes_async",
261
+ ]
@@ -0,0 +1,239 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Database transaction context manager for asyncpg.
4
+
5
+ This module provides a transaction context manager that properly wraps
6
+ database operations in transactions with configurable isolation levels,
7
+ readonly mode, and statement timeouts.
8
+
9
+ Critical Insight - Row Locks Require Explicit Transactions:
10
+ When using ``SELECT ... FOR UPDATE`` or similar locking constructs,
11
+ the locks are **released immediately after the SELECT** unless
12
+ executed within an explicit transaction context.
13
+
14
+ This is a subtle but critical behavior of PostgreSQL and asyncpg:
15
+ without ``conn.transaction()``, each statement runs in auto-commit
16
+ mode, causing locks to be acquired and immediately released.
17
+
18
+ Example of INCORRECT usage (locks NOT maintained):
19
+ ```python
20
+ async with pool.acquire() as conn:
21
+ # Lock is acquired but immediately released!
22
+ rows = await conn.fetch(
23
+ "SELECT * FROM queue WHERE status = 'pending' FOR UPDATE SKIP LOCKED"
24
+ )
25
+ # By this point, another worker could process the same row
26
+ await conn.execute("UPDATE queue SET status = 'processing' WHERE id = $1", row_id)
27
+ ```
28
+
29
+ Example of CORRECT usage (locks maintained):
30
+ ```python
31
+ async with pool.acquire() as conn:
32
+ async with conn.transaction():
33
+ # Lock is held until transaction commits
34
+ rows = await conn.fetch(
35
+ "SELECT * FROM queue WHERE status = 'pending' FOR UPDATE SKIP LOCKED"
36
+ )
37
+ # Lock still held - safe to update
38
+ await conn.execute("UPDATE queue SET status = 'processing' WHERE id = $1", row_id)
39
+ ```
40
+
41
+ The ``transaction_context()`` function in this module encapsulates
42
+ this pattern, ensuring locks are properly maintained throughout
43
+ the transaction scope.
44
+
45
+ Related Implementations:
46
+ - TransitionNotificationOutbox (runtime/transition_notification_outbox.py):
47
+ Uses explicit transaction wrapping for SELECT FOR UPDATE SKIP LOCKED
48
+ to safely process pending notifications with concurrent workers.
49
+
50
+ See Also:
51
+ - PostgreSQL locking documentation: https://www.postgresql.org/docs/current/explicit-locking.html
52
+ - asyncpg transaction documentation: https://magicstack.github.io/asyncpg/current/api/index.html#asyncpg.connection.Connection.transaction
53
+
54
+ Example:
55
+ >>> import asyncpg
56
+ >>> from omnibase_infra.utils import transaction_context
57
+ >>>
58
+ >>> async with transaction_context(pool) as conn:
59
+ ... await conn.execute("INSERT INTO logs (message) VALUES ($1)", "Hello")
60
+ >>>
61
+ >>> # With isolation level and timeout
62
+ >>> async with transaction_context(
63
+ ... pool,
64
+ ... isolation="serializable",
65
+ ... timeout=5.0,
66
+ ... ) as conn:
67
+ ... await conn.execute("UPDATE accounts SET balance = balance - 100 WHERE id = $1", account_id)
68
+
69
+ .. versionadded:: 0.10.0
70
+ Created as part of database utility consolidation.
71
+ """
72
+
73
+ from __future__ import annotations
74
+
75
+ import logging
76
+ from collections.abc import AsyncIterator
77
+ from contextlib import asynccontextmanager
78
+ from uuid import UUID
79
+
80
+ import asyncpg
81
+
82
+ logger = logging.getLogger(__name__)
83
+
84
+
85
+ @asynccontextmanager
86
+ async def transaction_context(
87
+ pool: asyncpg.Pool,
88
+ *,
89
+ isolation: str = "read_committed",
90
+ readonly: bool = False,
91
+ deferrable: bool = False,
92
+ timeout: float | None = None,
93
+ correlation_id: UUID | None = None,
94
+ ) -> AsyncIterator[asyncpg.Connection]:
95
+ """Async context manager for database transactions.
96
+
97
+ Acquires a connection from the pool and starts a transaction with the
98
+ specified isolation level and options. The connection is yielded for
99
+ use within the transaction scope.
100
+
101
+ Critical - Row Locks:
102
+ This context manager ensures that row locks (e.g., ``FOR UPDATE``,
103
+ ``FOR UPDATE SKIP LOCKED``) are maintained throughout the transaction.
104
+ Without explicit transaction wrapping, asyncpg operates in auto-commit
105
+ mode where locks are released immediately after each statement.
106
+
107
+ Isolation Levels:
108
+ - ``read_committed`` (default): Each statement sees a snapshot of
109
+ committed data as of the start of that statement.
110
+ - ``repeatable_read``: All statements in the transaction see a
111
+ snapshot of committed data as of the transaction start.
112
+ - ``serializable``: Strictest isolation - transactions execute as
113
+ if they were run serially.
114
+
115
+ Args:
116
+ pool: asyncpg connection pool to acquire connection from.
117
+ isolation: Transaction isolation level. One of "read_committed",
118
+ "repeatable_read", or "serializable". Defaults to "read_committed".
119
+ readonly: If True, the transaction is marked as read-only.
120
+ Attempting to modify data will raise an error. Defaults to False.
121
+ deferrable: If True, the transaction is deferrable. Only valid when
122
+ both ``isolation="serializable"`` and ``readonly=True``.
123
+ A deferrable transaction may block when first acquiring its
124
+ snapshot until it can execute without conflicting with other
125
+ serializable transactions. Defaults to False.
126
+ timeout: Statement timeout in seconds. If provided, sets
127
+ ``statement_timeout`` for the duration of the transaction.
128
+ Statements exceeding this timeout will be cancelled.
129
+ Defaults to None (no timeout).
130
+ correlation_id: Optional correlation ID for logging. When provided,
131
+ transaction start and commit/rollback events are logged with
132
+ this ID for distributed tracing.
133
+
134
+ Yields:
135
+ asyncpg.Connection: The acquired connection within the transaction
136
+ context. Use this connection for all queries within the transaction.
137
+
138
+ Raises:
139
+ asyncpg.PostgresError: For database-level errors.
140
+ TimeoutError: If a statement exceeds the configured timeout.
141
+
142
+ Example:
143
+ Basic usage:
144
+
145
+ >>> async with transaction_context(pool) as conn:
146
+ ... await conn.execute("INSERT INTO users (name) VALUES ($1)", "Alice")
147
+ ... await conn.execute("INSERT INTO audit_log (action) VALUES ($1)", "user_created")
148
+
149
+ With SELECT FOR UPDATE:
150
+
151
+ >>> async with transaction_context(pool) as conn:
152
+ ... # Lock is held until transaction commits
153
+ ... rows = await conn.fetch(
154
+ ... "SELECT * FROM jobs WHERE status = 'pending' LIMIT 1 FOR UPDATE SKIP LOCKED"
155
+ ... )
156
+ ... if rows:
157
+ ... await conn.execute(
158
+ ... "UPDATE jobs SET status = 'processing' WHERE id = $1",
159
+ ... rows[0]["id"]
160
+ ... )
161
+
162
+ With isolation and timeout:
163
+
164
+ >>> async with transaction_context(
165
+ ... pool,
166
+ ... isolation="serializable",
167
+ ... readonly=True,
168
+ ... timeout=10.0,
169
+ ... correlation_id=uuid4(),
170
+ ... ) as conn:
171
+ ... totals = await conn.fetchval("SELECT SUM(amount) FROM transactions")
172
+
173
+ Note:
174
+ The transaction is automatically committed on successful exit from
175
+ the context manager, or rolled back if an exception is raised.
176
+
177
+ Warning:
178
+ Asyncpg exception handling: This utility lets asyncpg exceptions
179
+ propagate naturally without wrapping them in ONEX errors. This is
180
+ intentional as it keeps the utility simple and composable. Callers
181
+ should handle asyncpg exceptions as appropriate for their use case.
182
+
183
+ Related:
184
+ - OMN-1139: TransitionNotificationOutbox uses this pattern for
185
+ SELECT FOR UPDATE SKIP LOCKED with concurrent workers.
186
+ """
187
+ async with pool.acquire() as conn:
188
+ # Log transaction start if correlation_id provided
189
+ if correlation_id is not None:
190
+ logger.debug(
191
+ "Starting database transaction",
192
+ extra={
193
+ "correlation_id": str(correlation_id),
194
+ "isolation": isolation,
195
+ "readonly": readonly,
196
+ "deferrable": deferrable,
197
+ "timeout": timeout,
198
+ },
199
+ )
200
+
201
+ try:
202
+ async with conn.transaction(
203
+ isolation=isolation,
204
+ readonly=readonly,
205
+ deferrable=deferrable,
206
+ ):
207
+ # Set statement timeout if provided
208
+ # Uses LOCAL to scope timeout to this transaction only
209
+ if timeout is not None:
210
+ timeout_ms = int(timeout * 1000)
211
+ await conn.execute("SET LOCAL statement_timeout = $1", timeout_ms)
212
+
213
+ yield conn
214
+
215
+ # Log successful commit if correlation_id provided
216
+ if correlation_id is not None:
217
+ logger.debug(
218
+ "Database transaction committed",
219
+ extra={
220
+ "correlation_id": str(correlation_id),
221
+ },
222
+ )
223
+
224
+ except Exception:
225
+ # Log rollback if correlation_id provided
226
+ # Transaction is automatically rolled back by asyncpg
227
+ if correlation_id is not None:
228
+ logger.debug(
229
+ "Database transaction rolled back",
230
+ extra={
231
+ "correlation_id": str(correlation_id),
232
+ },
233
+ )
234
+ raise
235
+
236
+
237
+ __all__: list[str] = [
238
+ "transaction_context",
239
+ ]