integration-automation-patterns 0.1.0__tar.gz
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.
- integration_automation_patterns-0.1.0/LICENSE +21 -0
- integration_automation_patterns-0.1.0/PKG-INFO +111 -0
- integration_automation_patterns-0.1.0/README.md +77 -0
- integration_automation_patterns-0.1.0/pyproject.toml +73 -0
- integration_automation_patterns-0.1.0/setup.cfg +4 -0
- integration_automation_patterns-0.1.0/src/integration_automation_patterns/__init__.py +15 -0
- integration_automation_patterns-0.1.0/src/integration_automation_patterns/event_envelope.py +206 -0
- integration_automation_patterns-0.1.0/src/integration_automation_patterns/sync_boundary.py +214 -0
- integration_automation_patterns-0.1.0/src/integration_automation_patterns.egg-info/PKG-INFO +111 -0
- integration_automation_patterns-0.1.0/src/integration_automation_patterns.egg-info/SOURCES.txt +13 -0
- integration_automation_patterns-0.1.0/src/integration_automation_patterns.egg-info/dependency_links.txt +1 -0
- integration_automation_patterns-0.1.0/src/integration_automation_patterns.egg-info/requires.txt +13 -0
- integration_automation_patterns-0.1.0/src/integration_automation_patterns.egg-info/top_level.txt +1 -0
- integration_automation_patterns-0.1.0/tests/test_event_envelope.py +158 -0
- integration_automation_patterns-0.1.0/tests/test_sync_boundary.py +197 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ashutosh Rana
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: integration-automation-patterns
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reference patterns for reliable enterprise integration, workflow automation, and system-of-record synchronization.
|
|
5
|
+
Author: Ashutosh Rana
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ashutoshrana/integration-automation-patterns
|
|
8
|
+
Project-URL: Issues, https://github.com/ashutoshrana/integration-automation-patterns/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/ashutoshrana/integration-automation-patterns/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: enterprise-integration,event-driven,workflow-automation,crm,erp,idempotency,system-of-record,integration-patterns,enterprise-architecture
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
20
|
+
Classifier: Topic :: Office/Business
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
26
|
+
Requires-Dist: pytest-cov>=4.0; extra == "test"
|
|
27
|
+
Provides-Extra: lint
|
|
28
|
+
Requires-Dist: ruff>=0.4.0; extra == "lint"
|
|
29
|
+
Provides-Extra: typecheck
|
|
30
|
+
Requires-Dist: mypy>=1.0; extra == "typecheck"
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: integration-automation-patterns[lint,test,typecheck]; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# integration-automation-patterns
|
|
36
|
+
|
|
37
|
+
[](https://github.com/ashutoshrana/integration-automation-patterns/actions/workflows/ci.yml)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
[](https://www.python.org/downloads/)
|
|
40
|
+
[](https://pypi.org/project/integration-automation-patterns/)
|
|
41
|
+
|
|
42
|
+
Practical patterns for enterprise integration, workflow orchestration, and system-of-record synchronization in complex operating environments.
|
|
43
|
+
|
|
44
|
+
## Why this repo exists
|
|
45
|
+
|
|
46
|
+
Enterprise modernization usually breaks down at the integration layer:
|
|
47
|
+
- brittle handoffs between systems
|
|
48
|
+
- inconsistent event handling
|
|
49
|
+
- weak retry and idempotency models
|
|
50
|
+
- workflow logic scattered across tools
|
|
51
|
+
- poor visibility into operational state
|
|
52
|
+
|
|
53
|
+
This repository is a public-safe reference for patterns that help teams build more reliable integration and automation systems. The patterns are platform-agnostic and cloud-agnostic — applicable across any combination of CRM, ERP, ITSM, and custom services, on any cloud environment (AWS, GCP, Azure, OCI) or on-premises.
|
|
54
|
+
|
|
55
|
+
## Scope
|
|
56
|
+
|
|
57
|
+
This repo focuses on:
|
|
58
|
+
- event-driven integration patterns with explicit retry and idempotency models
|
|
59
|
+
- system-of-record synchronization with authority boundaries
|
|
60
|
+
- workflow orchestration and escalation boundaries
|
|
61
|
+
- observability for automation flows
|
|
62
|
+
- public-safe architecture notes for enterprise operations
|
|
63
|
+
|
|
64
|
+
The patterns do not assume any specific vendor, broker, or cloud platform.
|
|
65
|
+
|
|
66
|
+
## Modules
|
|
67
|
+
|
|
68
|
+
- `event_envelope.py`
|
|
69
|
+
Reliable event transport with explicit delivery status, bounded retry policy,
|
|
70
|
+
and structured audit logging. Works with any message broker (Kafka, SQS,
|
|
71
|
+
Azure Service Bus, GCP Pub/Sub, RabbitMQ, IBM MQ, and others).
|
|
72
|
+
|
|
73
|
+
- `sync_boundary.py`
|
|
74
|
+
System-of-record synchronization contracts for bi-directional integration
|
|
75
|
+
between enterprise platforms. Explicit field-level authority assignment,
|
|
76
|
+
conflict detection, and exclusion management. Platform-agnostic.
|
|
77
|
+
|
|
78
|
+
## Repository structure
|
|
79
|
+
|
|
80
|
+
- `src/integration_automation_patterns/`
|
|
81
|
+
- `event_envelope.py` — event transport with retry and audit
|
|
82
|
+
- `sync_boundary.py` — bi-directional sync authority boundaries
|
|
83
|
+
- `docs/architecture.md`
|
|
84
|
+
- `docs/implementation-note-01.md`
|
|
85
|
+
- `docs/adr/`
|
|
86
|
+
- `examples/event-flow.yaml`
|
|
87
|
+
- `CITATION.cff`
|
|
88
|
+
- `CONTRIBUTING.md`
|
|
89
|
+
- `GOVERNANCE.md`
|
|
90
|
+
|
|
91
|
+
## Near-term roadmap
|
|
92
|
+
|
|
93
|
+
- add integration reliability ADRs
|
|
94
|
+
- add examples for retry-safe event handling across broker types
|
|
95
|
+
- document action logging and audit boundaries
|
|
96
|
+
- add workflow orchestration boundary patterns
|
|
97
|
+
|
|
98
|
+
## Published notes
|
|
99
|
+
|
|
100
|
+
- implementation note: [`docs/implementation-note-01.md`](./docs/implementation-note-01.md)
|
|
101
|
+
|
|
102
|
+
## Intended audience
|
|
103
|
+
|
|
104
|
+
- enterprise architects
|
|
105
|
+
- integration engineers
|
|
106
|
+
- workflow and automation operators
|
|
107
|
+
- platform teams responsible for system-of-record reliability across CRM, ERP, and service platforms
|
|
108
|
+
|
|
109
|
+
## Citing this work
|
|
110
|
+
|
|
111
|
+
If you use these patterns in your work, see `CITATION.cff` or use GitHub's "Cite this repository" button above.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# integration-automation-patterns
|
|
2
|
+
|
|
3
|
+
[](https://github.com/ashutoshrana/integration-automation-patterns/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://www.python.org/downloads/)
|
|
6
|
+
[](https://pypi.org/project/integration-automation-patterns/)
|
|
7
|
+
|
|
8
|
+
Practical patterns for enterprise integration, workflow orchestration, and system-of-record synchronization in complex operating environments.
|
|
9
|
+
|
|
10
|
+
## Why this repo exists
|
|
11
|
+
|
|
12
|
+
Enterprise modernization usually breaks down at the integration layer:
|
|
13
|
+
- brittle handoffs between systems
|
|
14
|
+
- inconsistent event handling
|
|
15
|
+
- weak retry and idempotency models
|
|
16
|
+
- workflow logic scattered across tools
|
|
17
|
+
- poor visibility into operational state
|
|
18
|
+
|
|
19
|
+
This repository is a public-safe reference for patterns that help teams build more reliable integration and automation systems. The patterns are platform-agnostic and cloud-agnostic — applicable across any combination of CRM, ERP, ITSM, and custom services, on any cloud environment (AWS, GCP, Azure, OCI) or on-premises.
|
|
20
|
+
|
|
21
|
+
## Scope
|
|
22
|
+
|
|
23
|
+
This repo focuses on:
|
|
24
|
+
- event-driven integration patterns with explicit retry and idempotency models
|
|
25
|
+
- system-of-record synchronization with authority boundaries
|
|
26
|
+
- workflow orchestration and escalation boundaries
|
|
27
|
+
- observability for automation flows
|
|
28
|
+
- public-safe architecture notes for enterprise operations
|
|
29
|
+
|
|
30
|
+
The patterns do not assume any specific vendor, broker, or cloud platform.
|
|
31
|
+
|
|
32
|
+
## Modules
|
|
33
|
+
|
|
34
|
+
- `event_envelope.py`
|
|
35
|
+
Reliable event transport with explicit delivery status, bounded retry policy,
|
|
36
|
+
and structured audit logging. Works with any message broker (Kafka, SQS,
|
|
37
|
+
Azure Service Bus, GCP Pub/Sub, RabbitMQ, IBM MQ, and others).
|
|
38
|
+
|
|
39
|
+
- `sync_boundary.py`
|
|
40
|
+
System-of-record synchronization contracts for bi-directional integration
|
|
41
|
+
between enterprise platforms. Explicit field-level authority assignment,
|
|
42
|
+
conflict detection, and exclusion management. Platform-agnostic.
|
|
43
|
+
|
|
44
|
+
## Repository structure
|
|
45
|
+
|
|
46
|
+
- `src/integration_automation_patterns/`
|
|
47
|
+
- `event_envelope.py` — event transport with retry and audit
|
|
48
|
+
- `sync_boundary.py` — bi-directional sync authority boundaries
|
|
49
|
+
- `docs/architecture.md`
|
|
50
|
+
- `docs/implementation-note-01.md`
|
|
51
|
+
- `docs/adr/`
|
|
52
|
+
- `examples/event-flow.yaml`
|
|
53
|
+
- `CITATION.cff`
|
|
54
|
+
- `CONTRIBUTING.md`
|
|
55
|
+
- `GOVERNANCE.md`
|
|
56
|
+
|
|
57
|
+
## Near-term roadmap
|
|
58
|
+
|
|
59
|
+
- add integration reliability ADRs
|
|
60
|
+
- add examples for retry-safe event handling across broker types
|
|
61
|
+
- document action logging and audit boundaries
|
|
62
|
+
- add workflow orchestration boundary patterns
|
|
63
|
+
|
|
64
|
+
## Published notes
|
|
65
|
+
|
|
66
|
+
- implementation note: [`docs/implementation-note-01.md`](./docs/implementation-note-01.md)
|
|
67
|
+
|
|
68
|
+
## Intended audience
|
|
69
|
+
|
|
70
|
+
- enterprise architects
|
|
71
|
+
- integration engineers
|
|
72
|
+
- workflow and automation operators
|
|
73
|
+
- platform teams responsible for system-of-record reliability across CRM, ERP, and service platforms
|
|
74
|
+
|
|
75
|
+
## Citing this work
|
|
76
|
+
|
|
77
|
+
If you use these patterns in your work, see `CITATION.cff` or use GitHub's "Cite this repository" button above.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "integration-automation-patterns"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Reference patterns for reliable enterprise integration, workflow automation, and system-of-record synchronization."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Ashutosh Rana" }
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"enterprise-integration",
|
|
17
|
+
"event-driven",
|
|
18
|
+
"workflow-automation",
|
|
19
|
+
"crm",
|
|
20
|
+
"erp",
|
|
21
|
+
"idempotency",
|
|
22
|
+
"system-of-record",
|
|
23
|
+
"integration-patterns",
|
|
24
|
+
"enterprise-architecture",
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Development Status :: 4 - Beta",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"License :: OSI Approved :: MIT License",
|
|
30
|
+
"Programming Language :: Python :: 3",
|
|
31
|
+
"Programming Language :: Python :: 3.10",
|
|
32
|
+
"Programming Language :: Python :: 3.11",
|
|
33
|
+
"Programming Language :: Python :: 3.12",
|
|
34
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
35
|
+
"Topic :: System :: Distributed Computing",
|
|
36
|
+
"Topic :: Office/Business",
|
|
37
|
+
]
|
|
38
|
+
dependencies = []
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/ashutoshrana/integration-automation-patterns"
|
|
42
|
+
Issues = "https://github.com/ashutoshrana/integration-automation-patterns/issues"
|
|
43
|
+
Changelog = "https://github.com/ashutoshrana/integration-automation-patterns/blob/main/CHANGELOG.md"
|
|
44
|
+
|
|
45
|
+
[project.optional-dependencies]
|
|
46
|
+
test = ["pytest>=7.0", "pytest-cov>=4.0"]
|
|
47
|
+
lint = ["ruff>=0.4.0"]
|
|
48
|
+
typecheck = ["mypy>=1.0"]
|
|
49
|
+
dev = [
|
|
50
|
+
"integration-automation-patterns[test,lint,typecheck]",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.setuptools]
|
|
54
|
+
package-dir = {"" = "src"}
|
|
55
|
+
|
|
56
|
+
[tool.setuptools.packages.find]
|
|
57
|
+
where = ["src"]
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
testpaths = ["tests"]
|
|
61
|
+
addopts = "-v --tb=short"
|
|
62
|
+
|
|
63
|
+
[tool.ruff]
|
|
64
|
+
line-length = 100
|
|
65
|
+
target-version = "py310"
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint]
|
|
68
|
+
select = ["E", "F", "I", "UP"]
|
|
69
|
+
|
|
70
|
+
[tool.mypy]
|
|
71
|
+
python_version = "3.10"
|
|
72
|
+
strict = true
|
|
73
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Reference patterns for reliable enterprise integration and workflow automation."""
|
|
2
|
+
|
|
3
|
+
from .event_envelope import DeliveryStatus, EventEnvelope, RetryPolicy
|
|
4
|
+
from .sync_boundary import RecordAuthority, SyncBoundary, SyncConflict
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
# Event handling
|
|
8
|
+
"DeliveryStatus",
|
|
9
|
+
"EventEnvelope",
|
|
10
|
+
"RetryPolicy",
|
|
11
|
+
# Sync boundary
|
|
12
|
+
"RecordAuthority",
|
|
13
|
+
"SyncBoundary",
|
|
14
|
+
"SyncConflict",
|
|
15
|
+
]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
event_envelope.py — Reliable Event Handling for Enterprise Integration
|
|
3
|
+
|
|
4
|
+
Enterprise integration fails most often at the transport layer — not because
|
|
5
|
+
the business logic is wrong, but because events are dropped, duplicated, or
|
|
6
|
+
processed out of order when systems restart, network partitions occur, or
|
|
7
|
+
downstream services are temporarily unavailable.
|
|
8
|
+
|
|
9
|
+
This module provides the structural primitives for building reliable,
|
|
10
|
+
replay-safe event handling in enterprise integration workflows. The patterns
|
|
11
|
+
apply regardless of the message broker (Kafka, SQS, Azure Service Bus,
|
|
12
|
+
GCP Pub/Sub, RabbitMQ, MQ Series) or the enterprise platforms being integrated
|
|
13
|
+
(CRM, ERP, ITSM, custom services).
|
|
14
|
+
|
|
15
|
+
Design goals:
|
|
16
|
+
- Every event carries enough context to be replayed safely
|
|
17
|
+
- Retry behavior is explicit and bounded, not implicit
|
|
18
|
+
- Delivery status is inspectable without querying the broker
|
|
19
|
+
- Deduplication is structural (idempotency key), not a side effect of storage
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DeliveryStatus(Enum):
|
|
31
|
+
"""
|
|
32
|
+
Tracks the lifecycle of one event through the integration pipeline.
|
|
33
|
+
|
|
34
|
+
Events move forward through this lifecycle. An event that has reached
|
|
35
|
+
FAILED should not be silently dropped — it should be routed to a dead
|
|
36
|
+
letter queue or an explicit remediation path.
|
|
37
|
+
"""
|
|
38
|
+
PENDING = "pending" # Created, not yet dispatched
|
|
39
|
+
DISPATCHED = "dispatched" # Sent to broker or downstream system
|
|
40
|
+
ACKNOWLEDGED = "acknowledged" # Downstream confirmed receipt
|
|
41
|
+
PROCESSED = "processed" # Business logic completed successfully
|
|
42
|
+
RETRYING = "retrying" # Failed at least once, within retry budget
|
|
43
|
+
FAILED = "failed" # Retry budget exhausted; requires intervention
|
|
44
|
+
SKIPPED = "skipped" # Intentionally not processed (e.g., duplicate)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class RetryPolicy:
|
|
49
|
+
"""
|
|
50
|
+
Defines retry behavior for a failed event delivery attempt.
|
|
51
|
+
|
|
52
|
+
Explicit retry policies prevent two common failure modes:
|
|
53
|
+
1. Infinite retry loops that mask persistent downstream failures
|
|
54
|
+
2. Retry storms that overwhelm recovering services
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
max_attempts: Total number of delivery attempts allowed (including
|
|
58
|
+
the first). After this many attempts, the event moves to FAILED.
|
|
59
|
+
backoff_seconds: Base wait time between retries in seconds.
|
|
60
|
+
Actual wait = backoff_seconds * (2 ** attempt_number) when
|
|
61
|
+
exponential = True.
|
|
62
|
+
exponential: If True, use exponential backoff. If False, use
|
|
63
|
+
fixed-interval retry.
|
|
64
|
+
max_backoff_seconds: Upper bound on wait time when using exponential
|
|
65
|
+
backoff. Prevents unbounded delays.
|
|
66
|
+
"""
|
|
67
|
+
max_attempts: int = 3
|
|
68
|
+
backoff_seconds: int = 30
|
|
69
|
+
exponential: bool = True
|
|
70
|
+
max_backoff_seconds: int = 3600
|
|
71
|
+
|
|
72
|
+
def wait_seconds_for_attempt(self, attempt_number: int) -> int:
|
|
73
|
+
"""
|
|
74
|
+
Calculate the wait time before the given attempt number.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
attempt_number: Zero-indexed attempt count (0 = first retry,
|
|
78
|
+
not the original attempt).
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Seconds to wait before attempting delivery again.
|
|
82
|
+
"""
|
|
83
|
+
if not self.exponential:
|
|
84
|
+
return self.backoff_seconds
|
|
85
|
+
delay = self.backoff_seconds * (2 ** attempt_number)
|
|
86
|
+
return min(delay, self.max_backoff_seconds)
|
|
87
|
+
|
|
88
|
+
def is_exhausted(self, attempt_number: int) -> bool:
|
|
89
|
+
"""Return True if the attempt budget is exhausted."""
|
|
90
|
+
return attempt_number >= self.max_attempts
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class EventEnvelope:
|
|
95
|
+
"""
|
|
96
|
+
A transport container for one unit of work in an enterprise integration flow.
|
|
97
|
+
|
|
98
|
+
An EventEnvelope carries the event payload alongside the metadata needed
|
|
99
|
+
to route, deduplicate, retry, and audit it. Separating envelope metadata
|
|
100
|
+
from business payload means integration infrastructure can handle
|
|
101
|
+
reliability concerns without touching business logic.
|
|
102
|
+
|
|
103
|
+
Design principle: The envelope must contain everything needed to replay
|
|
104
|
+
the event safely, without requiring coordination with the original sender.
|
|
105
|
+
|
|
106
|
+
Attributes:
|
|
107
|
+
event_id: Globally unique identifier for this event. Used as the
|
|
108
|
+
idempotency key — processing the same event_id twice should
|
|
109
|
+
produce the same result as processing it once.
|
|
110
|
+
event_type: A namespaced string identifying what happened.
|
|
111
|
+
Convention: "<domain>.<entity>.<action>" (e.g., "enrollment.student.updated")
|
|
112
|
+
source_system: Identifier for the system that originated the event.
|
|
113
|
+
Used for routing, filtering, and audit.
|
|
114
|
+
payload: The business data associated with the event. Keep payloads
|
|
115
|
+
small — prefer IDs + change summary over full record snapshots.
|
|
116
|
+
Full records should be fetched from the system of record at
|
|
117
|
+
processing time, not embedded in the event.
|
|
118
|
+
correlation_id: Links this event to a parent workflow, request, or
|
|
119
|
+
transaction. Enables tracing across system boundaries.
|
|
120
|
+
schema_version: Semantic version of the payload schema. Consumers
|
|
121
|
+
should reject events with unexpected versions rather than
|
|
122
|
+
silently misinterpreting the payload.
|
|
123
|
+
created_at: Wall clock time when the event was created. Stored in
|
|
124
|
+
UTC. Do not use for business ordering — use a sequence or
|
|
125
|
+
logical clock for that.
|
|
126
|
+
status: Current delivery status. Updated as the event moves through
|
|
127
|
+
the pipeline.
|
|
128
|
+
attempt_count: Number of delivery attempts made. Starts at 0,
|
|
129
|
+
incremented before each attempt.
|
|
130
|
+
retry_policy: Retry behavior for failed delivery attempts.
|
|
131
|
+
tags: Arbitrary key-value metadata for routing, filtering, or
|
|
132
|
+
observability. Not used in business logic.
|
|
133
|
+
"""
|
|
134
|
+
event_id: str
|
|
135
|
+
event_type: str
|
|
136
|
+
source_system: str
|
|
137
|
+
payload: dict[str, Any]
|
|
138
|
+
correlation_id: str = ""
|
|
139
|
+
schema_version: str = "1.0"
|
|
140
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
141
|
+
status: DeliveryStatus = DeliveryStatus.PENDING
|
|
142
|
+
attempt_count: int = 0
|
|
143
|
+
retry_policy: RetryPolicy = field(default_factory=RetryPolicy)
|
|
144
|
+
tags: dict[str, str] = field(default_factory=dict)
|
|
145
|
+
|
|
146
|
+
def mark_dispatched(self) -> None:
|
|
147
|
+
"""Record that this event has been sent to the broker or downstream system."""
|
|
148
|
+
self.attempt_count += 1
|
|
149
|
+
self.status = DeliveryStatus.DISPATCHED
|
|
150
|
+
|
|
151
|
+
def mark_acknowledged(self) -> None:
|
|
152
|
+
"""Record that the downstream system confirmed receipt."""
|
|
153
|
+
self.status = DeliveryStatus.ACKNOWLEDGED
|
|
154
|
+
|
|
155
|
+
def mark_processed(self) -> None:
|
|
156
|
+
"""Record that business processing completed successfully."""
|
|
157
|
+
self.status = DeliveryStatus.PROCESSED
|
|
158
|
+
|
|
159
|
+
def mark_failed(self) -> None:
|
|
160
|
+
"""
|
|
161
|
+
Record a failed delivery attempt and advance status accordingly.
|
|
162
|
+
|
|
163
|
+
If the retry budget is not exhausted, status becomes RETRYING.
|
|
164
|
+
If the budget is exhausted, status becomes FAILED.
|
|
165
|
+
"""
|
|
166
|
+
if self.retry_policy.is_exhausted(self.attempt_count):
|
|
167
|
+
self.status = DeliveryStatus.FAILED
|
|
168
|
+
else:
|
|
169
|
+
self.status = DeliveryStatus.RETRYING
|
|
170
|
+
|
|
171
|
+
def mark_skipped(self, reason: str = "") -> None:
|
|
172
|
+
"""Record that this event was intentionally skipped (e.g., duplicate)."""
|
|
173
|
+
self.status = DeliveryStatus.SKIPPED
|
|
174
|
+
if reason:
|
|
175
|
+
self.tags["skip_reason"] = reason
|
|
176
|
+
|
|
177
|
+
def next_retry_wait_seconds(self) -> int:
|
|
178
|
+
"""
|
|
179
|
+
Return the wait time before the next delivery attempt.
|
|
180
|
+
|
|
181
|
+
Returns 0 if the event is not in a retryable state.
|
|
182
|
+
"""
|
|
183
|
+
if self.status != DeliveryStatus.RETRYING:
|
|
184
|
+
return 0
|
|
185
|
+
return self.retry_policy.wait_seconds_for_attempt(self.attempt_count)
|
|
186
|
+
|
|
187
|
+
def is_terminal(self) -> bool:
|
|
188
|
+
"""Return True if the event has reached a state that requires no further processing."""
|
|
189
|
+
return self.status in {
|
|
190
|
+
DeliveryStatus.PROCESSED,
|
|
191
|
+
DeliveryStatus.FAILED,
|
|
192
|
+
DeliveryStatus.SKIPPED,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
def to_audit_line(self) -> str:
|
|
196
|
+
"""Format a structured log line for integration observability systems."""
|
|
197
|
+
return (
|
|
198
|
+
f"[INTEGRATION_EVENT] event_id={self.event_id} "
|
|
199
|
+
f"type={self.event_type} "
|
|
200
|
+
f"source={self.source_system} "
|
|
201
|
+
f"status={self.status.value} "
|
|
202
|
+
f"attempts={self.attempt_count} "
|
|
203
|
+
f"correlation={self.correlation_id or 'none'} "
|
|
204
|
+
f"schema={self.schema_version} "
|
|
205
|
+
f"created={self.created_at.isoformat()}"
|
|
206
|
+
)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sync_boundary.py — System-of-Record Synchronization Boundaries
|
|
3
|
+
|
|
4
|
+
Bi-directional synchronization between enterprise systems (CRM and ERP,
|
|
5
|
+
for example) fails in predictable ways. The most common failure is
|
|
6
|
+
ambiguity about which system is authoritative for a given field when
|
|
7
|
+
both systems have updated the same record simultaneously.
|
|
8
|
+
|
|
9
|
+
This module provides the structural primitives for making synchronization
|
|
10
|
+
boundaries explicit. It does not implement a full sync engine — it
|
|
11
|
+
provides the objects needed to define authority, detect conflicts, and
|
|
12
|
+
route resolution decisions to the right system.
|
|
13
|
+
|
|
14
|
+
The pattern applies wherever two or more enterprise platforms must
|
|
15
|
+
maintain consistent state for overlapping records: CRM + ERP,
|
|
16
|
+
CRM + SIS (Student Information System), ERP + ITSM, and similar pairings.
|
|
17
|
+
Platform-agnostic — the primitives work regardless of vendor.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RecordAuthority(Enum):
|
|
29
|
+
"""
|
|
30
|
+
Defines which system is authoritative for a given field or record type.
|
|
31
|
+
|
|
32
|
+
In bi-directional sync, a field must have exactly one authoritative system.
|
|
33
|
+
Changes in the authoritative system propagate outward; changes in
|
|
34
|
+
non-authoritative systems are either rejected, queued for review, or
|
|
35
|
+
overwritten on next sync.
|
|
36
|
+
|
|
37
|
+
SHARED indicates that authority is determined per-field (see SyncBoundary)
|
|
38
|
+
rather than per-record. Use this sparingly — field-level authority
|
|
39
|
+
increases complexity.
|
|
40
|
+
"""
|
|
41
|
+
SYSTEM_A = "system_a" # Source system A is authoritative
|
|
42
|
+
SYSTEM_B = "system_b" # Source system B is authoritative
|
|
43
|
+
SHARED = "shared" # Field-level authority defined separately
|
|
44
|
+
MANUAL = "manual" # Human review required before sync
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class SyncConflict:
|
|
49
|
+
"""
|
|
50
|
+
Represents a detected conflict between two systems' versions of a field.
|
|
51
|
+
|
|
52
|
+
A conflict occurs when both systems have modified the same field since
|
|
53
|
+
the last successful sync. The sync boundary policy determines how to
|
|
54
|
+
resolve it: prefer one system, defer to manual review, or apply a
|
|
55
|
+
merge function.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
field_name: The name of the field where the conflict was detected.
|
|
59
|
+
value_a: The value in system A at detection time.
|
|
60
|
+
value_b: The value in system B at detection time.
|
|
61
|
+
last_sync_value: The agreed-upon value from the last successful sync.
|
|
62
|
+
If None, this is the first sync for this record.
|
|
63
|
+
detected_at: Wall-clock time when the conflict was detected.
|
|
64
|
+
record_id: Identifier of the record where the conflict exists.
|
|
65
|
+
system_a_modified_at: When system A last modified this field.
|
|
66
|
+
None if not available.
|
|
67
|
+
system_b_modified_at: When system B last modified this field.
|
|
68
|
+
None if not available.
|
|
69
|
+
"""
|
|
70
|
+
field_name: str
|
|
71
|
+
value_a: Any
|
|
72
|
+
value_b: Any
|
|
73
|
+
record_id: str
|
|
74
|
+
last_sync_value: Any = None
|
|
75
|
+
detected_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
76
|
+
system_a_modified_at: datetime | None = None
|
|
77
|
+
system_b_modified_at: datetime | None = None
|
|
78
|
+
|
|
79
|
+
def last_writer(self) -> RecordAuthority | None:
|
|
80
|
+
"""
|
|
81
|
+
Return which system made the most recent modification, based on
|
|
82
|
+
available modification timestamps.
|
|
83
|
+
|
|
84
|
+
Returns None if timestamps are unavailable for one or both systems.
|
|
85
|
+
"""
|
|
86
|
+
if self.system_a_modified_at is None or self.system_b_modified_at is None:
|
|
87
|
+
return None
|
|
88
|
+
if self.system_a_modified_at > self.system_b_modified_at:
|
|
89
|
+
return RecordAuthority.SYSTEM_A
|
|
90
|
+
if self.system_b_modified_at > self.system_a_modified_at:
|
|
91
|
+
return RecordAuthority.SYSTEM_B
|
|
92
|
+
return None # Same timestamp — cannot determine last writer
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class SyncBoundary:
|
|
97
|
+
"""
|
|
98
|
+
Defines the synchronization contract between two systems for one record type.
|
|
99
|
+
|
|
100
|
+
A SyncBoundary specifies:
|
|
101
|
+
1. Which fields exist in both systems
|
|
102
|
+
2. Which system is authoritative for each field
|
|
103
|
+
3. Which fields should never be synced (exclusions)
|
|
104
|
+
|
|
105
|
+
Keeping this as an explicit data object — rather than encoding it in
|
|
106
|
+
sync scripts — makes the contract readable, testable, and auditable.
|
|
107
|
+
|
|
108
|
+
Usage::
|
|
109
|
+
|
|
110
|
+
boundary = SyncBoundary(
|
|
111
|
+
record_type="contact",
|
|
112
|
+
system_a_id="crm",
|
|
113
|
+
system_b_id="erp",
|
|
114
|
+
field_authority={
|
|
115
|
+
"email": RecordAuthority.SYSTEM_A,
|
|
116
|
+
"billing_address": RecordAuthority.SYSTEM_B,
|
|
117
|
+
"preferred_name": RecordAuthority.SYSTEM_A,
|
|
118
|
+
"account_status": RecordAuthority.SYSTEM_B,
|
|
119
|
+
},
|
|
120
|
+
excluded_fields={"internal_notes", "crm_lead_score"},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Check if a field is synced and which system wins
|
|
124
|
+
authority = boundary.authority_for("email") # RecordAuthority.SYSTEM_A
|
|
125
|
+
is_synced = boundary.is_synced("internal_notes") # False
|
|
126
|
+
|
|
127
|
+
Attributes:
|
|
128
|
+
record_type: A name for the type of record this boundary governs.
|
|
129
|
+
system_a_id: Identifier for the first system (e.g., "crm", "salesforce", "hubspot").
|
|
130
|
+
system_b_id: Identifier for the second system (e.g., "erp", "peoplesoft", "sap").
|
|
131
|
+
field_authority: Maps field names to which system is authoritative.
|
|
132
|
+
excluded_fields: Fields that are never synchronized, even if present
|
|
133
|
+
in both systems.
|
|
134
|
+
allow_system_b_override: If True, system B changes to SYSTEM_A-authoritative
|
|
135
|
+
fields are queued for manual review rather than silently discarded.
|
|
136
|
+
Default False (system A changes simply overwrite system B).
|
|
137
|
+
"""
|
|
138
|
+
record_type: str
|
|
139
|
+
system_a_id: str
|
|
140
|
+
system_b_id: str
|
|
141
|
+
field_authority: dict[str, RecordAuthority] = field(default_factory=dict)
|
|
142
|
+
excluded_fields: set[str] = field(default_factory=set)
|
|
143
|
+
allow_system_b_override: bool = False
|
|
144
|
+
|
|
145
|
+
def authority_for(self, field_name: str) -> RecordAuthority | None:
|
|
146
|
+
"""
|
|
147
|
+
Return the authoritative system for the given field.
|
|
148
|
+
|
|
149
|
+
Returns None if the field is not in the sync boundary (either
|
|
150
|
+
excluded or not mapped).
|
|
151
|
+
"""
|
|
152
|
+
if field_name in self.excluded_fields:
|
|
153
|
+
return None
|
|
154
|
+
return self.field_authority.get(field_name)
|
|
155
|
+
|
|
156
|
+
def is_synced(self, field_name: str) -> bool:
|
|
157
|
+
"""Return True if this field is part of the synchronization boundary."""
|
|
158
|
+
return field_name not in self.excluded_fields and field_name in self.field_authority
|
|
159
|
+
|
|
160
|
+
def synced_fields(self) -> list[str]:
|
|
161
|
+
"""Return the list of field names that are actively synchronized."""
|
|
162
|
+
return [f for f in self.field_authority if f not in self.excluded_fields]
|
|
163
|
+
|
|
164
|
+
def fields_owned_by(self, authority: RecordAuthority) -> list[str]:
|
|
165
|
+
"""Return all fields where the given system is authoritative."""
|
|
166
|
+
return [
|
|
167
|
+
f for f, auth in self.field_authority.items()
|
|
168
|
+
if auth == authority and f not in self.excluded_fields
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
def detect_conflict(
|
|
172
|
+
self,
|
|
173
|
+
field_name: str,
|
|
174
|
+
value_a: Any,
|
|
175
|
+
value_b: Any,
|
|
176
|
+
record_id: str,
|
|
177
|
+
last_sync_value: Any = None,
|
|
178
|
+
system_a_modified_at: datetime | None = None,
|
|
179
|
+
system_b_modified_at: datetime | None = None,
|
|
180
|
+
) -> SyncConflict | None:
|
|
181
|
+
"""
|
|
182
|
+
Detect whether a conflict exists for the given field.
|
|
183
|
+
|
|
184
|
+
A conflict exists when both systems have a value different from the
|
|
185
|
+
last sync value, indicating both have modified the field since the
|
|
186
|
+
last sync. If only one system differs from the last sync value,
|
|
187
|
+
that system made the authoritative change and no conflict exists.
|
|
188
|
+
|
|
189
|
+
Returns None if no conflict is detected or if the field is not synced.
|
|
190
|
+
"""
|
|
191
|
+
if not self.is_synced(field_name):
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
# Both systems agree — no conflict
|
|
195
|
+
if value_a == value_b:
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
# If we have a last sync value, check whether both systems diverged
|
|
199
|
+
if last_sync_value is not None:
|
|
200
|
+
a_changed = value_a != last_sync_value
|
|
201
|
+
b_changed = value_b != last_sync_value
|
|
202
|
+
if not (a_changed and b_changed):
|
|
203
|
+
# Only one system changed — not a conflict
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
return SyncConflict(
|
|
207
|
+
field_name=field_name,
|
|
208
|
+
value_a=value_a,
|
|
209
|
+
value_b=value_b,
|
|
210
|
+
record_id=record_id,
|
|
211
|
+
last_sync_value=last_sync_value,
|
|
212
|
+
system_a_modified_at=system_a_modified_at,
|
|
213
|
+
system_b_modified_at=system_b_modified_at,
|
|
214
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: integration-automation-patterns
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reference patterns for reliable enterprise integration, workflow automation, and system-of-record synchronization.
|
|
5
|
+
Author: Ashutosh Rana
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ashutoshrana/integration-automation-patterns
|
|
8
|
+
Project-URL: Issues, https://github.com/ashutoshrana/integration-automation-patterns/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/ashutoshrana/integration-automation-patterns/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: enterprise-integration,event-driven,workflow-automation,crm,erp,idempotency,system-of-record,integration-patterns,enterprise-architecture
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
20
|
+
Classifier: Topic :: Office/Business
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
26
|
+
Requires-Dist: pytest-cov>=4.0; extra == "test"
|
|
27
|
+
Provides-Extra: lint
|
|
28
|
+
Requires-Dist: ruff>=0.4.0; extra == "lint"
|
|
29
|
+
Provides-Extra: typecheck
|
|
30
|
+
Requires-Dist: mypy>=1.0; extra == "typecheck"
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: integration-automation-patterns[lint,test,typecheck]; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# integration-automation-patterns
|
|
36
|
+
|
|
37
|
+
[](https://github.com/ashutoshrana/integration-automation-patterns/actions/workflows/ci.yml)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
[](https://www.python.org/downloads/)
|
|
40
|
+
[](https://pypi.org/project/integration-automation-patterns/)
|
|
41
|
+
|
|
42
|
+
Practical patterns for enterprise integration, workflow orchestration, and system-of-record synchronization in complex operating environments.
|
|
43
|
+
|
|
44
|
+
## Why this repo exists
|
|
45
|
+
|
|
46
|
+
Enterprise modernization usually breaks down at the integration layer:
|
|
47
|
+
- brittle handoffs between systems
|
|
48
|
+
- inconsistent event handling
|
|
49
|
+
- weak retry and idempotency models
|
|
50
|
+
- workflow logic scattered across tools
|
|
51
|
+
- poor visibility into operational state
|
|
52
|
+
|
|
53
|
+
This repository is a public-safe reference for patterns that help teams build more reliable integration and automation systems. The patterns are platform-agnostic and cloud-agnostic — applicable across any combination of CRM, ERP, ITSM, and custom services, on any cloud environment (AWS, GCP, Azure, OCI) or on-premises.
|
|
54
|
+
|
|
55
|
+
## Scope
|
|
56
|
+
|
|
57
|
+
This repo focuses on:
|
|
58
|
+
- event-driven integration patterns with explicit retry and idempotency models
|
|
59
|
+
- system-of-record synchronization with authority boundaries
|
|
60
|
+
- workflow orchestration and escalation boundaries
|
|
61
|
+
- observability for automation flows
|
|
62
|
+
- public-safe architecture notes for enterprise operations
|
|
63
|
+
|
|
64
|
+
The patterns do not assume any specific vendor, broker, or cloud platform.
|
|
65
|
+
|
|
66
|
+
## Modules
|
|
67
|
+
|
|
68
|
+
- `event_envelope.py`
|
|
69
|
+
Reliable event transport with explicit delivery status, bounded retry policy,
|
|
70
|
+
and structured audit logging. Works with any message broker (Kafka, SQS,
|
|
71
|
+
Azure Service Bus, GCP Pub/Sub, RabbitMQ, IBM MQ, and others).
|
|
72
|
+
|
|
73
|
+
- `sync_boundary.py`
|
|
74
|
+
System-of-record synchronization contracts for bi-directional integration
|
|
75
|
+
between enterprise platforms. Explicit field-level authority assignment,
|
|
76
|
+
conflict detection, and exclusion management. Platform-agnostic.
|
|
77
|
+
|
|
78
|
+
## Repository structure
|
|
79
|
+
|
|
80
|
+
- `src/integration_automation_patterns/`
|
|
81
|
+
- `event_envelope.py` — event transport with retry and audit
|
|
82
|
+
- `sync_boundary.py` — bi-directional sync authority boundaries
|
|
83
|
+
- `docs/architecture.md`
|
|
84
|
+
- `docs/implementation-note-01.md`
|
|
85
|
+
- `docs/adr/`
|
|
86
|
+
- `examples/event-flow.yaml`
|
|
87
|
+
- `CITATION.cff`
|
|
88
|
+
- `CONTRIBUTING.md`
|
|
89
|
+
- `GOVERNANCE.md`
|
|
90
|
+
|
|
91
|
+
## Near-term roadmap
|
|
92
|
+
|
|
93
|
+
- add integration reliability ADRs
|
|
94
|
+
- add examples for retry-safe event handling across broker types
|
|
95
|
+
- document action logging and audit boundaries
|
|
96
|
+
- add workflow orchestration boundary patterns
|
|
97
|
+
|
|
98
|
+
## Published notes
|
|
99
|
+
|
|
100
|
+
- implementation note: [`docs/implementation-note-01.md`](./docs/implementation-note-01.md)
|
|
101
|
+
|
|
102
|
+
## Intended audience
|
|
103
|
+
|
|
104
|
+
- enterprise architects
|
|
105
|
+
- integration engineers
|
|
106
|
+
- workflow and automation operators
|
|
107
|
+
- platform teams responsible for system-of-record reliability across CRM, ERP, and service platforms
|
|
108
|
+
|
|
109
|
+
## Citing this work
|
|
110
|
+
|
|
111
|
+
If you use these patterns in your work, see `CITATION.cff` or use GitHub's "Cite this repository" button above.
|
integration_automation_patterns-0.1.0/src/integration_automation_patterns.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/integration_automation_patterns/__init__.py
|
|
5
|
+
src/integration_automation_patterns/event_envelope.py
|
|
6
|
+
src/integration_automation_patterns/sync_boundary.py
|
|
7
|
+
src/integration_automation_patterns.egg-info/PKG-INFO
|
|
8
|
+
src/integration_automation_patterns.egg-info/SOURCES.txt
|
|
9
|
+
src/integration_automation_patterns.egg-info/dependency_links.txt
|
|
10
|
+
src/integration_automation_patterns.egg-info/requires.txt
|
|
11
|
+
src/integration_automation_patterns.egg-info/top_level.txt
|
|
12
|
+
tests/test_event_envelope.py
|
|
13
|
+
tests/test_sync_boundary.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
integration_automation_patterns-0.1.0/src/integration_automation_patterns.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
integration_automation_patterns
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Tests for integration_automation_patterns.event_envelope."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from integration_automation_patterns.event_envelope import (
|
|
6
|
+
DeliveryStatus,
|
|
7
|
+
EventEnvelope,
|
|
8
|
+
RetryPolicy,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# RetryPolicy
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
class TestRetryPolicy:
|
|
17
|
+
def test_default_values(self):
|
|
18
|
+
rp = RetryPolicy()
|
|
19
|
+
assert rp.max_attempts == 3
|
|
20
|
+
assert rp.backoff_seconds == 30
|
|
21
|
+
assert rp.exponential is True
|
|
22
|
+
assert rp.max_backoff_seconds == 3600
|
|
23
|
+
|
|
24
|
+
def test_is_exhausted_at_limit(self):
|
|
25
|
+
rp = RetryPolicy(max_attempts=3)
|
|
26
|
+
assert rp.is_exhausted(3) is True
|
|
27
|
+
|
|
28
|
+
def test_is_not_exhausted_below_limit(self):
|
|
29
|
+
rp = RetryPolicy(max_attempts=3)
|
|
30
|
+
assert rp.is_exhausted(2) is False
|
|
31
|
+
|
|
32
|
+
def test_fixed_backoff(self):
|
|
33
|
+
rp = RetryPolicy(backoff_seconds=10, exponential=False)
|
|
34
|
+
assert rp.wait_seconds_for_attempt(0) == 10
|
|
35
|
+
assert rp.wait_seconds_for_attempt(5) == 10
|
|
36
|
+
|
|
37
|
+
def test_exponential_backoff(self):
|
|
38
|
+
rp = RetryPolicy(backoff_seconds=10, exponential=True, max_backoff_seconds=9999)
|
|
39
|
+
assert rp.wait_seconds_for_attempt(0) == 10 # 10 * 2^0
|
|
40
|
+
assert rp.wait_seconds_for_attempt(1) == 20 # 10 * 2^1
|
|
41
|
+
assert rp.wait_seconds_for_attempt(2) == 40 # 10 * 2^2
|
|
42
|
+
|
|
43
|
+
def test_exponential_backoff_capped(self):
|
|
44
|
+
rp = RetryPolicy(backoff_seconds=10, exponential=True, max_backoff_seconds=50)
|
|
45
|
+
assert rp.wait_seconds_for_attempt(10) == 50 # capped at max
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# EventEnvelope lifecycle
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def _make_event(**kwargs):
|
|
53
|
+
defaults = dict(
|
|
54
|
+
event_id="evt-001",
|
|
55
|
+
event_type="enrollment.student.updated",
|
|
56
|
+
source_system="crm",
|
|
57
|
+
payload={"student_id": "S-1"},
|
|
58
|
+
)
|
|
59
|
+
defaults.update(kwargs)
|
|
60
|
+
return EventEnvelope(**defaults)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestEventEnvelopeInitial:
|
|
64
|
+
def test_initial_status_pending(self):
|
|
65
|
+
ev = _make_event()
|
|
66
|
+
assert ev.status == DeliveryStatus.PENDING
|
|
67
|
+
|
|
68
|
+
def test_initial_attempt_count_zero(self):
|
|
69
|
+
ev = _make_event()
|
|
70
|
+
assert ev.attempt_count == 0
|
|
71
|
+
|
|
72
|
+
def test_is_not_terminal_when_pending(self):
|
|
73
|
+
ev = _make_event()
|
|
74
|
+
assert ev.is_terminal() is False
|
|
75
|
+
|
|
76
|
+
def test_fields_stored(self):
|
|
77
|
+
ev = _make_event(correlation_id="corr-99", schema_version="2.0")
|
|
78
|
+
assert ev.event_id == "evt-001"
|
|
79
|
+
assert ev.event_type == "enrollment.student.updated"
|
|
80
|
+
assert ev.source_system == "crm"
|
|
81
|
+
assert ev.correlation_id == "corr-99"
|
|
82
|
+
assert ev.schema_version == "2.0"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestEventEnvelopeTransitions:
|
|
86
|
+
def test_mark_dispatched_increments_attempt(self):
|
|
87
|
+
ev = _make_event()
|
|
88
|
+
ev.mark_dispatched()
|
|
89
|
+
assert ev.attempt_count == 1
|
|
90
|
+
assert ev.status == DeliveryStatus.DISPATCHED
|
|
91
|
+
|
|
92
|
+
def test_mark_acknowledged(self):
|
|
93
|
+
ev = _make_event()
|
|
94
|
+
ev.mark_dispatched()
|
|
95
|
+
ev.mark_acknowledged()
|
|
96
|
+
assert ev.status == DeliveryStatus.ACKNOWLEDGED
|
|
97
|
+
|
|
98
|
+
def test_mark_processed_is_terminal(self):
|
|
99
|
+
ev = _make_event()
|
|
100
|
+
ev.mark_dispatched()
|
|
101
|
+
ev.mark_processed()
|
|
102
|
+
assert ev.status == DeliveryStatus.PROCESSED
|
|
103
|
+
assert ev.is_terminal() is True
|
|
104
|
+
|
|
105
|
+
def test_mark_failed_within_budget_sets_retrying(self):
|
|
106
|
+
ev = _make_event()
|
|
107
|
+
ev.mark_dispatched() # attempt_count = 1
|
|
108
|
+
ev.mark_failed() # attempt_count=1 < max_attempts=3 → RETRYING
|
|
109
|
+
assert ev.status == DeliveryStatus.RETRYING
|
|
110
|
+
assert ev.is_terminal() is False
|
|
111
|
+
|
|
112
|
+
def test_mark_failed_budget_exhausted_sets_failed(self):
|
|
113
|
+
ev = _make_event()
|
|
114
|
+
for _ in range(3): # exhaust budget
|
|
115
|
+
ev.mark_dispatched()
|
|
116
|
+
ev.mark_failed()
|
|
117
|
+
assert ev.status == DeliveryStatus.FAILED
|
|
118
|
+
assert ev.is_terminal() is True
|
|
119
|
+
|
|
120
|
+
def test_mark_skipped_is_terminal(self):
|
|
121
|
+
ev = _make_event()
|
|
122
|
+
ev.mark_skipped(reason="duplicate")
|
|
123
|
+
assert ev.status == DeliveryStatus.SKIPPED
|
|
124
|
+
assert ev.is_terminal() is True
|
|
125
|
+
assert ev.tags["skip_reason"] == "duplicate"
|
|
126
|
+
|
|
127
|
+
def test_mark_skipped_without_reason(self):
|
|
128
|
+
ev = _make_event()
|
|
129
|
+
ev.mark_skipped()
|
|
130
|
+
assert ev.status == DeliveryStatus.SKIPPED
|
|
131
|
+
assert "skip_reason" not in ev.tags
|
|
132
|
+
|
|
133
|
+
def test_next_retry_wait_zero_when_not_retrying(self):
|
|
134
|
+
ev = _make_event()
|
|
135
|
+
assert ev.next_retry_wait_seconds() == 0
|
|
136
|
+
|
|
137
|
+
def test_next_retry_wait_positive_when_retrying(self):
|
|
138
|
+
ev = _make_event()
|
|
139
|
+
ev.mark_dispatched()
|
|
140
|
+
ev.mark_failed()
|
|
141
|
+
assert ev.next_retry_wait_seconds() > 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestEventEnvelopeAudit:
|
|
145
|
+
def test_to_audit_line_contains_key_fields(self):
|
|
146
|
+
ev = _make_event(correlation_id="corr-1")
|
|
147
|
+
ev.mark_dispatched()
|
|
148
|
+
line = ev.to_audit_line()
|
|
149
|
+
assert "[INTEGRATION_EVENT]" in line
|
|
150
|
+
assert "event_id=evt-001" in line
|
|
151
|
+
assert "type=enrollment.student.updated" in line
|
|
152
|
+
assert "source=crm" in line
|
|
153
|
+
assert "correlation=corr-1" in line
|
|
154
|
+
|
|
155
|
+
def test_audit_line_no_correlation(self):
|
|
156
|
+
ev = _make_event()
|
|
157
|
+
line = ev.to_audit_line()
|
|
158
|
+
assert "correlation=none" in line
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Tests for integration_automation_patterns.sync_boundary."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from integration_automation_patterns.sync_boundary import (
|
|
8
|
+
RecordAuthority,
|
|
9
|
+
SyncBoundary,
|
|
10
|
+
SyncConflict,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _dt(offset_seconds: int = 0) -> datetime:
|
|
15
|
+
from datetime import timedelta
|
|
16
|
+
return datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=offset_seconds)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _boundary(**kwargs):
|
|
20
|
+
defaults = dict(
|
|
21
|
+
record_type="contact",
|
|
22
|
+
system_a_id="crm",
|
|
23
|
+
system_b_id="erp",
|
|
24
|
+
field_authority={
|
|
25
|
+
"email": RecordAuthority.SYSTEM_A,
|
|
26
|
+
"billing_address": RecordAuthority.SYSTEM_B,
|
|
27
|
+
"status": RecordAuthority.SYSTEM_A,
|
|
28
|
+
},
|
|
29
|
+
excluded_fields={"internal_notes"},
|
|
30
|
+
)
|
|
31
|
+
defaults.update(kwargs)
|
|
32
|
+
return SyncBoundary(**defaults)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# SyncBoundary.authority_for
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
class TestSyncBoundaryAuthority:
|
|
40
|
+
def test_returns_authority_for_known_field(self):
|
|
41
|
+
b = _boundary()
|
|
42
|
+
assert b.authority_for("email") == RecordAuthority.SYSTEM_A
|
|
43
|
+
assert b.authority_for("billing_address") == RecordAuthority.SYSTEM_B
|
|
44
|
+
|
|
45
|
+
def test_returns_none_for_excluded_field(self):
|
|
46
|
+
b = _boundary()
|
|
47
|
+
assert b.authority_for("internal_notes") is None
|
|
48
|
+
|
|
49
|
+
def test_returns_none_for_unknown_field(self):
|
|
50
|
+
b = _boundary()
|
|
51
|
+
assert b.authority_for("unknown_field") is None
|
|
52
|
+
|
|
53
|
+
def test_is_synced_true_for_mapped_field(self):
|
|
54
|
+
b = _boundary()
|
|
55
|
+
assert b.is_synced("email") is True
|
|
56
|
+
|
|
57
|
+
def test_is_synced_false_for_excluded(self):
|
|
58
|
+
b = _boundary()
|
|
59
|
+
assert b.is_synced("internal_notes") is False
|
|
60
|
+
|
|
61
|
+
def test_is_synced_false_for_unmapped(self):
|
|
62
|
+
b = _boundary()
|
|
63
|
+
assert b.is_synced("no_such_field") is False
|
|
64
|
+
|
|
65
|
+
def test_synced_fields_excludes_excluded(self):
|
|
66
|
+
b = _boundary()
|
|
67
|
+
synced = b.synced_fields()
|
|
68
|
+
assert "internal_notes" not in synced
|
|
69
|
+
assert "email" in synced
|
|
70
|
+
|
|
71
|
+
def test_fields_owned_by_system_a(self):
|
|
72
|
+
b = _boundary()
|
|
73
|
+
owned = b.fields_owned_by(RecordAuthority.SYSTEM_A)
|
|
74
|
+
assert "email" in owned
|
|
75
|
+
assert "status" in owned
|
|
76
|
+
assert "billing_address" not in owned
|
|
77
|
+
|
|
78
|
+
def test_fields_owned_by_system_b(self):
|
|
79
|
+
b = _boundary()
|
|
80
|
+
owned = b.fields_owned_by(RecordAuthority.SYSTEM_B)
|
|
81
|
+
assert "billing_address" in owned
|
|
82
|
+
assert "email" not in owned
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# SyncBoundary.detect_conflict
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
class TestDetectConflict:
|
|
90
|
+
def test_no_conflict_when_values_equal(self):
|
|
91
|
+
b = _boundary()
|
|
92
|
+
result = b.detect_conflict("email", "a@x.com", "a@x.com", "rec-1")
|
|
93
|
+
assert result is None
|
|
94
|
+
|
|
95
|
+
def test_conflict_when_both_differ_from_last_sync(self):
|
|
96
|
+
b = _boundary()
|
|
97
|
+
conflict = b.detect_conflict(
|
|
98
|
+
field_name="email",
|
|
99
|
+
value_a="new_a@x.com",
|
|
100
|
+
value_b="new_b@x.com",
|
|
101
|
+
record_id="rec-1",
|
|
102
|
+
last_sync_value="old@x.com",
|
|
103
|
+
)
|
|
104
|
+
assert conflict is not None
|
|
105
|
+
assert isinstance(conflict, SyncConflict)
|
|
106
|
+
assert conflict.field_name == "email"
|
|
107
|
+
|
|
108
|
+
def test_no_conflict_when_only_a_changed(self):
|
|
109
|
+
b = _boundary()
|
|
110
|
+
result = b.detect_conflict(
|
|
111
|
+
field_name="email",
|
|
112
|
+
value_a="new_a@x.com",
|
|
113
|
+
value_b="old@x.com",
|
|
114
|
+
record_id="rec-1",
|
|
115
|
+
last_sync_value="old@x.com",
|
|
116
|
+
)
|
|
117
|
+
assert result is None
|
|
118
|
+
|
|
119
|
+
def test_no_conflict_when_only_b_changed(self):
|
|
120
|
+
b = _boundary()
|
|
121
|
+
result = b.detect_conflict(
|
|
122
|
+
field_name="email",
|
|
123
|
+
value_a="old@x.com",
|
|
124
|
+
value_b="new_b@x.com",
|
|
125
|
+
record_id="rec-1",
|
|
126
|
+
last_sync_value="old@x.com",
|
|
127
|
+
)
|
|
128
|
+
assert result is None
|
|
129
|
+
|
|
130
|
+
def test_conflict_without_last_sync_value(self):
|
|
131
|
+
b = _boundary()
|
|
132
|
+
conflict = b.detect_conflict("email", "a@x.com", "b@x.com", "rec-1")
|
|
133
|
+
assert conflict is not None
|
|
134
|
+
|
|
135
|
+
def test_no_conflict_for_excluded_field(self):
|
|
136
|
+
b = _boundary()
|
|
137
|
+
result = b.detect_conflict("internal_notes", "val_a", "val_b", "rec-1")
|
|
138
|
+
assert result is None
|
|
139
|
+
|
|
140
|
+
def test_no_conflict_for_unmapped_field(self):
|
|
141
|
+
b = _boundary()
|
|
142
|
+
result = b.detect_conflict("unknown_field", "val_a", "val_b", "rec-1")
|
|
143
|
+
assert result is None
|
|
144
|
+
|
|
145
|
+
def test_conflict_contains_correct_values(self):
|
|
146
|
+
b = _boundary()
|
|
147
|
+
conflict = b.detect_conflict(
|
|
148
|
+
"email", "a@x.com", "b@x.com", "rec-42", last_sync_value="old@x.com"
|
|
149
|
+
)
|
|
150
|
+
assert conflict.value_a == "a@x.com"
|
|
151
|
+
assert conflict.value_b == "b@x.com"
|
|
152
|
+
assert conflict.last_sync_value == "old@x.com"
|
|
153
|
+
assert conflict.record_id == "rec-42"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# SyncConflict.last_writer
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
class TestSyncConflictLastWriter:
|
|
161
|
+
def test_last_writer_a_wins(self):
|
|
162
|
+
c = SyncConflict(
|
|
163
|
+
field_name="email",
|
|
164
|
+
value_a="new",
|
|
165
|
+
value_b="old",
|
|
166
|
+
record_id="r1",
|
|
167
|
+
system_a_modified_at=_dt(100),
|
|
168
|
+
system_b_modified_at=_dt(50),
|
|
169
|
+
)
|
|
170
|
+
assert c.last_writer() == RecordAuthority.SYSTEM_A
|
|
171
|
+
|
|
172
|
+
def test_last_writer_b_wins(self):
|
|
173
|
+
c = SyncConflict(
|
|
174
|
+
field_name="email",
|
|
175
|
+
value_a="old",
|
|
176
|
+
value_b="new",
|
|
177
|
+
record_id="r1",
|
|
178
|
+
system_a_modified_at=_dt(50),
|
|
179
|
+
system_b_modified_at=_dt(100),
|
|
180
|
+
)
|
|
181
|
+
assert c.last_writer() == RecordAuthority.SYSTEM_B
|
|
182
|
+
|
|
183
|
+
def test_last_writer_none_when_timestamps_missing(self):
|
|
184
|
+
c = SyncConflict(field_name="email", value_a="a", value_b="b", record_id="r1")
|
|
185
|
+
assert c.last_writer() is None
|
|
186
|
+
|
|
187
|
+
def test_last_writer_none_when_same_timestamp(self):
|
|
188
|
+
ts = _dt(100)
|
|
189
|
+
c = SyncConflict(
|
|
190
|
+
field_name="email",
|
|
191
|
+
value_a="a",
|
|
192
|
+
value_b="b",
|
|
193
|
+
record_id="r1",
|
|
194
|
+
system_a_modified_at=ts,
|
|
195
|
+
system_b_modified_at=ts,
|
|
196
|
+
)
|
|
197
|
+
assert c.last_writer() is None
|