|
11 | 11 |
|
12 | 12 | from __future__ import annotations |
13 | 13 |
|
14 | | -import logging |
15 | 14 | import threading |
16 | 15 | import time |
17 | 16 | from typing import Any |
@@ -182,6 +181,18 @@ def __deepcopy__(self, memo: dict[int, Any]) -> Any: |
182 | 181 | raise RuntimeError("uncopyable payload") |
183 | 182 |
|
184 | 183 |
|
| 184 | +class _SlottedPayload: |
| 185 | + __slots__ = ("payload",) |
| 186 | + |
| 187 | + def __init__(self, payload: Any) -> None: |
| 188 | + self.payload = payload |
| 189 | + |
| 190 | + |
| 191 | +class _UnsizedSlottedPayload(_SlottedPayload): |
| 192 | + def __sizeof__(self) -> int: |
| 193 | + raise RuntimeError("size unavailable") |
| 194 | + |
| 195 | + |
185 | 196 | class _Unsized: |
186 | 197 | """A payload whose custom ``__sizeof__`` raises.""" |
187 | 198 |
|
@@ -318,58 +329,45 @@ def test_pending_fifo_cap_drops_oldest_and_retains_terminal(): |
318 | 329 | # -- copy failure isolation --------------------------------------------------- |
319 | 330 |
|
320 | 331 |
|
321 | | -def test_deepcopy_failure_skips_record_and_lane_continues(caplog): |
322 | | - exporter = RecordingExporter() |
| 332 | +def test_uncopyable_custom_value_reaches_exporter_render(): |
| 333 | + class CustomRenderExporter(RecordingExporter): |
| 334 | + def __init__(self) -> None: |
| 335 | + super().__init__(max_record_size_bytes=10_000) |
| 336 | + self.rendered_values: list[str] = [] |
| 337 | + |
| 338 | + def render(self, record: dict[str, Any]) -> Any: |
| 339 | + value = record["payload"]["value"] |
| 340 | + self.rendered_values.append(value) |
| 341 | + return {"value": value} |
| 342 | + |
| 343 | + exporter = CustomRenderExporter() |
323 | 344 | scheduler = _ExportScheduler([exporter]) |
324 | | - # A record whose deepcopy raises must be skipped for this lane -- never |
325 | | - # exported by aliasing the shared object -- and the lane must keep draining. |
326 | | - bad = _rec(ARN_A, "bad") |
327 | | - bad["payload"] = _Uncopyable() |
328 | | - good = _rec(ARN_B, "good") |
329 | | - with caplog.at_level( |
330 | | - logging.WARNING, logger="aws_durable_execution_sdk_python_insight" |
331 | | - ): |
332 | | - scheduler.schedule(ARN_A, bad) # queued first: copy fails -> skipped |
333 | | - scheduler.schedule(ARN_B, good) # queued behind it: must still export |
334 | | - # The good record delivering proves the lane continued past the failure; |
335 | | - # a single-lane worker drains FIFO, so "bad" was processed (and skipped) |
336 | | - # before "good" ran. |
337 | | - assert _wait_until(lambda: exporter.exported_values() == ["good"]) |
338 | | - scheduler.end_invocation(5.0) |
339 | | - # The exporter was never called for the un-copyable record. |
340 | | - assert exporter.exported_values() == ["good"] |
341 | | - # The failure was logged through the module logger. |
342 | | - assert any( |
343 | | - "record copy failed" in record.getMessage() |
344 | | - for record in caplog.records |
345 | | - if record.name == "aws_durable_execution_sdk_python_insight" |
346 | | - ) |
| 345 | + record = _rec(ARN_A, "before-render") |
| 346 | + record["payload"] = _Uncopyable() |
347 | 347 |
|
| 348 | + scheduler.schedule(ARN_A, record) |
| 349 | + scheduler.end_invocation(5.0) |
| 350 | + |
| 351 | + assert exporter.rendered_values == ["safe"] |
| 352 | + assert exporter.exported_values() == ["before-render"] |
348 | 353 |
|
349 | | -def test_deepcopy_failure_does_not_alias_shared_record(): |
350 | | - # Before the fix a copy failure aliased the shared record and passed it to |
351 | | - # truncate_record -> render, which could mutate the canonical object other |
352 | | - # lanes still read. With the fix the record is skipped before render, so it |
353 | | - # is never aliased or mutated in place. |
| 354 | + |
| 355 | +def test_uncopyable_custom_value_does_not_alias_record_containers(): |
354 | 356 | class MutatingRenderExporter(RecordingExporter): |
355 | 357 | def render(self, record: dict[str, Any]) -> Any: |
356 | | - record["mutated"] = True # would corrupt an aliased shared record |
| 358 | + record["mutated"] = True |
357 | 359 | return record |
358 | 360 |
|
359 | 361 | exporter = MutatingRenderExporter() |
360 | 362 | scheduler = _ExportScheduler([exporter]) |
361 | | - bad = _rec(ARN_A, "bad") |
362 | | - bad["payload"] = _Uncopyable() |
363 | | - scheduler.schedule(ARN_A, bad) |
364 | | - # A good record behind it lets us deterministically wait for the lane to |
365 | | - # drain past the bad one (single lane drains FIFO). |
366 | | - scheduler.schedule(ARN_B, _rec(ARN_B, "good")) |
367 | | - assert _wait_until(lambda: exporter.exported_values() == ["good"]) |
| 363 | + record = _rec(ARN_A, "custom") |
| 364 | + record["payload"] = _Uncopyable() |
| 365 | + |
| 366 | + scheduler.schedule(ARN_A, record) |
368 | 367 | scheduler.end_invocation(5.0) |
369 | | - # render never ran on the un-copyable record, so the canonical object was |
370 | | - # neither aliased into export nor mutated in place. |
371 | | - assert "mutated" not in bad |
372 | | - assert exporter.exported_values() == ["good"] |
| 368 | + |
| 369 | + assert "mutated" not in record |
| 370 | + assert exporter.exported_values() == ["custom"] |
373 | 371 |
|
374 | 372 |
|
375 | 373 | # -- non-blocking hook return / fast-vs-slow isolation ----------------------- |
@@ -692,6 +690,47 @@ def test_individually_over_budget_record_does_not_evict_existing_backlog(): |
692 | 690 | assert exported[1:] == ["b" * 700, "c" * 700] |
693 | 691 |
|
694 | 692 |
|
| 693 | +def test_over_budget_replacement_removes_superseded_same_arn_only(): |
| 694 | + exporter = BlockingExporter() |
| 695 | + scheduler = _ExportScheduler([exporter], max_pending_bytes=3_500) |
| 696 | + lane = scheduler._lanes[0] |
| 697 | + scheduler.schedule(ARN_A, _rec(ARN_A, "inflight")) |
| 698 | + assert _wait_until(exporter.started.is_set) |
| 699 | + scheduler.schedule(ARN_A, _rec(ARN_A, "stale-running")) |
| 700 | + scheduler.schedule(ARN_B, _rec(ARN_B, "unrelated")) |
| 701 | + scheduler.schedule( |
| 702 | + ARN_A, |
| 703 | + _rec(ARN_A, "terminal" * 500, status="SUCCEEDED"), |
| 704 | + ) |
| 705 | + |
| 706 | + assert lane._pending_count() == 1 |
| 707 | + assert lane._pending_bytes_count() <= 3_500 |
| 708 | + exporter.release() |
| 709 | + scheduler.end_invocation(5.0) |
| 710 | + assert exporter.exported_values() == ["inflight", "unrelated"] |
| 711 | + |
| 712 | + |
| 713 | +def test_retained_size_traverses_slots_after_shallow_size_failure(): |
| 714 | + for payload in ( |
| 715 | + _SlottedPayload("x" * 4_000), |
| 716 | + _UnsizedSlottedPayload("x" * 4_000), |
| 717 | + ): |
| 718 | + exporter = BlockingExporter() |
| 719 | + scheduler = _ExportScheduler([exporter], max_pending_bytes=2_500) |
| 720 | + lane = scheduler._lanes[0] |
| 721 | + scheduler.schedule(ARN_A, _rec(ARN_A, "inflight")) |
| 722 | + assert _wait_until(exporter.started.is_set) |
| 723 | + record = _rec(ARN_B, "opaque") |
| 724 | + record["payload"] = payload |
| 725 | + scheduler.schedule(ARN_B, record) |
| 726 | + |
| 727 | + assert lane._pending_count() == 0 |
| 728 | + assert lane._pending_bytes_count() == 0 |
| 729 | + exporter.release() |
| 730 | + scheduler.end_invocation(5.0) |
| 731 | + assert exporter.exported_values() == ["inflight"] |
| 732 | + |
| 733 | + |
695 | 734 | def test_record_sizing_exception_does_not_escape_schedule(): |
696 | 735 | exporter = BlockingExporter() |
697 | 736 | scheduler = _ExportScheduler([exporter]) |
|
0 commit comments