omnibase_infra 0.2.1__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.
- omnibase_infra/__init__.py +1 -1
- omnibase_infra/adapters/adapter_onex_tool_execution.py +451 -0
- omnibase_infra/capabilities/__init__.py +15 -0
- omnibase_infra/capabilities/capability_inference_rules.py +211 -0
- omnibase_infra/capabilities/contract_capability_extractor.py +221 -0
- omnibase_infra/capabilities/intent_type_extractor.py +160 -0
- omnibase_infra/cli/commands.py +1 -1
- omnibase_infra/configs/widget_mapping.yaml +176 -0
- omnibase_infra/contracts/handlers/filesystem/handler_contract.yaml +5 -2
- omnibase_infra/contracts/handlers/mcp/handler_contract.yaml +5 -2
- omnibase_infra/enums/__init__.py +6 -0
- omnibase_infra/enums/enum_handler_error_type.py +10 -0
- omnibase_infra/enums/enum_handler_source_mode.py +72 -0
- omnibase_infra/enums/enum_kafka_acks.py +99 -0
- omnibase_infra/errors/error_compute_registry.py +4 -1
- omnibase_infra/errors/error_event_bus_registry.py +4 -1
- omnibase_infra/errors/error_infra.py +3 -1
- omnibase_infra/errors/error_policy_registry.py +4 -1
- omnibase_infra/event_bus/event_bus_kafka.py +1 -1
- omnibase_infra/event_bus/models/config/model_kafka_event_bus_config.py +59 -10
- omnibase_infra/handlers/__init__.py +8 -1
- omnibase_infra/handlers/handler_consul.py +7 -1
- omnibase_infra/handlers/handler_db.py +10 -3
- omnibase_infra/handlers/handler_graph.py +10 -5
- omnibase_infra/handlers/handler_http.py +8 -2
- omnibase_infra/handlers/handler_intent.py +387 -0
- omnibase_infra/handlers/handler_mcp.py +745 -63
- omnibase_infra/handlers/handler_vault.py +11 -5
- omnibase_infra/handlers/mixins/mixin_consul_kv.py +4 -3
- omnibase_infra/handlers/mixins/mixin_consul_service.py +2 -1
- omnibase_infra/handlers/registration_storage/handler_registration_storage_postgres.py +7 -0
- omnibase_infra/handlers/service_discovery/handler_service_discovery_consul.py +308 -4
- omnibase_infra/handlers/service_discovery/models/model_service_info.py +10 -0
- omnibase_infra/mixins/mixin_async_circuit_breaker.py +3 -2
- omnibase_infra/mixins/mixin_node_introspection.py +42 -7
- omnibase_infra/mixins/mixin_retry_execution.py +1 -1
- omnibase_infra/models/discovery/model_introspection_config.py +11 -0
- omnibase_infra/models/handlers/__init__.py +48 -5
- omnibase_infra/models/handlers/model_bootstrap_handler_descriptor.py +162 -0
- omnibase_infra/models/handlers/model_contract_discovery_result.py +6 -4
- omnibase_infra/models/handlers/model_handler_descriptor.py +15 -0
- omnibase_infra/models/handlers/model_handler_source_config.py +220 -0
- omnibase_infra/models/mcp/__init__.py +15 -0
- omnibase_infra/models/mcp/model_mcp_contract_config.py +80 -0
- omnibase_infra/models/mcp/model_mcp_server_config.py +67 -0
- omnibase_infra/models/mcp/model_mcp_tool_definition.py +73 -0
- omnibase_infra/models/mcp/model_mcp_tool_parameter.py +35 -0
- omnibase_infra/models/registration/model_node_capabilities.py +11 -0
- omnibase_infra/models/registration/model_node_introspection_event.py +9 -0
- omnibase_infra/models/runtime/model_handler_contract.py +25 -9
- omnibase_infra/models/runtime/model_loaded_handler.py +9 -0
- omnibase_infra/nodes/architecture_validator/contract_architecture_validator.yaml +0 -5
- omnibase_infra/nodes/architecture_validator/registry/registry_infra_architecture_validator.py +17 -10
- omnibase_infra/nodes/effects/contract.yaml +0 -5
- omnibase_infra/nodes/node_registration_orchestrator/contract.yaml +7 -0
- omnibase_infra/nodes/node_registration_orchestrator/handlers/handler_node_introspected.py +86 -1
- omnibase_infra/nodes/node_registration_orchestrator/introspection_event_router.py +3 -3
- omnibase_infra/nodes/node_registration_orchestrator/plugin.py +1 -1
- omnibase_infra/nodes/node_registration_orchestrator/registry/registry_infra_node_registration_orchestrator.py +9 -8
- omnibase_infra/nodes/node_registration_orchestrator/timeout_coordinator.py +4 -3
- omnibase_infra/nodes/node_registration_orchestrator/wiring.py +14 -13
- omnibase_infra/nodes/node_registration_storage_effect/contract.yaml +0 -5
- omnibase_infra/nodes/node_registration_storage_effect/node.py +4 -1
- omnibase_infra/nodes/node_registration_storage_effect/registry/registry_infra_registration_storage.py +47 -26
- omnibase_infra/nodes/node_registry_effect/contract.yaml +0 -5
- omnibase_infra/nodes/node_registry_effect/handlers/handler_partial_retry.py +2 -1
- omnibase_infra/nodes/node_service_discovery_effect/registry/registry_infra_service_discovery.py +28 -20
- omnibase_infra/plugins/examples/plugin_json_normalizer.py +2 -2
- omnibase_infra/plugins/examples/plugin_json_normalizer_error_handling.py +2 -2
- omnibase_infra/plugins/plugin_compute_base.py +16 -2
- omnibase_infra/protocols/__init__.py +2 -0
- omnibase_infra/protocols/protocol_container_aware.py +200 -0
- omnibase_infra/protocols/protocol_event_projector.py +1 -1
- omnibase_infra/runtime/__init__.py +90 -1
- omnibase_infra/runtime/binding_config_resolver.py +102 -37
- omnibase_infra/runtime/constants_notification.py +75 -0
- omnibase_infra/runtime/contract_handler_discovery.py +6 -1
- omnibase_infra/runtime/handler_bootstrap_source.py +507 -0
- omnibase_infra/runtime/handler_contract_config_loader.py +603 -0
- omnibase_infra/runtime/handler_contract_source.py +267 -186
- omnibase_infra/runtime/handler_identity.py +81 -0
- omnibase_infra/runtime/handler_plugin_loader.py +19 -2
- omnibase_infra/runtime/handler_registry.py +11 -3
- omnibase_infra/runtime/handler_source_resolver.py +326 -0
- omnibase_infra/runtime/mixin_semver_cache.py +25 -1
- omnibase_infra/runtime/mixins/__init__.py +7 -0
- omnibase_infra/runtime/mixins/mixin_projector_notification_publishing.py +566 -0
- omnibase_infra/runtime/mixins/mixin_projector_sql_operations.py +31 -10
- omnibase_infra/runtime/models/__init__.py +24 -0
- omnibase_infra/runtime/models/model_health_check_result.py +2 -1
- omnibase_infra/runtime/models/model_projector_notification_config.py +171 -0
- omnibase_infra/runtime/models/model_transition_notification_outbox_config.py +112 -0
- omnibase_infra/runtime/models/model_transition_notification_outbox_metrics.py +140 -0
- omnibase_infra/runtime/models/model_transition_notification_publisher_metrics.py +357 -0
- omnibase_infra/runtime/projector_plugin_loader.py +1 -1
- omnibase_infra/runtime/projector_shell.py +229 -1
- omnibase_infra/runtime/protocol_lifecycle_executor.py +6 -6
- omnibase_infra/runtime/protocols/__init__.py +10 -0
- omnibase_infra/runtime/registry/registry_protocol_binding.py +16 -15
- omnibase_infra/runtime/registry_contract_source.py +693 -0
- omnibase_infra/runtime/registry_policy.py +9 -326
- omnibase_infra/runtime/secret_resolver.py +4 -2
- omnibase_infra/runtime/service_kernel.py +11 -3
- omnibase_infra/runtime/service_message_dispatch_engine.py +4 -2
- omnibase_infra/runtime/service_runtime_host_process.py +589 -106
- omnibase_infra/runtime/transition_notification_outbox.py +1190 -0
- omnibase_infra/runtime/transition_notification_publisher.py +764 -0
- omnibase_infra/runtime/util_container_wiring.py +6 -5
- omnibase_infra/runtime/util_wiring.py +17 -4
- omnibase_infra/schemas/schema_transition_notification_outbox.sql +245 -0
- omnibase_infra/services/__init__.py +21 -0
- omnibase_infra/services/corpus_capture.py +7 -1
- omnibase_infra/services/mcp/__init__.py +31 -0
- omnibase_infra/services/mcp/mcp_server_lifecycle.py +449 -0
- omnibase_infra/services/mcp/service_mcp_tool_discovery.py +411 -0
- omnibase_infra/services/mcp/service_mcp_tool_registry.py +329 -0
- omnibase_infra/services/mcp/service_mcp_tool_sync.py +547 -0
- omnibase_infra/services/registry_api/__init__.py +40 -0
- omnibase_infra/services/registry_api/main.py +261 -0
- omnibase_infra/services/registry_api/models/__init__.py +66 -0
- omnibase_infra/services/registry_api/models/model_capability_widget_mapping.py +38 -0
- omnibase_infra/services/registry_api/models/model_pagination_info.py +48 -0
- omnibase_infra/services/registry_api/models/model_registry_discovery_response.py +73 -0
- omnibase_infra/services/registry_api/models/model_registry_health_response.py +49 -0
- omnibase_infra/services/registry_api/models/model_registry_instance_view.py +88 -0
- omnibase_infra/services/registry_api/models/model_registry_node_view.py +88 -0
- omnibase_infra/services/registry_api/models/model_registry_summary.py +60 -0
- omnibase_infra/services/registry_api/models/model_response_list_instances.py +43 -0
- omnibase_infra/services/registry_api/models/model_response_list_nodes.py +51 -0
- omnibase_infra/services/registry_api/models/model_warning.py +49 -0
- omnibase_infra/services/registry_api/models/model_widget_defaults.py +28 -0
- omnibase_infra/services/registry_api/models/model_widget_mapping.py +51 -0
- omnibase_infra/services/registry_api/routes.py +371 -0
- omnibase_infra/services/registry_api/service.py +837 -0
- omnibase_infra/services/service_capability_query.py +4 -4
- omnibase_infra/services/service_health.py +3 -2
- omnibase_infra/services/service_timeout_emitter.py +20 -3
- omnibase_infra/services/service_timeout_scanner.py +7 -3
- omnibase_infra/services/session/__init__.py +56 -0
- omnibase_infra/services/session/config_consumer.py +120 -0
- omnibase_infra/services/session/config_store.py +139 -0
- omnibase_infra/services/session/consumer.py +1007 -0
- omnibase_infra/services/session/protocol_session_aggregator.py +117 -0
- omnibase_infra/services/session/store.py +997 -0
- omnibase_infra/utils/__init__.py +19 -0
- omnibase_infra/utils/util_atomic_file.py +261 -0
- omnibase_infra/utils/util_db_transaction.py +239 -0
- omnibase_infra/utils/util_dsn_validation.py +1 -1
- omnibase_infra/utils/util_retry_optimistic.py +281 -0
- omnibase_infra/validation/__init__.py +3 -19
- omnibase_infra/validation/contracts/security.validation.yaml +114 -0
- omnibase_infra/validation/infra_validators.py +35 -24
- omnibase_infra/validation/validation_exemptions.yaml +140 -9
- omnibase_infra/validation/validator_chain_propagation.py +2 -2
- omnibase_infra/validation/validator_runtime_shape.py +1 -1
- omnibase_infra/validation/validator_security.py +473 -370
- {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/METADATA +3 -3
- {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/RECORD +161 -98
- {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/WHEEL +0 -0
- {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/entry_points.txt +0 -0
- {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/licenses/LICENSE +0 -0
omnibase_infra/utils/__init__.py
CHANGED
|
@@ -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
|
+
]
|
|
@@ -73,7 +73,7 @@ def _assert_postgres_scheme(scheme: str) -> Literal["postgresql", "postgres"]:
|
|
|
73
73
|
parameter="scheme",
|
|
74
74
|
value=scheme,
|
|
75
75
|
)
|
|
76
|
-
return cast(Literal[
|
|
76
|
+
return cast("Literal['postgresql', 'postgres']", scheme)
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
def parse_and_validate_dsn(dsn: object) -> ModelParsedDSN:
|