feat: add TTL cache utility for reducing redundant API calls - #19
feat: add TTL cache utility for reducing redundant API calls#19bnusunny wants to merge 32 commits into
Conversation
Add a manually-triggered GitHub Actions workflow that runs test_download_two_layers 5 times with debug instrumentation to diagnose why layers sometimes return 'Layer1' instead of 'Layer2' on Docker (but not Finch). Instrumentation logs: - Layer ordering in download_all() input/output - Generated Dockerfile ADD command sequence - Tarball construction order - Image build vs reuse decisions
Instead of running isolated iterations, run the exact same pytest command as the CI local-invoke suite with added debug logging. This reproduces the real test ordering and cross-test interactions. Enhanced instrumentation now also captures: - Per-layer download decisions (cached vs fresh) - Extracted layer file contents after download - Docker build stream output (cache hits show as CACHED)
Run just the TestLayerVersion class instead of the full local-invoke suite. This covers the failing test_download_two_layers plus the other tests in the class that may cause cross-test contamination.
debug: add workflow to investigate flaky layer ordering tests
debug: remove repo guard for fork
debug: use direct OIDC auth
debug: add BY_CANARY=true
…rm container tests * Update notify-slack.yml to add sleep (aws#8646) * fix: use tag prefix matching to clean up samcli/lambda-* images (aws#8647) Docker's images.list(name='samcli/lambda') does exact repository matching and won't match repositories like 'samcli/lambda-python'. This caused stale images to persist across parameterized test classes, leading to flaky test_download_two_layers failures where Layer2 should overwrite Layer1 but the image was never rebuilt. Fix by: 1. Adding _cleanup_samcli_images() that lists all images and filters by 'samcli/lambda-' tag prefix 2. Using this method in both tearDown and tearDownClass 3. Fixing the same pattern in TestLayerVersionThatDoNotCreateCache * fix: remove fallback in count_running_containers that causes flaky warm container tests The count_running_containers method had a fallback that returned the count of ALL SAM CLI containers when MODE env var filtering found no matches. This caused AssertionError: 3 != 2 when stale containers from other tests were present. Now it strictly counts only containers matching this test's unique MODE UUID and uses exact string matching. * fix: add AWS_DEFAULT_REGION and use -k pattern for parameterized tests * fix: add BY_CANARY=true to enable Docker tests on CI * fix: exclude RemoteLayers tests that need AWS credentials --------- Co-authored-by: seshubaws <116689586+seshubaws@users.noreply.github.com>
When build_in_source is used with Node.js, the build directory contains a node_modules symlink. During local invoke, SAM CLI resolves this symlink and creates an additional bind mount. Docker tolerates creating a mountpoint over a symlink, but Finch (containerd/runc) fails with 'not a directory'. This fix temporarily replaces symlinks with empty directories before container creation, then restores them afterward. This ensures: - Finch/runc gets a valid directory mountpoint - Docker continues to work as before - Repeated invocations work because symlinks are restored - Host filesystem is left unchanged even if container creation fails
fix: replace symlinks with dirs for Finch container mount compatibility
fix: restore BY_CANARY to run docker tests on CI
Containerd/Finch resolves bind mounts at start time, not create time. Moving the symlink restore to after start() ensures the empty directory mountpoints are still present when the container runtime sets up mounts.
fix: move symlink restore to after container start
Bind mounts must remain valid for the container's entire lifetime. Both Docker and Finch need the empty directory mountpoint to persist until the container is stopped and deleted. Restoring symlinks in the delete() method's finally block ensures proper cleanup alongside the existing host_tmp_dir cleanup.
fix: restore symlinks at container delete time
Implement a local CloudFormation Language Extensions processor supporting: - Fn::ForEach loop expansion in Resources, Conditions, and Outputs - Fn::Length, Fn::ToJsonString intrinsic functions - Fn::FindInMap with DefaultValue support - Conditional DeletionPolicy/UpdateReplacePolicy - Nested ForEach depth validation (max 5 levels) - Partial resolution mode preserving unresolvable references Pipeline architecture: TemplateParsingProcessor -> ForEachProcessor -> IntrinsicResolverProcessor -> DeletionPolicyProcessor -> UpdateReplacePolicyProcessor Includes comprehensive unit tests and CloudFormation compatibility suite.
Wire the language extensions library into SAM CLI with two-phase architecture: - Phase 1: expand_language_extensions() -> LanguageExtensionResult - Phase 2: SamTranslatorWrapper.run_plugins() (SAM transform only) Key components: - expand_language_extensions() canonical entry point with template-level cache keyed on (path, mtime, params_hash) - SamTranslatorWrapper receives pre-expanded template (Phase 2 only) - SamLocalStackProvider.get_stacks() calls expand_language_extensions() - SamTemplateValidator calls expand_language_extensions() - DynamicArtifactProperty dataclass for Mappings transformation - Fn::ForEach guards in artifact_exporter, normalizer, cdk/utils - clear_expansion_cache() for warm container file change events
- _get_template_for_output() preserves Fn::ForEach in build output - _update_foreach_artifact_paths() generates Mappings for dynamic artifact properties with per-function build paths - Recursive nested Fn::ForEach support - ForEach-aware path resolution skips Docker image URIs Test templates: static CodeUri, dynamic CodeUri, parameter collections, nested stacks, nested ForEach, dynamic ImageUri, depth validation.
Package:
- _export() calls expand_language_extensions() for Phase 1
- Preserves Fn::ForEach in packaged template with S3 URIs
- Generates Mappings for dynamic artifact properties
- _find_artifact_uri_for_resource() handles all export formats:
string, {S3Bucket,S3Key}, {Bucket,Key}, {ImageUri}
- Recursive nested Fn::ForEach support
- Warning for parameter-based collections
Deploy:
- Uploads original unexpanded template to CloudFormation
- Clear error for missing Mapping keys
Integration tests for CodeUri, ContentUri, DefinitionUri, ImageUri,
BodyS3Location across all packageable resource types.
- sam validate: valid ForEach, invalid syntax, cloud-dependent collections, dynamic CodeUri, nested depth validation (5 valid, 6 invalid) - sam local invoke: expanded function names from ForEach - sam local start-api: ForEach-generated API endpoints
Add make test-lang-ext and make test-all targets so the 1695 language extensions unit tests only run when needed, keeping the default make test fast for unrelated PRs.
The expand_language_extensions() cache stored references to template dicts that were later mutated in-place by ApplicationBuilder.update_template() (which changes nested stack Location properties to build-output paths). On cache hit, the mutated dict was returned, causing TemplateNotFoundException during the second infra sync in sam sync --watch. Remove the cache entirely since deep-copying on hit negates the performance benefit and adds complexity. Keep clear_expansion_cache() as a no-op for backward compatibility. Fixes TestSyncInfraNestedStacks_0 and TestSyncInfraNestedStacks_1 integration test failures.
fix: remove expansion cache to fix sync watch nested stack failures
fix: use OIDC credentials directly for sync test workflow
There was a problem hiding this comment.
Review: feat: add TTL cache utility
The implementation is clean and the core design is sound — time.monotonic() for expiration, threading.Lock for safety, and a simple dict-based store. However, there are a few issues that should be addressed before merging.
🔴 Must Fix
-
No unit tests — A utility claiming thread-safety needs tests for basic get/set/delete, TTL expiration, concurrent access, and edge cases. This is a hard blocker.
-
Unbounded memory growth — There is no
max_sizelimit. If callers cache many unique keys (e.g., per-resource API responses), the store grows without bound. Expired entries are only cleaned up on access or whensize()is called. Add amax_sizeparameter with eviction (e.g., LRU or oldest-first) onset(). -
TOCTOU race in
size()—now = time.monotonic()is captured before acquiring the lock. Time passes between that call and the lock acquisition, so the filtering may keep entries that are actually expired. Movenow = time.monotonic()inside thewith self._lock:block.
🟡 Should Fix
-
size()mutates state as a side effect — A method namedsizeis expected to be read-only, but it purges expired entries. Either rename it (e.g.,cleanup_and_count()) or separate the purge into its own method. -
Python version compatibility —
dict[str, tuple[Any, float]]syntax requires Python 3.9+. If the project supports 3.8, usetyping.Dictandtyping.Tupleinstead. -
No proactive cleanup — Expired entries only get removed when their specific key is accessed via
get()or whensize()is called. Consider a purge-on-write strategy (e.g., probabilistic cleanup onset()).
💡 Suggestions
setshadows the Python built-in — common in cache APIs but some linters will flag it.putorstoreare alternatives.- No
__contains__/has()method — callers must docache.get(key) is not None, which is ambiguous ifNoneis a valid cached value. get()returnsNonefor both "key missing" and "key expired" — worth documenting explicitly in the docstring.
The core design is solid. With tests and a max-size cap, this would be a great addition. 👍
| Keys expire individually based on the TTL set at insertion time. | ||
| Expired entries are lazily cleaned up on access. | ||
| """ | ||
|
|
There was a problem hiding this comment.
🔴 Unbounded growth: The _store dict has no size limit. In long-running processes with many unique cache keys, this will grow without bound since expired entries are only cleaned up lazily.
Suggestion: add a max_size parameter and evict oldest entries on set():
def __init__(self, default_ttl: float = 300.0, max_size: int = 1024):
self._max_size = max_size| def clear(self) -> None: | ||
| """Remove all entries.""" | ||
| with self._lock: | ||
| self._store.clear() |
There was a problem hiding this comment.
🔴 TOCTOU race: now is captured before the lock is acquired. Move it inside the with block:
def size(self) -> int:
with self._lock:
now = time.monotonic()
self._store = {k: v for k, v in self._store.items() if v[1] > now}
return len(self._store)Also, a method named size shouldn't mutate state as a side effect — consider splitting into a purge() method and a read-only size.
| Keys expire individually based on the TTL set at insertion time. | ||
| Expired entries are lazily cleaned up on access. | ||
| """ | ||
|
|
There was a problem hiding this comment.
🟡 Python compat: dict[str, tuple[Any, float]] requires Python 3.9+. If the project supports 3.8, use typing.Dict[str, typing.Tuple[Any, float]] instead.
|
I've completed the review of PR #19. Here's a summary of what I did:
The main blockers are the missing unit tests and unbounded memory growth. The core design is solid and should be ready to merge after those are addressed. |
Adds a thread-safe in-memory TTL cache with per-key expiration for reducing redundant AWS API calls during SAM CLI operations.