odysseys-py 0.0.1__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.
- odyssey/__init__.py +0 -0
- odyssey/async_core.py +0 -0
- odyssey/async_odyssey.py +0 -0
- odyssey/build_ledger.py +64 -0
- odyssey/config.py +0 -0
- odyssey/core.py +171 -0
- odyssey/odyssey.py +0 -0
- odyssey/results.py +29 -0
- odyssey/schema.py +78 -0
- odyssey/utils.py +14 -0
- odysseys_py-0.0.1.dist-info/METADATA +20 -0
- odysseys_py-0.0.1.dist-info/RECORD +14 -0
- odysseys_py-0.0.1.dist-info/WHEEL +5 -0
- odysseys_py-0.0.1.dist-info/top_level.txt +1 -0
odyssey/__init__.py
ADDED
|
File without changes
|
odyssey/async_core.py
ADDED
|
File without changes
|
odyssey/async_odyssey.py
ADDED
|
File without changes
|
odyssey/build_ledger.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
class Build_ledger:
|
|
2
|
+
def __init__(self, get_conn, key, steps, delegates):
|
|
3
|
+
self.get_conn = get_conn
|
|
4
|
+
self.key = key
|
|
5
|
+
self.steps = steps
|
|
6
|
+
self.delegates = delegates or {}
|
|
7
|
+
|
|
8
|
+
def run(self):
|
|
9
|
+
|
|
10
|
+
if not self.steps:
|
|
11
|
+
raise ValueError("build_ledger requires at least one step")
|
|
12
|
+
|
|
13
|
+
if len(self.steps) != len(set(self.steps)):
|
|
14
|
+
raise ValueError("steps must be unique")
|
|
15
|
+
|
|
16
|
+
unknown = set(self.delegates) - set(self.steps)
|
|
17
|
+
if unknown:
|
|
18
|
+
raise ValueError(f"delegates references steps not present in `steps`: {sorted(unknown)}")
|
|
19
|
+
|
|
20
|
+
conn = self.get_conn()
|
|
21
|
+
ledger_rows = []
|
|
22
|
+
delivery_rows = []
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
for target in self.steps:
|
|
26
|
+
|
|
27
|
+
if target in self.delegates:
|
|
28
|
+
destination = self.delegates.get(target)
|
|
29
|
+
delivery_rows.append((self.key, target, destination))
|
|
30
|
+
ledger_rows.append((self.key, target, "delegated"))
|
|
31
|
+
else:
|
|
32
|
+
ledger_rows.append((self.key, target, "local"))
|
|
33
|
+
|
|
34
|
+
with conn.cursor() as cur:
|
|
35
|
+
cur.executemany("""
|
|
36
|
+
INSERT INTO odyssey_ledger(
|
|
37
|
+
key,
|
|
38
|
+
target,
|
|
39
|
+
mode
|
|
40
|
+
)
|
|
41
|
+
VALUES(%s,%s,%s)
|
|
42
|
+
""", ledger_rows, )
|
|
43
|
+
|
|
44
|
+
cur.executemany("""
|
|
45
|
+
INSERT INTO odyssey_deliveries(
|
|
46
|
+
key,
|
|
47
|
+
target,
|
|
48
|
+
emit_to
|
|
49
|
+
)
|
|
50
|
+
VALUES(%s, %s, %s)
|
|
51
|
+
""", delivery_rows, )
|
|
52
|
+
|
|
53
|
+
conn.commit()
|
|
54
|
+
return {
|
|
55
|
+
"steps": len(ledger_rows),
|
|
56
|
+
"delegated": len(delivery_rows)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
except Exception as e:
|
|
60
|
+
conn.rollback()
|
|
61
|
+
raise RuntimeError("Failed to build ledger") from e
|
|
62
|
+
|
|
63
|
+
finally:
|
|
64
|
+
conn.close()
|
odyssey/config.py
ADDED
|
File without changes
|
odyssey/core.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from .utils import get_owner_id, row_to_dict
|
|
3
|
+
from .results import AcquireResult, OperationResult, InspectResult
|
|
4
|
+
|
|
5
|
+
def acquire(conn, key, *, owner_id=None, ttl_ms=10000):
|
|
6
|
+
|
|
7
|
+
owner_id = owner_id or get_owner_id()
|
|
8
|
+
ttl_ms = ttl_ms if ttl_ms and ttl_ms > 0 else 10000
|
|
9
|
+
|
|
10
|
+
row = None
|
|
11
|
+
|
|
12
|
+
with conn.cursor() as cur:
|
|
13
|
+
cur.execute("""
|
|
14
|
+
INSERT INTO odyssey_journeys (
|
|
15
|
+
key,
|
|
16
|
+
owner_id,
|
|
17
|
+
expires_at,
|
|
18
|
+
updated_at,
|
|
19
|
+
fencing_token
|
|
20
|
+
)
|
|
21
|
+
VALUES (
|
|
22
|
+
%s,
|
|
23
|
+
%s,
|
|
24
|
+
NOW() + (%s * INTERVAL '1 millisecond'),
|
|
25
|
+
NOW(),
|
|
26
|
+
nextval('odyssey_token_seq')
|
|
27
|
+
)
|
|
28
|
+
ON CONFLICT (key)
|
|
29
|
+
DO UPDATE
|
|
30
|
+
SET
|
|
31
|
+
owner_id = EXCLUDED.owner_id,
|
|
32
|
+
expires_at = EXCLUDED.expires_at,
|
|
33
|
+
updated_at = NOW(),
|
|
34
|
+
fencing_token = nextval('odyssey_token_seq')
|
|
35
|
+
WHERE odyssey_journeys.expires_at < NOW() AND odyssey_journeys.status = 'claimed'
|
|
36
|
+
RETURNING owner_id, expires_at, fencing_token, status, expires_at > NOW() AS journey_alive;
|
|
37
|
+
""", (key, owner_id, ttl_ms))
|
|
38
|
+
|
|
39
|
+
result = cur.fetchone()
|
|
40
|
+
|
|
41
|
+
if result is not None:
|
|
42
|
+
row = row_to_dict(cur, result)
|
|
43
|
+
|
|
44
|
+
conn.commit()
|
|
45
|
+
|
|
46
|
+
if row is not None and row["fencing_token"] is None:
|
|
47
|
+
raise Exception("Invariant violation: fencing_token is None")
|
|
48
|
+
|
|
49
|
+
if row is not None:
|
|
50
|
+
return AcquireResult(
|
|
51
|
+
acquired=True,
|
|
52
|
+
owner_id=row["owner_id"],
|
|
53
|
+
expires_at=row["expires_at"],
|
|
54
|
+
journey_alive=row["journey_alive"],
|
|
55
|
+
fencing_token=row["fencing_token"],
|
|
56
|
+
status=row["status"]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
with conn.cursor() as cur:
|
|
60
|
+
cur.execute("""
|
|
61
|
+
SELECT owner_id, expires_at, fencing_token, status, expires_at > NOW() AS journey_alive
|
|
62
|
+
FROM odyssey_journeys
|
|
63
|
+
WHERE key = %s
|
|
64
|
+
""", (key,))
|
|
65
|
+
|
|
66
|
+
result = cur.fetchone()
|
|
67
|
+
|
|
68
|
+
if result is not None:
|
|
69
|
+
row = row_to_dict(cur, result)
|
|
70
|
+
|
|
71
|
+
if row is not None:
|
|
72
|
+
return AcquireResult(acquired=False,
|
|
73
|
+
owner_id=row["owner_id"],
|
|
74
|
+
expires_at=row["expires_at"],
|
|
75
|
+
journey_alive=row["journey_alive"],
|
|
76
|
+
fencing_token=row["fencing_token"],
|
|
77
|
+
status=row["status"])
|
|
78
|
+
|
|
79
|
+
def start_execution(conn, key, *, fencing_token):
|
|
80
|
+
|
|
81
|
+
row = None
|
|
82
|
+
|
|
83
|
+
with conn.cursor() as cur:
|
|
84
|
+
cur.execute("""
|
|
85
|
+
UPDATE odyssey_journeys
|
|
86
|
+
SET status = 'executing',
|
|
87
|
+
updated_at = NOW()
|
|
88
|
+
WHERE key = %s
|
|
89
|
+
AND fencing_token = %s
|
|
90
|
+
AND status = 'claimed'
|
|
91
|
+
RETURNING status;
|
|
92
|
+
""", (key, fencing_token))
|
|
93
|
+
|
|
94
|
+
result = cur.fetchone()
|
|
95
|
+
success = result is not None
|
|
96
|
+
if result is not None:
|
|
97
|
+
row = row_to_dict(cur, result)
|
|
98
|
+
|
|
99
|
+
conn.commit()
|
|
100
|
+
if row is None:
|
|
101
|
+
return OperationResult(success)
|
|
102
|
+
|
|
103
|
+
return OperationResult(success, status=row["status"])
|
|
104
|
+
|
|
105
|
+
def complete(conn, key, *, fencing_token, execution_result=None):
|
|
106
|
+
|
|
107
|
+
serialized_result = (
|
|
108
|
+
json.dumps(execution_result)
|
|
109
|
+
if execution_result is not None
|
|
110
|
+
else None
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
with conn.cursor() as cur:
|
|
114
|
+
cur.execute("""
|
|
115
|
+
UPDATE odyssey_journeys
|
|
116
|
+
SET
|
|
117
|
+
status = 'completed',
|
|
118
|
+
execution_result = %s,
|
|
119
|
+
updated_at = NOW()
|
|
120
|
+
WHERE key = %s
|
|
121
|
+
AND fencing_token = %s
|
|
122
|
+
AND status = 'executing'
|
|
123
|
+
RETURNING 1;
|
|
124
|
+
""", (serialized_result, key, fencing_token))
|
|
125
|
+
|
|
126
|
+
success = cur.fetchone() is not None
|
|
127
|
+
|
|
128
|
+
conn.commit()
|
|
129
|
+
return OperationResult(success)
|
|
130
|
+
|
|
131
|
+
def abandon(conn, key, *, fencing_token):
|
|
132
|
+
with conn.cursor() as cur:
|
|
133
|
+
cur.execute("""
|
|
134
|
+
UPDATE odyssey_journeys
|
|
135
|
+
SET expires_at = NOW(),
|
|
136
|
+
updated_at = NOW()
|
|
137
|
+
WHERE key = %s
|
|
138
|
+
AND fencing_token = %s
|
|
139
|
+
AND status = 'executing'
|
|
140
|
+
RETURNING 1;
|
|
141
|
+
""", (key, fencing_token))
|
|
142
|
+
success = cur.fetchone() is not None
|
|
143
|
+
|
|
144
|
+
conn.commit()
|
|
145
|
+
return OperationResult(success)
|
|
146
|
+
|
|
147
|
+
def inspect(conn, key):
|
|
148
|
+
with conn.cursor() as cur:
|
|
149
|
+
cur.execute("""
|
|
150
|
+
SELECT owner_id, expires_at, updated_at, fencing_token, status,
|
|
151
|
+
expires_at > NOW() AS journey_alive, execution_result
|
|
152
|
+
FROM odyssey_journeys
|
|
153
|
+
WHERE key = %s
|
|
154
|
+
""", (key,))
|
|
155
|
+
|
|
156
|
+
result = cur.fetchone()
|
|
157
|
+
|
|
158
|
+
if result is None:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
row = row_to_dict(cur, result)
|
|
162
|
+
|
|
163
|
+
return InspectResult(
|
|
164
|
+
key = key,
|
|
165
|
+
owner_id = row["owner_id"],
|
|
166
|
+
fencing_token = row["fencing_token"],
|
|
167
|
+
status = row["status"],
|
|
168
|
+
journey_alive = row["journey_alive"],
|
|
169
|
+
expires_at = row["expires_at"],
|
|
170
|
+
updated_at = row["updated_at"],
|
|
171
|
+
execution_result = row["execution_result"])
|
odyssey/odyssey.py
ADDED
|
File without changes
|
odyssey/results.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Response structure of acquire
|
|
2
|
+
class AcquireResult:
|
|
3
|
+
def __init__(self, acquired, owner_id=None, expires_at=None, fencing_token=None, status=None, journey_alive=None):
|
|
4
|
+
self.acquired = acquired
|
|
5
|
+
self.owner_id = owner_id
|
|
6
|
+
self.expires_at = expires_at
|
|
7
|
+
self.fencing_token = fencing_token
|
|
8
|
+
self.status = status
|
|
9
|
+
self.journey_alive = journey_alive
|
|
10
|
+
|
|
11
|
+
class OperationResult:
|
|
12
|
+
def __init__(self, success: bool, status=None):
|
|
13
|
+
self.success = success
|
|
14
|
+
self.status = status
|
|
15
|
+
|
|
16
|
+
class InspectResult:
|
|
17
|
+
def __init__(
|
|
18
|
+
self, key, owner_id, fencing_token, status, journey_alive,
|
|
19
|
+
expires_at, updated_at, execution_result=None
|
|
20
|
+
):
|
|
21
|
+
self.key = key
|
|
22
|
+
self.owner_id = owner_id
|
|
23
|
+
self.fencing_token = fencing_token
|
|
24
|
+
self.status = status
|
|
25
|
+
self.journey_alive = journey_alive
|
|
26
|
+
self.expires_at = expires_at
|
|
27
|
+
self.updated_at = updated_at
|
|
28
|
+
self.execution_result = execution_result
|
|
29
|
+
|
odyssey/schema.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
SCHEMA_SQL = """
|
|
2
|
+
CREATE SEQUENCE IF NOT EXISTS odyssey_token_seq;
|
|
3
|
+
|
|
4
|
+
DO $$
|
|
5
|
+
BEGIN
|
|
6
|
+
CREATE TYPE odyssey_status AS ENUM (
|
|
7
|
+
'claimed',
|
|
8
|
+
'executing',
|
|
9
|
+
'completed',
|
|
10
|
+
'reconciling'
|
|
11
|
+
);
|
|
12
|
+
EXCEPTION
|
|
13
|
+
WHEN duplicate_object THEN NULL;
|
|
14
|
+
END $$;
|
|
15
|
+
|
|
16
|
+
DO $$
|
|
17
|
+
BEGIN
|
|
18
|
+
CREATE TYPE odyssey_execution_mode AS ENUM (
|
|
19
|
+
'local',
|
|
20
|
+
'delegated'
|
|
21
|
+
);
|
|
22
|
+
EXCEPTION
|
|
23
|
+
WHEN duplicate_object THEN NULL;
|
|
24
|
+
END $$;
|
|
25
|
+
|
|
26
|
+
DO $$
|
|
27
|
+
BEGIN
|
|
28
|
+
CREATE TYPE delivery_status AS ENUM (
|
|
29
|
+
'delivered',
|
|
30
|
+
'failed',
|
|
31
|
+
'pending'
|
|
32
|
+
);
|
|
33
|
+
EXCEPTION
|
|
34
|
+
WHEN duplicate_object THEN NULL;
|
|
35
|
+
END $$;
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS odyssey_ledger (
|
|
38
|
+
key TEXT NOT NULL,
|
|
39
|
+
target TEXT NOT NULL,
|
|
40
|
+
status odyssey_status NOT NULL DEFAULT 'claimed',
|
|
41
|
+
mode odyssey_execution_mode NOT NULL,
|
|
42
|
+
started_at TIMESTAMPTZ,
|
|
43
|
+
completed_at TIMESTAMPTZ,
|
|
44
|
+
|
|
45
|
+
PRIMARY KEY (key, target)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS odyssey_journeys (
|
|
49
|
+
key TEXT NOT NULL,
|
|
50
|
+
target TEXT NOT NULL,
|
|
51
|
+
owner_id TEXT NOT NULL,
|
|
52
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
53
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
54
|
+
execution_result JSONB,
|
|
55
|
+
status odyssey_status NOT NULL DEFAULT 'claimed',
|
|
56
|
+
fencing_token BIGINT NOT NULL DEFAULT 1,
|
|
57
|
+
|
|
58
|
+
PRIMARY KEY (key, target),
|
|
59
|
+
FOREIGN KEY (key, target)
|
|
60
|
+
REFERENCES odyssey_ledger(key, target)
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE TABLE IF NOT EXISTS odyssey_deliveries (
|
|
64
|
+
key TEXT NOT NULL,
|
|
65
|
+
target TEXT NOT NULL,
|
|
66
|
+
emit_to TEXT NOT NULL,
|
|
67
|
+
payload JSONB,
|
|
68
|
+
status delivery_status NOT NULL DEFAULT 'pending',
|
|
69
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
70
|
+
last_attempt_at TIMESTAMPTZ,
|
|
71
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
72
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
73
|
+
|
|
74
|
+
PRIMARY KEY (key, target, emit_to),
|
|
75
|
+
FOREIGN KEY (key, target)
|
|
76
|
+
REFERENCES odyssey_ledger(key, target)
|
|
77
|
+
);
|
|
78
|
+
"""
|
odyssey/utils.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import threading
|
|
3
|
+
import socket
|
|
4
|
+
|
|
5
|
+
def get_owner_id():
|
|
6
|
+
hostname = socket.gethostname()
|
|
7
|
+
pid = os.getpid()
|
|
8
|
+
thread_id = threading.get_ident()
|
|
9
|
+
|
|
10
|
+
return f"{hostname}:{pid}-{thread_id}"
|
|
11
|
+
|
|
12
|
+
def row_to_dict(cursor, row):
|
|
13
|
+
columns = [desc[0] for desc in cursor.description]
|
|
14
|
+
return dict(zip(columns, row))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: odysseys-py
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A lightweight durable execution and orchestration runtime for distributed services.
|
|
5
|
+
Author: Sreejay Reddy
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: workflow,durable-execution,orchestration,distributed-systems,postgres
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: psycopg[binary]>=3.2
|
|
19
|
+
Requires-Dist: PyYAML>=6.0
|
|
20
|
+
Requires-Dist: python-dotenv>=1.0
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
odyssey/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
odyssey/async_core.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
odyssey/async_odyssey.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
odyssey/build_ledger.py,sha256=-DmySjYLEiP-xukcXiUbTV-DQhE3qabdDg0kSDpKpzc,2005
|
|
5
|
+
odyssey/config.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
odyssey/core.py,sha256=oDK14R9gA7vwmcTKaYDTZE-mXCq9sk_B4YX8Fa2s3Xk,5012
|
|
7
|
+
odyssey/odyssey.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
odyssey/results.py,sha256=f-9ow7aIk-iIMeXlGPGSlwFTOcuvIjwAbIIhR4qlk5g,1027
|
|
9
|
+
odyssey/schema.py,sha256=QKnym3QD3HD9lHEi8g4HRCU2bzvehBcO319NBWWRxR0,1909
|
|
10
|
+
odyssey/utils.py,sha256=0013Y9rpQVpFV7gPEEOVHzuvIbECIyA-913pVISyua8,335
|
|
11
|
+
odysseys_py-0.0.1.dist-info/METADATA,sha256=r9dErkINTHdjEauwjHAylvtgmyRv2fyU9xwqnoWOY2Q,831
|
|
12
|
+
odysseys_py-0.0.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
13
|
+
odysseys_py-0.0.1.dist-info/top_level.txt,sha256=VLIR-rNWvsxwTiHp8C7gv3FqFDgiRTGlrYb-pVOgLoo,8
|
|
14
|
+
odysseys_py-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
odyssey
|