graphddb-runtime 0.9.0__tar.gz → 0.9.1__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.
Files changed (56) hide show
  1. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/PKG-INFO +1 -1
  2. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime/runtime.py +160 -23
  3. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime/templates.py +13 -9
  4. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime.egg-info/PKG-INFO +1 -1
  5. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime.egg-info/SOURCES.txt +1 -0
  6. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/pyproject.toml +1 -1
  7. graphddb_runtime-0.9.1/tests/test_atomic_add_command.py +184 -0
  8. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/README.md +0 -0
  9. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime/__init__.py +0 -0
  10. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime/async_runtime.py +0 -0
  11. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime/batch.py +0 -0
  12. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime/behavior_exec.py +0 -0
  13. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime/concurrency.py +0 -0
  14. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime/cursor.py +0 -0
  15. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime/errors.py +0 -0
  16. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime/filters.py +0 -0
  17. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime/handler_registry.py +0 -0
  18. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime/hydration.py +0 -0
  19. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime/limits.py +0 -0
  20. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime/middleware.py +0 -0
  21. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime/per_key_cursor.py +0 -0
  22. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime/transactions.py +0 -0
  23. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
  24. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime.egg-info/requires.txt +0 -0
  25. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/graphddb_runtime.egg-info/top_level.txt +0 -0
  26. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/setup.cfg +0 -0
  27. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_concurrency.py +0 -0
  28. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_contract_runtime.py +0 -0
  29. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_crosslang_python_cells.py +0 -0
  30. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_description.py +0 -0
  31. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_description_followups.py +0 -0
  32. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_description_jsdoc.py +0 -0
  33. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_error_policy.py +0 -0
  34. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_generated_module.py +0 -0
  35. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_guard_eval.py +0 -0
  36. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_handler_registry.py +0 -0
  37. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_integration.py +0 -0
  38. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_integration_batch_write.py +0 -0
  39. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_integration_binding_contract.py +0 -0
  40. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_integration_command.py +0 -0
  41. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_integration_compose.py +0 -0
  42. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_integration_contract.py +0 -0
  43. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_integration_edge_derive.py +0 -0
  44. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_integration_edge_write.py +0 -0
  45. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_integration_events.py +0 -0
  46. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_integration_maintain.py +0 -0
  47. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_integration_middleware.py +0 -0
  48. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_integration_referential.py +0 -0
  49. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_integration_relations.py +0 -0
  50. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_integration_unique.py +0 -0
  51. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_maintain.py +0 -0
  52. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_middleware.py +0 -0
  53. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_relations.py +0 -0
  54. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_spec_version_guard.py +0 -0
  55. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_ttl.py +0 -0
  56. {graphddb_runtime-0.9.0 → graphddb_runtime-0.9.1}/tests/test_unit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44).
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -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._send_write_item(command_id, item, force_old_image=False)
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._send_write_item(
1263
- command_id, items[0], force_old_image=force_old_image, cap=cap
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, command_id: str, spec: Mapping[str, Any], params: Mapping[str, Any]
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
- for i, (field, tmpl) in enumerate(spec.get("changes", {}).items()):
2189
- n = f"#c{i}"
2190
- v = f":c{i}"
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
- sets.append(f"{n} = {v}")
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
- request["UpdateExpression"] = "SET " + ", ".join(sets)
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
- self._apply_condition(spec, request, params)
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
- m = _WHOLE_TOKEN.fullmatch(template)
166
- if m is not None:
167
- name = m.group(1)
168
- if name in params and params[name] is not None:
169
- return params[name]
170
- raise ParameterValidationError(
171
- f"template '{template}' references unbound parameter '{name}'"
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44).
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -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.0"
7
+ version = "0.9.1"
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"