graphddb-runtime 0.1.0__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.
- graphddb_runtime/__init__.py +58 -0
- graphddb_runtime/async_runtime.py +110 -0
- graphddb_runtime/batch.py +218 -0
- graphddb_runtime/concurrency.py +87 -0
- graphddb_runtime/cursor.py +49 -0
- graphddb_runtime/errors.py +80 -0
- graphddb_runtime/filters.py +194 -0
- graphddb_runtime/hydration.py +75 -0
- graphddb_runtime/limits.py +20 -0
- graphddb_runtime/per_key_cursor.py +105 -0
- graphddb_runtime/relations.py +199 -0
- graphddb_runtime/runtime.py +1674 -0
- graphddb_runtime/templates.py +131 -0
- graphddb_runtime/transactions.py +440 -0
- graphddb_runtime-0.1.0.dist-info/METADATA +160 -0
- graphddb_runtime-0.1.0.dist-info/RECORD +18 -0
- graphddb_runtime-0.1.0.dist-info/WHEEL +5 -0
- graphddb_runtime-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""GraphDDB Python runtime (issue #44, single-operation core).
|
|
2
|
+
|
|
3
|
+
Public surface consumed by generated ``repositories.py`` and applications:
|
|
4
|
+
|
|
5
|
+
- :class:`GraphDDBRuntime` — the executor.
|
|
6
|
+
- :class:`RuntimeLimits` — execution-time bounds.
|
|
7
|
+
- the :class:`GraphDDBError` family.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .cursor import decode_cursor, encode_cursor
|
|
13
|
+
from .errors import (
|
|
14
|
+
CommandNotFoundError,
|
|
15
|
+
ContractArityError,
|
|
16
|
+
ContractNotFoundError,
|
|
17
|
+
GraphDDBError,
|
|
18
|
+
HydrationError,
|
|
19
|
+
LimitExceededError,
|
|
20
|
+
MultiOperationNotSupportedError,
|
|
21
|
+
OperationExecutionError,
|
|
22
|
+
ParameterValidationError,
|
|
23
|
+
QueryNotFoundError,
|
|
24
|
+
TransactionNotFoundError,
|
|
25
|
+
)
|
|
26
|
+
from .async_runtime import AsyncGraphDDBRuntime
|
|
27
|
+
from .concurrency import RELATION_TRAVERSAL_CONCURRENCY, map_with_concurrency
|
|
28
|
+
from .limits import RuntimeLimits
|
|
29
|
+
from .per_key_cursor import (
|
|
30
|
+
decode_per_key_cursor,
|
|
31
|
+
encode_per_key_cursor,
|
|
32
|
+
serialize_contract_key,
|
|
33
|
+
)
|
|
34
|
+
from .runtime import GraphDDBRuntime
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"GraphDDBRuntime",
|
|
38
|
+
"AsyncGraphDDBRuntime",
|
|
39
|
+
"RuntimeLimits",
|
|
40
|
+
"RELATION_TRAVERSAL_CONCURRENCY",
|
|
41
|
+
"map_with_concurrency",
|
|
42
|
+
"GraphDDBError",
|
|
43
|
+
"QueryNotFoundError",
|
|
44
|
+
"CommandNotFoundError",
|
|
45
|
+
"TransactionNotFoundError",
|
|
46
|
+
"ContractNotFoundError",
|
|
47
|
+
"ContractArityError",
|
|
48
|
+
"ParameterValidationError",
|
|
49
|
+
"LimitExceededError",
|
|
50
|
+
"OperationExecutionError",
|
|
51
|
+
"HydrationError",
|
|
52
|
+
"MultiOperationNotSupportedError",
|
|
53
|
+
"encode_cursor",
|
|
54
|
+
"decode_cursor",
|
|
55
|
+
"serialize_contract_key",
|
|
56
|
+
"encode_per_key_cursor",
|
|
57
|
+
"decode_per_key_cursor",
|
|
58
|
+
]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Async adapter over the sync :class:`GraphDDBRuntime` (issue #46, Phase 4).
|
|
2
|
+
|
|
3
|
+
boto3 is a synchronous SDK, so the runtime core is synchronous. This module
|
|
4
|
+
provides a **thin** async wrapper that runs each blocking call in a worker
|
|
5
|
+
thread via :func:`asyncio.to_thread`, giving an ``await``-able surface with
|
|
6
|
+
**behavior equivalent** to the sync runtime (same params, same specs, same
|
|
7
|
+
results, same error types) without duplicating the executor.
|
|
8
|
+
|
|
9
|
+
It does not require ``aioboto3``; the wrapped sync runtime keeps using boto3.
|
|
10
|
+
For a project that wants a fully non-blocking DynamoDB client, ``aioboto3`` can
|
|
11
|
+
be adopted later behind the same interface — but for the common Lambda / request
|
|
12
|
+
handler case, ``asyncio.to_thread`` is sufficient and keeps a single executor
|
|
13
|
+
implementation (no TS/Python or sync/async divergence to test twice).
|
|
14
|
+
|
|
15
|
+
Usage::
|
|
16
|
+
|
|
17
|
+
import boto3
|
|
18
|
+
from graphddb_runtime import GraphDDBRuntime, AsyncGraphDDBRuntime
|
|
19
|
+
|
|
20
|
+
sync = GraphDDBRuntime(boto3.client("dynamodb"), manifest, operations)
|
|
21
|
+
runtime = AsyncGraphDDBRuntime(sync)
|
|
22
|
+
|
|
23
|
+
user = await runtime.execute_query("getUser", {"userId": "alice"})
|
|
24
|
+
await runtime.execute_transaction("addManyMembers", {...})
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
from typing import Any, Mapping, Optional
|
|
31
|
+
|
|
32
|
+
from .runtime import GraphDDBRuntime
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AsyncGraphDDBRuntime:
|
|
36
|
+
"""An ``await``-able adapter delegating to a sync :class:`GraphDDBRuntime`.
|
|
37
|
+
|
|
38
|
+
Every method runs its synchronous counterpart in a thread, so a slow boto3
|
|
39
|
+
round trip does not block the event loop. The wrapped runtime is exposed as
|
|
40
|
+
:attr:`sync` for callers that need the blocking API directly.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, runtime: GraphDDBRuntime) -> None:
|
|
44
|
+
self.sync = runtime
|
|
45
|
+
|
|
46
|
+
async def execute_query(
|
|
47
|
+
self,
|
|
48
|
+
query_id: str,
|
|
49
|
+
params: Mapping[str, Any],
|
|
50
|
+
options: Optional[Mapping[str, Any]] = None,
|
|
51
|
+
) -> Optional[dict]:
|
|
52
|
+
return await asyncio.to_thread(
|
|
53
|
+
self.sync.execute_query, query_id, params, options
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
async def execute_query_method(
|
|
57
|
+
self,
|
|
58
|
+
contract_name: str,
|
|
59
|
+
method_name: str,
|
|
60
|
+
key_or_keys: Any,
|
|
61
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
62
|
+
) -> Any:
|
|
63
|
+
"""Async wrapper over :meth:`GraphDDBRuntime.execute_query_method` (#62)."""
|
|
64
|
+
return await asyncio.to_thread(
|
|
65
|
+
self.sync.execute_query_method,
|
|
66
|
+
contract_name,
|
|
67
|
+
method_name,
|
|
68
|
+
key_or_keys,
|
|
69
|
+
params,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
async def execute_command(
|
|
73
|
+
self,
|
|
74
|
+
command_id: str,
|
|
75
|
+
params: Mapping[str, Any],
|
|
76
|
+
options: Optional[Mapping[str, Any]] = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
await asyncio.to_thread(
|
|
79
|
+
self.sync.execute_command, command_id, params, options
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
async def execute_command_method(
|
|
83
|
+
self,
|
|
84
|
+
contract_name: str,
|
|
85
|
+
method_name: str,
|
|
86
|
+
key_or_keys: Any,
|
|
87
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Async wrapper over :meth:`GraphDDBRuntime.execute_command_method` (#64)."""
|
|
90
|
+
await asyncio.to_thread(
|
|
91
|
+
self.sync.execute_command_method,
|
|
92
|
+
contract_name,
|
|
93
|
+
method_name,
|
|
94
|
+
key_or_keys,
|
|
95
|
+
params,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
async def execute_transaction(
|
|
99
|
+
self,
|
|
100
|
+
transaction_id: str,
|
|
101
|
+
params: Mapping[str, Any],
|
|
102
|
+
) -> None:
|
|
103
|
+
await asyncio.to_thread(
|
|
104
|
+
self.sync.execute_transaction, transaction_id, params
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
async def explain(self, query_id: str, params: Mapping[str, Any]) -> dict:
|
|
108
|
+
# `explain` does not touch DynamoDB, but keeping it on the async surface
|
|
109
|
+
# lets call sites use one runtime object uniformly.
|
|
110
|
+
return await asyncio.to_thread(self.sync.explain, query_id, params)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""BatchGetItem chunking + UnprocessedKeys retry (issue #45).
|
|
2
|
+
|
|
3
|
+
Port of the TypeScript batch semantics (``src/operations/batch-retry.ts`` /
|
|
4
|
+
``src/executor/batch-executor.ts``):
|
|
5
|
+
|
|
6
|
+
- keys are **deduped** before the request (``planBatchGetForQueryKeys`` /
|
|
7
|
+
``dedupeDynamoKeys``);
|
|
8
|
+
- split into chunks of at most :data:`BATCH_GET_MAX_KEYS` (100) keys;
|
|
9
|
+
- each chunk retries ``UnprocessedKeys`` with exponential backoff
|
|
10
|
+
(``50 * 2^(attempt-1)`` ms, capped at 1000ms) up to
|
|
11
|
+
:data:`BATCH_MAX_RETRY_ATTEMPTS` (10) attempts, then raises.
|
|
12
|
+
|
|
13
|
+
The sleep is injected so unit tests can observe the backoff schedule without
|
|
14
|
+
real delays.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import time
|
|
20
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional
|
|
21
|
+
|
|
22
|
+
# Mirrors src/operations/batch-retry.ts.
|
|
23
|
+
BATCH_GET_MAX_KEYS = 100
|
|
24
|
+
BATCH_WRITE_MAX_ITEMS = 25
|
|
25
|
+
BATCH_MAX_RETRY_ATTEMPTS = 10
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def compute_backoff_delay(attempt: int) -> float:
|
|
29
|
+
"""Backoff for a 1-based attempt: ``50 * 2^(attempt-1)`` ms, capped at 1000ms."""
|
|
30
|
+
return min(1000.0, 50.0 * (2 ** (attempt - 1))) / 1000.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def chunk(items: List[Any], size: int) -> List[List[Any]]:
|
|
34
|
+
return [items[i : i + size] for i in range(0, len(items), size)]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def serialize_key(key: Mapping[str, Any]) -> str:
|
|
38
|
+
"""Stable string form of a (plain) key for dedup / parent matching.
|
|
39
|
+
|
|
40
|
+
Mirrors the TS ``dedupeDynamoKeys`` / ``serializeQueryKey`` ordering
|
|
41
|
+
(attributes sorted by name).
|
|
42
|
+
"""
|
|
43
|
+
import json
|
|
44
|
+
|
|
45
|
+
return json.dumps(sorted(key.items()), separators=(",", ":"), default=str)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BatchGetExecutor:
|
|
49
|
+
"""Executes a deduped, chunked, retrying BatchGetItem against a boto3 client.
|
|
50
|
+
|
|
51
|
+
The client is the low-level ``boto3.client("dynamodb")`` (AttributeValue
|
|
52
|
+
shapes). ``request_extra`` carries the optional ``ProjectionExpression`` /
|
|
53
|
+
``ExpressionAttributeNames``. Returns the flat list of raw (AttributeValue)
|
|
54
|
+
items across all chunks/retries.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
client: Any,
|
|
60
|
+
*,
|
|
61
|
+
boto_errors: tuple = (),
|
|
62
|
+
on_request: Optional[Callable[[int], None]] = None,
|
|
63
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
64
|
+
max_batch_get_items: int = BATCH_GET_MAX_KEYS,
|
|
65
|
+
) -> None:
|
|
66
|
+
self._client = client
|
|
67
|
+
self._boto_errors = boto_errors
|
|
68
|
+
self._on_request = on_request
|
|
69
|
+
self._sleep = sleep
|
|
70
|
+
self._max_batch_get_items = max_batch_get_items
|
|
71
|
+
|
|
72
|
+
def get(
|
|
73
|
+
self,
|
|
74
|
+
physical_table: str,
|
|
75
|
+
serialized_keys: List[Dict[str, Any]],
|
|
76
|
+
request_extra: Optional[Dict[str, Any]] = None,
|
|
77
|
+
) -> List[Dict[str, Any]]:
|
|
78
|
+
if not serialized_keys:
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
items: List[Dict[str, Any]] = []
|
|
82
|
+
for batch in chunk(serialized_keys, self._max_batch_get_items):
|
|
83
|
+
items.extend(
|
|
84
|
+
self._get_chunk(physical_table, batch, request_extra or {})
|
|
85
|
+
)
|
|
86
|
+
return items
|
|
87
|
+
|
|
88
|
+
def _get_chunk(
|
|
89
|
+
self,
|
|
90
|
+
physical_table: str,
|
|
91
|
+
keys: List[Dict[str, Any]],
|
|
92
|
+
request_extra: Dict[str, Any],
|
|
93
|
+
) -> List[Dict[str, Any]]:
|
|
94
|
+
pending = keys
|
|
95
|
+
attempt = 0
|
|
96
|
+
out: List[Dict[str, Any]] = []
|
|
97
|
+
|
|
98
|
+
while pending:
|
|
99
|
+
table_request = {"Keys": pending, **request_extra}
|
|
100
|
+
if self._on_request is not None:
|
|
101
|
+
self._on_request(len(pending))
|
|
102
|
+
try:
|
|
103
|
+
resp = self._client.batch_get_item(
|
|
104
|
+
RequestItems={physical_table: table_request}
|
|
105
|
+
)
|
|
106
|
+
except self._boto_errors as exc: # type: ignore[misc]
|
|
107
|
+
from .errors import OperationExecutionError
|
|
108
|
+
|
|
109
|
+
raise OperationExecutionError(
|
|
110
|
+
f"BatchGetItem failed for table {physical_table!r}: {exc}",
|
|
111
|
+
original=exc,
|
|
112
|
+
) from exc
|
|
113
|
+
|
|
114
|
+
out.extend(resp.get("Responses", {}).get(physical_table, []))
|
|
115
|
+
|
|
116
|
+
unprocessed = (
|
|
117
|
+
resp.get("UnprocessedKeys", {})
|
|
118
|
+
.get(physical_table, {})
|
|
119
|
+
.get("Keys", [])
|
|
120
|
+
)
|
|
121
|
+
if not unprocessed:
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
if attempt >= BATCH_MAX_RETRY_ATTEMPTS:
|
|
125
|
+
from .errors import OperationExecutionError
|
|
126
|
+
|
|
127
|
+
raise OperationExecutionError(
|
|
128
|
+
f"BatchGet exceeded the maximum of {BATCH_MAX_RETRY_ATTEMPTS} "
|
|
129
|
+
f"retry attempts with {len(unprocessed)} key(s) still "
|
|
130
|
+
f"unprocessed for table {physical_table!r} (likely sustained "
|
|
131
|
+
f"throttling)."
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
pending = unprocessed
|
|
135
|
+
attempt += 1
|
|
136
|
+
self._sleep(compute_backoff_delay(attempt))
|
|
137
|
+
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class BatchWriteExecutor:
|
|
142
|
+
"""Executes a chunked, retrying ``BatchWriteItem`` against a boto3 client.
|
|
143
|
+
|
|
144
|
+
Port of the TypeScript batch-write semantics
|
|
145
|
+
(``src/operations/batch-retry.ts`` ``batchWriteChunkWithRetry`` +
|
|
146
|
+
``src/operations/batch.ts`` ``executeBatchWrite``): the per-table write
|
|
147
|
+
requests (``{"PutRequest": {"Item": …}}`` / ``{"DeleteRequest": {"Key": …}}``,
|
|
148
|
+
AttributeValue shapes) are split into chunks of at most
|
|
149
|
+
:data:`BATCH_WRITE_MAX_ITEMS` (25); each chunk retries ``UnprocessedItems``
|
|
150
|
+
with the same exponential backoff as :class:`BatchGetExecutor` up to
|
|
151
|
+
:data:`BATCH_MAX_RETRY_ATTEMPTS` attempts, then raises.
|
|
152
|
+
|
|
153
|
+
``BatchWriteItem`` carries **no conditions** (DynamoDB has no per-request
|
|
154
|
+
``ConditionExpression`` for it) and is **not atomic** — both are properties of
|
|
155
|
+
the command-contract ``'batchWrite'`` mode (issue #64). The sleep is injected
|
|
156
|
+
so unit tests can observe the backoff schedule without real delays.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
client: Any,
|
|
162
|
+
*,
|
|
163
|
+
boto_errors: tuple = (),
|
|
164
|
+
on_request: Optional[Callable[[int], None]] = None,
|
|
165
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
166
|
+
max_batch_write_items: int = BATCH_WRITE_MAX_ITEMS,
|
|
167
|
+
) -> None:
|
|
168
|
+
self._client = client
|
|
169
|
+
self._boto_errors = boto_errors
|
|
170
|
+
self._on_request = on_request
|
|
171
|
+
self._sleep = sleep
|
|
172
|
+
self._max_batch_write_items = max_batch_write_items
|
|
173
|
+
|
|
174
|
+
def write(self, physical_table: str, requests: List[Dict[str, Any]]) -> None:
|
|
175
|
+
"""Apply every write request to ``physical_table``, chunked + retried."""
|
|
176
|
+
if not requests:
|
|
177
|
+
return
|
|
178
|
+
for batch in chunk(requests, self._max_batch_write_items):
|
|
179
|
+
self._write_chunk(physical_table, batch)
|
|
180
|
+
|
|
181
|
+
def _write_chunk(
|
|
182
|
+
self, physical_table: str, requests: List[Dict[str, Any]]
|
|
183
|
+
) -> None:
|
|
184
|
+
pending = requests
|
|
185
|
+
attempt = 0
|
|
186
|
+
|
|
187
|
+
while pending:
|
|
188
|
+
if self._on_request is not None:
|
|
189
|
+
self._on_request(len(pending))
|
|
190
|
+
try:
|
|
191
|
+
resp = self._client.batch_write_item(
|
|
192
|
+
RequestItems={physical_table: pending}
|
|
193
|
+
)
|
|
194
|
+
except self._boto_errors as exc: # type: ignore[misc]
|
|
195
|
+
from .errors import OperationExecutionError
|
|
196
|
+
|
|
197
|
+
raise OperationExecutionError(
|
|
198
|
+
f"BatchWriteItem failed for table {physical_table!r}: {exc}",
|
|
199
|
+
original=exc,
|
|
200
|
+
) from exc
|
|
201
|
+
|
|
202
|
+
unprocessed = resp.get("UnprocessedItems", {}).get(physical_table, [])
|
|
203
|
+
if not unprocessed:
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
if attempt >= BATCH_MAX_RETRY_ATTEMPTS:
|
|
207
|
+
from .errors import OperationExecutionError
|
|
208
|
+
|
|
209
|
+
raise OperationExecutionError(
|
|
210
|
+
f"BatchWrite exceeded the maximum of {BATCH_MAX_RETRY_ATTEMPTS} "
|
|
211
|
+
f"retry attempts with {len(unprocessed)} item(s) still "
|
|
212
|
+
f"unprocessed for table {physical_table!r} (likely sustained "
|
|
213
|
+
f"throttling)."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
pending = unprocessed
|
|
217
|
+
attempt += 1
|
|
218
|
+
self._sleep(compute_backoff_delay(attempt))
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Bounded-concurrency staged execution (issue #70b).
|
|
2
|
+
|
|
3
|
+
The static planner records an **execution plan** in the SSoT (issue #70a): a
|
|
4
|
+
query's :class:`OperationSpec`\\ s are partitioned into ordered *stages*, where the
|
|
5
|
+
operations **within** a stage are mutually independent (none reads a
|
|
6
|
+
``{result.*}`` value another produces) and so may be issued concurrently, while
|
|
7
|
+
the stages run in order. A contract method mirrors this for its External Query
|
|
8
|
+
compositions. This module is the Python runtime's executor for that plan: it runs
|
|
9
|
+
the independent work of one stage with a bounded number of in-flight workers, the
|
|
10
|
+
faithful counterpart of the TypeScript runtime's ``mapWithConcurrency``
|
|
11
|
+
(``src/relation/concurrency.ts``).
|
|
12
|
+
|
|
13
|
+
## Why threads (and not asyncio)
|
|
14
|
+
|
|
15
|
+
The runtime core is **synchronous** — it drives ``boto3``, a synchronous SDK
|
|
16
|
+
(see ``async_runtime.py``: the async surface is a thin ``asyncio.to_thread``
|
|
17
|
+
wrapper over this same sync core). A DynamoDB request is network I/O, during
|
|
18
|
+
which ``boto3``/``botocore`` release the GIL, so a :class:`ThreadPoolExecutor`
|
|
19
|
+
gives **real wall-clock overlap** for the independent requests of a stage while
|
|
20
|
+
keeping a single executor implementation (no sync/async fork). The default bound
|
|
21
|
+
is the same declared value the TS runtime uses (16), carried in the serialized
|
|
22
|
+
plan as ``concurrency``.
|
|
23
|
+
|
|
24
|
+
## Ordering guarantee
|
|
25
|
+
|
|
26
|
+
:func:`map_with_concurrency` returns results in **input order**
|
|
27
|
+
(``output[i]`` is ``worker(items[i])``) regardless of completion order, exactly
|
|
28
|
+
like the TS helper. Stage consumers that mutate a shared result tree must still
|
|
29
|
+
ensure each worker writes a **disjoint** slot (its own parent / property), which
|
|
30
|
+
the relation-assembly and composition call sites do — completion order is then
|
|
31
|
+
irrelevant to the merged output.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
37
|
+
from typing import Callable, List, Sequence, TypeVar
|
|
38
|
+
|
|
39
|
+
T = TypeVar("T")
|
|
40
|
+
R = TypeVar("R")
|
|
41
|
+
|
|
42
|
+
#: The default declared in-flight bound, mirroring the TypeScript
|
|
43
|
+
#: ``RELATION_TRAVERSAL_CONCURRENCY`` (``src/relation/concurrency.ts``). A spec's
|
|
44
|
+
#: ``executionPlan.concurrency`` overrides it; this is the fallback when a plan is
|
|
45
|
+
#: absent or omits the field.
|
|
46
|
+
RELATION_TRAVERSAL_CONCURRENCY = 16
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def map_with_concurrency(
|
|
50
|
+
items: Sequence[T],
|
|
51
|
+
limit: int,
|
|
52
|
+
worker: Callable[[T, int], R],
|
|
53
|
+
) -> List[R]:
|
|
54
|
+
"""Map ``items`` to results by invoking ``worker(item, index)`` with at most
|
|
55
|
+
``limit`` workers in flight, preserving input order in the output.
|
|
56
|
+
|
|
57
|
+
Behaves like ``[worker(x, i) for i, x in enumerate(items)]`` but never runs
|
|
58
|
+
more than ``limit`` workers concurrently — the faithful counterpart of the
|
|
59
|
+
TypeScript ``mapWithConcurrency``. Independent boto3 round trips therefore
|
|
60
|
+
overlap (the SDK releases the GIL during I/O) under the declared bound.
|
|
61
|
+
|
|
62
|
+
- An empty input returns ``[]`` without spawning a pool.
|
|
63
|
+
- A single item (or ``limit <= 1``) runs **inline** on the calling thread, so
|
|
64
|
+
a non-staged / one-op-per-stage execution is byte-for-byte the pre-#70b
|
|
65
|
+
sequential path (no pool, no thread hand-off) — the backward-compatible
|
|
66
|
+
fallback.
|
|
67
|
+
- The first worker exception propagates (the pool is shut down); this mirrors
|
|
68
|
+
``Promise.all`` rejecting on the first failure.
|
|
69
|
+
"""
|
|
70
|
+
n = len(items)
|
|
71
|
+
if n == 0:
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
effective = max(1, min(limit, n))
|
|
75
|
+
# Inline fast path: nothing to parallelize → run on the calling thread, no
|
|
76
|
+
# pool. Keeps a single-op-per-stage plan identical to sequential execution.
|
|
77
|
+
if effective == 1 or n == 1:
|
|
78
|
+
return [worker(item, i) for i, item in enumerate(items)]
|
|
79
|
+
|
|
80
|
+
results: List[R] = [None] * n # type: ignore[list-item]
|
|
81
|
+
with ThreadPoolExecutor(max_workers=effective) as pool:
|
|
82
|
+
futures = {
|
|
83
|
+
pool.submit(worker, item, i): i for i, item in enumerate(items)
|
|
84
|
+
}
|
|
85
|
+
for future, i in futures.items():
|
|
86
|
+
results[i] = future.result()
|
|
87
|
+
return results
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Pagination cursor encode/decode (issue #44).
|
|
2
|
+
|
|
3
|
+
A cursor is the DynamoDB ``LastEvaluatedKey`` encoded as base64url-without-pad
|
|
4
|
+
JSON, matching the TypeScript ``encodeCursor`` / ``decodeCursor``
|
|
5
|
+
(``src/pagination/cursor.ts``): the JSON is the key object only — no wrapping
|
|
6
|
+
``{table, index, ...}`` envelope — so the byte shape is identical for a given
|
|
7
|
+
key.
|
|
8
|
+
|
|
9
|
+
The runtime works with the **deserialized** (plain Python) key produced by
|
|
10
|
+
``TypeDeserializer`` so the encoded JSON matches the un-marshalled shape the TS
|
|
11
|
+
runtime (lib-dynamodb) encodes. ``encode_cursor`` accepts whatever JSON-safe
|
|
12
|
+
mapping it is given; numbers from a deserialized key arrive as ``Decimal`` and
|
|
13
|
+
are rendered as plain JSON numbers.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import json
|
|
20
|
+
from decimal import Decimal
|
|
21
|
+
from typing import Any, Mapping
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _default(value: Any) -> Any:
|
|
25
|
+
if isinstance(value, Decimal):
|
|
26
|
+
# Render as int when integral, else float — mirrors JSON.stringify(Number).
|
|
27
|
+
return int(value) if value == value.to_integral_value() else float(value)
|
|
28
|
+
if isinstance(value, (bytes, bytearray)):
|
|
29
|
+
return base64.b64encode(bytes(value)).decode("ascii")
|
|
30
|
+
raise TypeError(f"Cannot encode {type(value).__name__} into a cursor")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def encode_cursor(last_evaluated_key: Mapping[str, Any]) -> str:
|
|
34
|
+
"""Encode a (deserialized) ``LastEvaluatedKey`` into a base64url cursor."""
|
|
35
|
+
payload = json.dumps(
|
|
36
|
+
last_evaluated_key,
|
|
37
|
+
separators=(",", ":"),
|
|
38
|
+
ensure_ascii=False,
|
|
39
|
+
default=_default,
|
|
40
|
+
)
|
|
41
|
+
raw = base64.urlsafe_b64encode(payload.encode("utf-8")).decode("ascii")
|
|
42
|
+
return raw.rstrip("=")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def decode_cursor(cursor: str) -> dict:
|
|
46
|
+
"""Decode a base64url cursor back into a plain ``ExclusiveStartKey`` dict."""
|
|
47
|
+
padding = "=" * (-len(cursor) % 4)
|
|
48
|
+
payload = base64.urlsafe_b64decode((cursor + padding).encode("ascii"))
|
|
49
|
+
return json.loads(payload.decode("utf-8"))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Error hierarchy for the GraphDDB Python runtime (issue #44).
|
|
2
|
+
|
|
3
|
+
Mirrors the error taxonomy sketched in ``docs/python-bridge.md``.
|
|
4
|
+
Every runtime-raised error derives from :class:`GraphDDBError` so callers can
|
|
5
|
+
catch the whole family with a single ``except``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GraphDDBError(Exception):
|
|
12
|
+
"""Base class for every error raised by the runtime."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class QueryNotFoundError(GraphDDBError):
|
|
16
|
+
"""Raised when an ``execute_query`` is given an unknown ``query_id``."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CommandNotFoundError(GraphDDBError):
|
|
20
|
+
"""Raised when an ``execute_command`` is given an unknown ``command_id``."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TransactionNotFoundError(GraphDDBError):
|
|
24
|
+
"""Raised when an ``execute_transaction`` is given an unknown id (issue #46)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ContractNotFoundError(GraphDDBError):
|
|
28
|
+
"""Raised when an ``execute_query_method`` names an unknown contract / method.
|
|
29
|
+
|
|
30
|
+
Covers an unknown contract name, an unknown method on a known contract, or a
|
|
31
|
+
method whose ``kind`` does not match the call (e.g. a command method invoked
|
|
32
|
+
through the query path). Issue #62 (single-service contract Runtime).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ContractArityError(GraphDDBError):
|
|
37
|
+
"""Raised when a contract method is called with the wrong input arity.
|
|
38
|
+
|
|
39
|
+
Specifically, an **array** of keys fed into a ``range`` method (whose
|
|
40
|
+
``inputArity`` is ``'single'``): a range resolution is one partition ``Query``
|
|
41
|
+
per key, so an array would be an N+1 fan-out forbidden by the contract's N+1
|
|
42
|
+
rule. The caller must loop in application code if N independent range reads
|
|
43
|
+
are genuinely needed. Issue #62.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ParameterValidationError(GraphDDBError):
|
|
48
|
+
"""Raised before any DynamoDB call when params fail validation.
|
|
49
|
+
|
|
50
|
+
Covers a missing required param, an unknown param, a wrong scalar type, or a
|
|
51
|
+
``literal`` param whose value is not in the allowed set.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LimitExceededError(GraphDDBError):
|
|
56
|
+
"""Raised before any DynamoDB call when a runtime limit would be exceeded."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class OperationExecutionError(GraphDDBError):
|
|
60
|
+
"""Wraps a boto3 ``ClientError`` (or any boto3 failure) from a DynamoDB call.
|
|
61
|
+
|
|
62
|
+
The original exception is preserved as ``__cause__`` and on ``original``.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, message: str, original: BaseException | None = None) -> None:
|
|
66
|
+
super().__init__(message)
|
|
67
|
+
self.original = original
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class HydrationError(GraphDDBError):
|
|
71
|
+
"""Raised when a raw DynamoDB item cannot be hydrated into a result."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class MultiOperationNotSupportedError(GraphDDBError):
|
|
75
|
+
"""Raised when a query/command spec needs more than the single-op core.
|
|
76
|
+
|
|
77
|
+
Relation chains (operations whose length is > 1, or ops carrying a
|
|
78
|
+
``sourceField``) are out of scope for issue #44 and are handled by the
|
|
79
|
+
relation runtime in issue #45.
|
|
80
|
+
"""
|