graphddb-runtime 0.9.0__tar.gz → 0.9.2__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.
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/PKG-INFO +1 -1
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime/runtime.py +160 -23
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime/templates.py +13 -9
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime.egg-info/PKG-INFO +1 -1
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime.egg-info/SOURCES.txt +1 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/pyproject.toml +1 -1
- graphddb_runtime-0.9.2/tests/test_atomic_add_command.py +184 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/README.md +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime/__init__.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime/async_runtime.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime/batch.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime/behavior_exec.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime/concurrency.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime/cursor.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime/errors.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime/filters.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime/handler_registry.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime/hydration.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime/limits.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime/middleware.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime/per_key_cursor.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime/transactions.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime.egg-info/requires.txt +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime.egg-info/top_level.txt +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/setup.cfg +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_concurrency.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_contract_runtime.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_crosslang_python_cells.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_description.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_description_followups.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_description_jsdoc.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_error_policy.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_generated_module.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_guard_eval.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_handler_registry.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_integration.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_integration_batch_write.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_integration_binding_contract.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_integration_command.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_integration_compose.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_integration_contract.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_integration_edge_derive.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_integration_edge_write.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_integration_events.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_integration_maintain.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_integration_middleware.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_integration_referential.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_integration_relations.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_integration_unique.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_maintain.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_middleware.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_relations.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_spec_version_guard.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_ttl.py +0 -0
- {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_unit.py +0 -0
|
@@ -151,6 +151,44 @@ except Exception: # pragma: no cover
|
|
|
151
151
|
_PLACEHOLDER_RE = re.compile(r"\{[^{}]+\}")
|
|
152
152
|
|
|
153
153
|
|
|
154
|
+
def _is_conditional_check_failure(err: BaseException) -> bool:
|
|
155
|
+
"""Whether ``err`` is a DynamoDB ``ConditionalCheckFailedException`` — the
|
|
156
|
+
#301 two-stage fallback trigger.
|
|
157
|
+
|
|
158
|
+
The runtime wraps every DynamoDB error in an ``OperationExecutionError`` whose
|
|
159
|
+
``original`` is the boto ``ClientError`` (its ``response.Error.Code`` is
|
|
160
|
+
``ConditionalCheckFailedException``). A fake test client may raise a plain
|
|
161
|
+
exception standing in for the boto error; matched by the exception name / its
|
|
162
|
+
string carrying ``ConditionalCheckFailed`` (mirroring the TS runtime's
|
|
163
|
+
name-based match). Unwraps ``__cause__`` / ``original`` so a wrapped error is
|
|
164
|
+
detected identically."""
|
|
165
|
+
seen: set = set()
|
|
166
|
+
stack: List[BaseException] = [err]
|
|
167
|
+
while stack:
|
|
168
|
+
e = stack.pop()
|
|
169
|
+
if id(e) in seen:
|
|
170
|
+
continue
|
|
171
|
+
seen.add(id(e))
|
|
172
|
+
# boto ClientError carries a structured error code.
|
|
173
|
+
response = getattr(e, "response", None)
|
|
174
|
+
if isinstance(response, Mapping):
|
|
175
|
+
code = response.get("Error", {}).get("Code")
|
|
176
|
+
if code == "ConditionalCheckFailedException":
|
|
177
|
+
return True
|
|
178
|
+
# Name / message match (fake clients, wrapped strings).
|
|
179
|
+
if "ConditionalCheckFailed" in type(e).__name__:
|
|
180
|
+
return True
|
|
181
|
+
if "ConditionalCheckFailed" in str(e):
|
|
182
|
+
return True
|
|
183
|
+
original = getattr(e, "original", None)
|
|
184
|
+
if isinstance(original, BaseException):
|
|
185
|
+
stack.append(original)
|
|
186
|
+
cause = getattr(e, "__cause__", None)
|
|
187
|
+
if isinstance(cause, BaseException):
|
|
188
|
+
stack.append(cause)
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
154
192
|
def _normalize_template(template: Optional[str]) -> Optional[str]:
|
|
155
193
|
"""Collapse every ``{placeholder}`` to a wildcard for structural matching."""
|
|
156
194
|
if template is None:
|
|
@@ -1243,8 +1281,24 @@ class GraphDDBRuntime:
|
|
|
1243
1281
|
else: # pragma: no cover - schema only allows the three above
|
|
1244
1282
|
raise GraphDDBError(f"{command_id}: unknown command type '{op_type}'")
|
|
1245
1283
|
|
|
1284
|
+
# #301: an atomic-ADD update (`add` present) coexisting with a `condition`
|
|
1285
|
+
# cannot apply the ADD when the condition fails (DynamoDB rejects the WHOLE
|
|
1286
|
+
# UpdateItem on a ConditionExpression miss), yet the ADD (the mark's
|
|
1287
|
+
# `version++`) MUST always happen. So the send is TWO-STAGE: primary
|
|
1288
|
+
# `ADD + SET` under the condition (the common path, 1 write); on
|
|
1289
|
+
# `ConditionalCheckFailedException`, a fallback `ADD`-only write (no SET, no
|
|
1290
|
+
# condition). Without a condition the single ADD+SET is already
|
|
1291
|
+
# unconditional (no fallback). read=0, retry=0.
|
|
1292
|
+
two_stage = (
|
|
1293
|
+
op_type == "UpdateItem"
|
|
1294
|
+
and bool(spec.get("add"))
|
|
1295
|
+
and spec.get("condition") is not None
|
|
1296
|
+
)
|
|
1297
|
+
|
|
1246
1298
|
if not mw.active:
|
|
1247
|
-
self.
|
|
1299
|
+
self._send_update_two_stage(
|
|
1300
|
+
command_id, spec, params, item, force_old_image=False, two_stage=two_stage
|
|
1301
|
+
)
|
|
1248
1302
|
return None
|
|
1249
1303
|
|
|
1250
1304
|
kind = self._TYPE_TO_KIND.get(op_type)
|
|
@@ -1259,8 +1313,14 @@ class GraphDDBRuntime:
|
|
|
1259
1313
|
persist_ctx = mw.persist_ctx([item], origin)
|
|
1260
1314
|
mw.run_persist(
|
|
1261
1315
|
persist_ctx,
|
|
1262
|
-
lambda items: self.
|
|
1263
|
-
command_id,
|
|
1316
|
+
lambda items: self._send_update_two_stage(
|
|
1317
|
+
command_id,
|
|
1318
|
+
spec,
|
|
1319
|
+
params,
|
|
1320
|
+
items[0],
|
|
1321
|
+
force_old_image=force_old_image,
|
|
1322
|
+
two_stage=two_stage,
|
|
1323
|
+
cap=cap,
|
|
1264
1324
|
),
|
|
1265
1325
|
)
|
|
1266
1326
|
if not force_old_image:
|
|
@@ -1356,6 +1416,47 @@ class GraphDDBRuntime:
|
|
|
1356
1416
|
attrs = resp.get("Attributes") if isinstance(resp, Mapping) else None
|
|
1357
1417
|
return self._deserialize(attrs) if attrs else None
|
|
1358
1418
|
|
|
1419
|
+
def _send_update_two_stage(
|
|
1420
|
+
self,
|
|
1421
|
+
command_id: str,
|
|
1422
|
+
spec: Mapping[str, Any],
|
|
1423
|
+
params: Mapping[str, Any],
|
|
1424
|
+
item: Mapping[str, Any],
|
|
1425
|
+
force_old_image: bool = False,
|
|
1426
|
+
two_stage: bool = False,
|
|
1427
|
+
cap: Optional[Dict[str, Any]] = None,
|
|
1428
|
+
) -> Any:
|
|
1429
|
+
"""Send one composed write, applying the #301 atomic-ADD TWO-STAGE fallback.
|
|
1430
|
+
|
|
1431
|
+
When ``two_stage`` is set (an UpdateItem carrying both ``add`` and a write
|
|
1432
|
+
condition), the primary ``ADD + SET`` send is attempted under the condition;
|
|
1433
|
+
on ``ConditionalCheckFailedException`` a fallback ``ADD``-only UpdateItem is
|
|
1434
|
+
re-issued (no SET, no condition — the counter still increments). For every
|
|
1435
|
+
other write this is a plain :meth:`_send_write_item`."""
|
|
1436
|
+
if not two_stage:
|
|
1437
|
+
return self._send_write_item(
|
|
1438
|
+
command_id, item, force_old_image=force_old_image, cap=cap
|
|
1439
|
+
)
|
|
1440
|
+
try:
|
|
1441
|
+
return self._send_write_item(
|
|
1442
|
+
command_id, item, force_old_image=force_old_image, cap=cap
|
|
1443
|
+
)
|
|
1444
|
+
except Exception as err: # noqa: BLE001 — re-raised unless it is a CCF
|
|
1445
|
+
if not _is_conditional_check_failure(err):
|
|
1446
|
+
raise
|
|
1447
|
+
# Fallback: ADD-only. Rebuild the UpdateItem with `add_only=True` (drops
|
|
1448
|
+
# the SET changes, the GSI re-derive AND the condition); the ADD (the
|
|
1449
|
+
# version bump) still applies. Its own `_send_write_item` may still
|
|
1450
|
+
# capture an ALL_OLD image for a W2 hook.
|
|
1451
|
+
fallback_item = {
|
|
1452
|
+
"Update": self._build_update_request(
|
|
1453
|
+
command_id, spec, params, add_only=True
|
|
1454
|
+
)
|
|
1455
|
+
}
|
|
1456
|
+
return self._send_write_item(
|
|
1457
|
+
command_id, fallback_item, force_old_image=force_old_image, cap=cap
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1359
1460
|
def execute_transaction(
|
|
1360
1461
|
self,
|
|
1361
1462
|
transaction_id: str,
|
|
@@ -2175,9 +2276,20 @@ class GraphDDBRuntime:
|
|
|
2175
2276
|
self._call(command_id, self._client.put_item, self._build_put_request(spec, params))
|
|
2176
2277
|
|
|
2177
2278
|
def _build_update_request(
|
|
2178
|
-
self,
|
|
2279
|
+
self,
|
|
2280
|
+
command_id: str,
|
|
2281
|
+
spec: Mapping[str, Any],
|
|
2282
|
+
params: Mapping[str, Any],
|
|
2283
|
+
add_only: bool = False,
|
|
2179
2284
|
) -> Dict[str, Any]:
|
|
2180
|
-
"""Compose the boto3 ``update_item`` request body (no send).
|
|
2285
|
+
"""Compose the boto3 ``update_item`` request body (no send).
|
|
2286
|
+
|
|
2287
|
+
#301: the atomic-``ADD`` clauses (``ADD #a :d``) ride the SAME
|
|
2288
|
+
UpdateExpression as the SET changes but are a distinct DynamoDB action.
|
|
2289
|
+
When ``add_only`` is set (the two-stage fallback), the SET changes, the GSI
|
|
2290
|
+
re-derive and the write condition are ALL dropped — only the unconditional
|
|
2291
|
+
``ADD`` survives (the counter still increments after a condition miss).
|
|
2292
|
+
"""
|
|
2181
2293
|
key = {
|
|
2182
2294
|
attr: self._serializer.serialize(resolve_template(tmpl, params))
|
|
2183
2295
|
for attr, tmpl in spec["keyCondition"].items()
|
|
@@ -2185,34 +2297,59 @@ class GraphDDBRuntime:
|
|
|
2185
2297
|
names: Dict[str, str] = {}
|
|
2186
2298
|
values: Dict[str, Any] = {}
|
|
2187
2299
|
sets: List[str] = []
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2300
|
+
if not add_only:
|
|
2301
|
+
for i, (field, tmpl) in enumerate(spec.get("changes", {}).items()):
|
|
2302
|
+
n = f"#c{i}"
|
|
2303
|
+
v = f":c{i}"
|
|
2304
|
+
names[n] = field
|
|
2305
|
+
# Typed value-position resolution (issue #295) — see _build_put_request.
|
|
2306
|
+
values[v] = self._serializer.serialize(
|
|
2307
|
+
resolve_value_template(tmpl, params)
|
|
2308
|
+
)
|
|
2309
|
+
sets.append(f"{n} = {v}")
|
|
2310
|
+
|
|
2311
|
+
# GSI key re-derivation (issue #115): an UpdateItem that changes a GSI's
|
|
2312
|
+
# composing field MUST re-derive its `<index>PK`/`<index>SK` or the index
|
|
2313
|
+
# silently rots — exactly as the TS `buildUpdateInput` does. The available
|
|
2314
|
+
# values are the caller `params` (key fields ∪ change fields); a GSI is
|
|
2315
|
+
# affected when at least one of its template placeholders is a changed
|
|
2316
|
+
# field. An affected GSI whose composing fields are not all available
|
|
2317
|
+
# throws (mirroring the base-table partial-key throw).
|
|
2318
|
+
self._append_gsi_rederive_sets(
|
|
2319
|
+
command_id, spec, params, names, values, sets
|
|
2320
|
+
)
|
|
2321
|
+
|
|
2322
|
+
# #301: atomic-`ADD` clauses (`ADD #a :d`, a caller `param.number()` delta).
|
|
2323
|
+
# Built AFTER the SET so an ADD-only expression (`add_only`, or an update
|
|
2324
|
+
# whose SET change set is empty) is legal — DynamoDB's `SET … ADD …` is one
|
|
2325
|
+
# expression, and `ADD …` alone is a valid UpdateExpression. The delta rides
|
|
2326
|
+
# value-position (numeric N), resolved with the SAME typed resolution the SET
|
|
2327
|
+
# values use, so a `param.number()` delta lands as `N`.
|
|
2328
|
+
add_clauses: List[str] = []
|
|
2329
|
+
add_index = len(sets)
|
|
2330
|
+
for field, tmpl in spec.get("add", {}).items():
|
|
2331
|
+
n = f"#a{add_index}"
|
|
2332
|
+
v = f":a{add_index}"
|
|
2191
2333
|
names[n] = field
|
|
2192
|
-
# Typed value-position resolution (issue #295) — see _build_put_request.
|
|
2193
2334
|
values[v] = self._serializer.serialize(resolve_value_template(tmpl, params))
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
# GSI key re-derivation (issue #115): an UpdateItem that changes a GSI's
|
|
2197
|
-
# composing field MUST re-derive its `<index>PK`/`<index>SK` or the index
|
|
2198
|
-
# silently rots — exactly as the TS `buildUpdateInput` does. The available
|
|
2199
|
-
# values are the caller `params` (key fields ∪ change fields); a GSI is
|
|
2200
|
-
# affected when at least one of its template placeholders is a changed
|
|
2201
|
-
# field. An affected GSI whose composing fields are not all available
|
|
2202
|
-
# throws (mirroring the base-table partial-key throw).
|
|
2203
|
-
self._append_gsi_rederive_sets(
|
|
2204
|
-
command_id, spec, params, names, values, sets
|
|
2205
|
-
)
|
|
2335
|
+
add_clauses.append(f"{n} {v}")
|
|
2336
|
+
add_index += 1
|
|
2206
2337
|
|
|
2207
2338
|
request: Dict[str, Any] = {
|
|
2208
2339
|
"TableName": self._physical_table(spec["tableName"]),
|
|
2209
2340
|
"Key": key,
|
|
2210
2341
|
}
|
|
2342
|
+
expr_parts: List[str] = []
|
|
2211
2343
|
if sets:
|
|
2212
|
-
|
|
2344
|
+
expr_parts.append("SET " + ", ".join(sets))
|
|
2345
|
+
if add_clauses:
|
|
2346
|
+
expr_parts.append("ADD " + ", ".join(add_clauses))
|
|
2347
|
+
if expr_parts:
|
|
2348
|
+
request["UpdateExpression"] = " ".join(expr_parts)
|
|
2213
2349
|
request["ExpressionAttributeNames"] = names
|
|
2214
2350
|
request["ExpressionAttributeValues"] = values
|
|
2215
|
-
|
|
2351
|
+
if not add_only:
|
|
2352
|
+
self._apply_condition(spec, request, params)
|
|
2216
2353
|
return request
|
|
2217
2354
|
|
|
2218
2355
|
def _run_update_item(
|
|
@@ -161,15 +161,19 @@ def resolve_value_template(template: Any, params: Mapping[str, Any]) -> Any:
|
|
|
161
161
|
STRING param resolves to the same string either way, so pre-#295 outputs are
|
|
162
162
|
byte-identical.
|
|
163
163
|
"""
|
|
164
|
-
if isinstance(template, str):
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
164
|
+
if not isinstance(template, str):
|
|
165
|
+
# A TYPED literal (a `number` / `boolean` carried verbatim by the emitter,
|
|
166
|
+
# issue #299) is not a template — return it as-is so it marshals to `N` /
|
|
167
|
+
# `BOOL`. Only strings carry `{param}` placeholders.
|
|
168
|
+
return template
|
|
169
|
+
m = _WHOLE_TOKEN.fullmatch(template)
|
|
170
|
+
if m is not None:
|
|
171
|
+
name = m.group(1)
|
|
172
|
+
if name in params and params[name] is not None:
|
|
173
|
+
return params[name]
|
|
174
|
+
raise ParameterValidationError(
|
|
175
|
+
f"template '{template}' references unbound parameter '{name}'"
|
|
176
|
+
)
|
|
173
177
|
return resolve_template(template, params)
|
|
174
178
|
|
|
175
179
|
|
|
@@ -21,6 +21,7 @@ graphddb_runtime.egg-info/SOURCES.txt
|
|
|
21
21
|
graphddb_runtime.egg-info/dependency_links.txt
|
|
22
22
|
graphddb_runtime.egg-info/requires.txt
|
|
23
23
|
graphddb_runtime.egg-info/top_level.txt
|
|
24
|
+
tests/test_atomic_add_command.py
|
|
24
25
|
tests/test_concurrency.py
|
|
25
26
|
tests/test_contract_runtime.py
|
|
26
27
|
tests/test_crosslang_python_cells.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "graphddb-runtime"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.2"
|
|
8
8
|
description = "Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Atomic-ADD standalone update command — two-stage fallback (issue #301).
|
|
2
|
+
|
|
3
|
+
Mirrors the TS ``__tests__/runtime/atomic-add-command.test.ts`` fake-executor
|
|
4
|
+
test against a recording-fake DynamoDB client (no Docker). The ``StatsTreeCmd.mark``
|
|
5
|
+
command carries ``add: { computeVersion }`` (an atomic `ADD`) coexisting with a
|
|
6
|
+
CONDITIONAL SET of ``minPendingAt``/``computedAt``. The correctness guarantee is
|
|
7
|
+
that the ADD is ALWAYS applied while the SET is conditional; because DynamoDB
|
|
8
|
+
discards the WHOLE UpdateItem on a ConditionExpression miss, the runtime performs
|
|
9
|
+
a transparent TWO-STAGE:
|
|
10
|
+
|
|
11
|
+
1. primary: ``ADD + SET`` under the condition (the common path, 1 write);
|
|
12
|
+
2. on ConditionalCheckFailedException: fallback ``ADD``-only (no SET, no
|
|
13
|
+
condition) — the counter still increments.
|
|
14
|
+
|
|
15
|
+
The two cases below pin BOTH branches on the fake client:
|
|
16
|
+
|
|
17
|
+
* common path (condition holds → single UpdateItem, ADD+SET together);
|
|
18
|
+
* fallback path (first UpdateItem throws CCF → a SECOND UpdateItem that is
|
|
19
|
+
ADD-only: no SET-#c aliases, no ConditionExpression).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
|
|
26
|
+
import pytest
|
|
27
|
+
|
|
28
|
+
from graphddb_runtime import GraphDDBRuntime
|
|
29
|
+
|
|
30
|
+
import conftest
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _mark_runtime(client) -> GraphDDBRuntime:
|
|
34
|
+
return GraphDDBRuntime(
|
|
35
|
+
dynamodb_client=client,
|
|
36
|
+
manifest_path=conftest.MANIFEST_PATH,
|
|
37
|
+
operations_path=conftest.OPERATIONS_PATH,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _ConditionalCheckFailed(Exception):
|
|
42
|
+
"""A stand-in for the boto ConditionalCheckFailedException the runtime maps to
|
|
43
|
+
the two-stage fallback (matched by name, mirroring the TS name-based match)."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _ProvisionedThroughputExceeded(Exception):
|
|
47
|
+
"""A stand-in for a NON-CCF boto ``ClientError`` (a throttling error). It carries
|
|
48
|
+
a boto-style ``response.Error.Code`` that is NOT ``ConditionalCheckFailedException``,
|
|
49
|
+
so the runtime's two-stage matcher rejects it and re-raises (no ADD-only fallback)."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str):
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
self.response = {
|
|
54
|
+
"Error": {
|
|
55
|
+
"Code": "ProvisionedThroughputExceededException",
|
|
56
|
+
"Message": message,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _MarkClient:
|
|
62
|
+
"""Records every update_item request; optionally throws a ConditionalCheckFailed
|
|
63
|
+
on the FIRST update_item to drive the two-stage fallback."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, fail_first: bool):
|
|
66
|
+
self.updates: list[dict] = []
|
|
67
|
+
self._fail_first = fail_first
|
|
68
|
+
|
|
69
|
+
def update_item(self, **kwargs):
|
|
70
|
+
self.updates.append(kwargs)
|
|
71
|
+
if self._fail_first and len(self.updates) == 1:
|
|
72
|
+
raise _ConditionalCheckFailed(
|
|
73
|
+
"An error occurred (ConditionalCheckFailedException) when calling "
|
|
74
|
+
"the UpdateItem operation: The conditional request failed"
|
|
75
|
+
)
|
|
76
|
+
return {}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _NonCCFMarkClient:
|
|
80
|
+
"""Records every update_item request; the FIRST update_item raises a NON-CCF error
|
|
81
|
+
(a throttling ``ProvisionedThroughputExceededException``) to prove that a non-CCF
|
|
82
|
+
failure propagates and does NOT trigger the ADD-only two-stage fallback."""
|
|
83
|
+
|
|
84
|
+
def __init__(self):
|
|
85
|
+
self.updates: list[dict] = []
|
|
86
|
+
|
|
87
|
+
def update_item(self, **kwargs):
|
|
88
|
+
self.updates.append(kwargs)
|
|
89
|
+
if len(self.updates) == 1:
|
|
90
|
+
raise _ProvisionedThroughputExceeded(
|
|
91
|
+
"An error occurred (ProvisionedThroughputExceededException) when "
|
|
92
|
+
"calling the UpdateItem operation: rate exceeded"
|
|
93
|
+
)
|
|
94
|
+
return {}
|
|
95
|
+
|
|
96
|
+
def put_item(self, **kwargs): # pragma: no cover - defensive
|
|
97
|
+
raise AssertionError("mark must not put_item")
|
|
98
|
+
|
|
99
|
+
def delete_item(self, **kwargs): # pragma: no cover - defensive
|
|
100
|
+
raise AssertionError("mark must not delete_item")
|
|
101
|
+
|
|
102
|
+
# The mark command never issues these, but the runtime probes for them.
|
|
103
|
+
def put_item(self, **kwargs): # pragma: no cover - defensive
|
|
104
|
+
raise AssertionError("mark must not put_item")
|
|
105
|
+
|
|
106
|
+
def delete_item(self, **kwargs): # pragma: no cover - defensive
|
|
107
|
+
raise AssertionError("mark must not delete_item")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
_MARK_PARAMS = {
|
|
111
|
+
"part": "n1",
|
|
112
|
+
"child": "leaf",
|
|
113
|
+
"computeVersion": 1,
|
|
114
|
+
"minPendingAt": 1700000000000,
|
|
115
|
+
"computedAt": 1700000001000,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _has_add_clause(expr: str) -> bool:
|
|
120
|
+
return "ADD " in expr
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _has_set_clause(expr: str) -> bool:
|
|
124
|
+
return "SET " in expr
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_mark_common_path_single_write_add_and_set():
|
|
128
|
+
"""Condition holds → ONE UpdateItem carrying ADD + SET together, under the
|
|
129
|
+
ConditionExpression. No fallback."""
|
|
130
|
+
client = _MarkClient(fail_first=False)
|
|
131
|
+
rt = _mark_runtime(client)
|
|
132
|
+
|
|
133
|
+
rt.execute_command("StatsTreeCmd__mark", _MARK_PARAMS)
|
|
134
|
+
|
|
135
|
+
assert len(client.updates) == 1, "common path must be a SINGLE UpdateItem"
|
|
136
|
+
req = client.updates[0]
|
|
137
|
+
expr = req["UpdateExpression"]
|
|
138
|
+
assert _has_add_clause(expr), f"primary must carry ADD: {expr!r}"
|
|
139
|
+
assert _has_set_clause(expr), f"primary must carry SET: {expr!r}"
|
|
140
|
+
assert "ConditionExpression" in req, "primary is CONDITIONAL"
|
|
141
|
+
# The ADD delta rides as a numeric N value.
|
|
142
|
+
vals = req["ExpressionAttributeValues"]
|
|
143
|
+
assert any(v == {"N": "1"} for v in vals.values()), vals
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_mark_fallback_add_only_on_condition_fail():
|
|
147
|
+
"""First UpdateItem throws ConditionalCheckFailed → a SECOND UpdateItem that is
|
|
148
|
+
ADD-only: it carries the ADD clause, NO SET clause, and NO ConditionExpression —
|
|
149
|
+
the computeVersion++ still applies while the SET is dropped."""
|
|
150
|
+
client = _MarkClient(fail_first=True)
|
|
151
|
+
rt = _mark_runtime(client)
|
|
152
|
+
|
|
153
|
+
rt.execute_command("StatsTreeCmd__mark", _MARK_PARAMS)
|
|
154
|
+
|
|
155
|
+
assert len(client.updates) == 2, "condition miss must trigger EXACTLY one retry"
|
|
156
|
+
primary, fallback = client.updates
|
|
157
|
+
# Primary: ADD + SET under the condition.
|
|
158
|
+
assert _has_add_clause(primary["UpdateExpression"])
|
|
159
|
+
assert _has_set_clause(primary["UpdateExpression"])
|
|
160
|
+
assert "ConditionExpression" in primary
|
|
161
|
+
# Fallback: ADD-only — no SET, no condition.
|
|
162
|
+
fexpr = fallback["UpdateExpression"]
|
|
163
|
+
assert _has_add_clause(fexpr), f"fallback must carry ADD: {fexpr!r}"
|
|
164
|
+
assert not _has_set_clause(fexpr), f"fallback must NOT carry SET: {fexpr!r}"
|
|
165
|
+
assert "ConditionExpression" not in fallback, "fallback must be UNCONDITIONAL"
|
|
166
|
+
# The ADD delta still rides in the fallback as the same numeric N.
|
|
167
|
+
fvals = fallback["ExpressionAttributeValues"]
|
|
168
|
+
assert any(v == {"N": "1"} for v in fvals.values()), fvals
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_mark_non_ccf_error_propagates():
|
|
172
|
+
"""A NON-condition error on the primary must NOT be swallowed by the two-stage —
|
|
173
|
+
it propagates and no ADD-only fallback fires. Parity with the Rust
|
|
174
|
+
``mark_non_ccf_error_propagates`` and the Go ``TestMarkNonCCFErrorPropagates``."""
|
|
175
|
+
client = _NonCCFMarkClient()
|
|
176
|
+
rt = _mark_runtime(client)
|
|
177
|
+
|
|
178
|
+
with pytest.raises(Exception) as excinfo:
|
|
179
|
+
rt.execute_command("StatsTreeCmd__mark", _MARK_PARAMS)
|
|
180
|
+
|
|
181
|
+
# The original throttling error must surface (never rewritten to a CCF path).
|
|
182
|
+
assert "ProvisionedThroughputExceeded" in str(excinfo.value), excinfo.value
|
|
183
|
+
# Exactly ONE UpdateItem: a non-CCF error must NOT trigger the ADD-only retry.
|
|
184
|
+
assert len(client.updates) == 1, "a non-CCF error must NOT trigger a second write"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/graphddb_runtime.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{graphddb_runtime-0.9.0 → graphddb_runtime-0.9.2}/tests/test_integration_binding_contract.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|