A Python Cache Hit Can Return an Exhausted Coroutine
The hit counter increases, but the caller receives an exception instead of a value. In the sequential fixture below, an asynchronous loader decorated with functools.lru_cache returns ("A", 10) on its first await. The next same-key lookup registers a cache hit, yet awaiting the returned object raises RuntimeError: cannot reuse already awaited coroutine. The cache found its entry. That entry did not satisfy the caller’s contract.
The distinction is between caching an operation’s immediate return object and caching the value obtained after that operation finishes. Here, the immediate object is a coroutine. Following its identity and lifecycle explains the failure without invoking a service outage, a scheduling race, or an unreliable external dependency.
This playbook uses one local CPython 3.13.5 process, one event loop, standard-library tools, and strictly sequential calls. Synthetic source values begin at A=10 and B=20. Nine cases compare fresh coroutines, cached coroutines, completed immutable tuples, explicit invalidation, and one Task. Each case has a narrow purpose, including negative controls that pass precisely because they reproduce incorrect cache behavior.
The repair is deliberately small: admit a completed value only after its producer succeeds and its result passes the declared checks. This is not a performance benchmark or a general-purpose asynchronous cache design. The final decision depends on entry type, genuine failure propagation, and an explicit freshness policy, not on a green hit counter alone.
Define what a repeated lookup must return
Start with an independent oracle rather than whatever the implementation happens to produce. The synthetic source defines A as 10 and B as 20. A successful load returns a two-element tuple: the requested literal string key followed by its integer value. Initially, the approved values are therefore ("A", 10) and ("B", 20).
For this policy, a successful cached lookup may reuse an admitted completed tuple until its key is explicitly invalidated. On an uncached load, a genuine producer failure must reach the caller and must not become successful cached data. A repeat lookup is not required to rerun the producer merely because it is a new call; that would contradict the selected reuse policy.
Conversely, a repeat lookup must not require restarting an already consumed operation. The public expression is await cache.get("A"), but the item retained inside the repaired cache is data, not the coroutine created by calling get.
Keep four questions separate during review: what object was retained, whether the producer executed, what reached the caller, and whether that value remains authorized by the freshness policy. A hit counter answers none of those questions completely. It is supporting wrapper evidence, not the acceptance oracle.
Broader endpoint guidance provides context for cache performance versus correctness. This laboratory deliberately evaluates correctness without claiming any latency or throughput improvement.
The admitted domain and the exercised domain also differ. The driver permits the two literal keys, but its nine named scenarios actively look up A. B’s initial value is a declared input, not a separately observed lookup result. Acceptance must not quietly become a claim of exhaustive two-key or admission-guard coverage. That distinction keeps the test inventory honest before any output appears.
Separate an async call from its first execution
Calling the fixture’s async def function creates a coroutine object; it does not, by that call alone, execute the body or schedule the object as a Task. Python’s coroutines and Tasks documentation distinguishes the coroutine function from the object returned by calling it.
Now place an ordinary memoizing wrapper around that function. The lru_cache documentation describes retained return values, hit and miss counts, cache size, and clearing. It explicitly identifies async functions as inappropriate candidates for this style of caching. The relevant immediate return here is the coroutine, not the tuple eventually produced by awaiting it.
In this driver, the producer counter increments inside load, before its await asyncio.sleep(0). That location makes body entry independently visible. A wrapper can construct and retain an object successfully while the counter remains zero. Neither a returned object nor a populated cache entry proves that the loader has run.
Identify the object that the cache actually owns
A2 assigns x and y from two same-key wrapper calls before either is awaited. The decisive comparison is x is y. Equal eventual tuples would not establish shared ownership; this identity comparison does.
The case records one miss, one hit, one retained entry, zero producer calls, and CORO_CREATED. After await x, inspection through y reports CORO_CLOSED. Python’s coroutine-state inspection reference documents those states alongside CORO_RUNNING and CORO_SUSPENDED; this fixture samples the created and closed endpoints, not every intermediate state.
The interpretation is specific: two references lead to one coroutine whose execution lifetime has ended. No memory-address analysis or assumption about CPython’s internal object layout is necessary. Preserve the identity observation and the state transition together. A screenshot showing only the eventual tuple would omit the evidence that explains why the next consumer fails.
Run the complete nine-case fixture
Save the following complete source as async_cache_fixture.py in a new, writable disposable directory. Run it as a script with the intended interpreter, rather than pasting it into an interactive session: its evidence capture uses file.
python --version
python async_cache_fixture.py
No package installation, credentials, network connection, or companion module is required. Each scenario constructs a fresh producer and, where applicable, a fresh wrapper. All nine scenarios execute inside the single asyncio.run call. Their counters and synthetic source changes do not carry into subsequent cases.
The initial successful-result oracle is ("A", 10); after A8’s explicit source update and invalidation it is ("A", 11). Expected failures are specified by exact exception type. Do not derive these expectations from a previous report and then call the comparison independent.
"""Sequential, local coroutine/value-cache qualification; no network or threads."""
import asyncio
import functools
import hashlib
import inspect
import json
import platform
import tempfile
from datetime import datetime, timezone
from pathlib import Path
def require(ok, message):
if not ok:
raise RuntimeError(message)
def producer(failures=0):
state = {"calls": 0, "failures": failures, "source": {"A": 10, "B": 20}}
async def load(key):
state["calls"] += 1
await asyncio.sleep(0)
if state["failures"]:
state["failures"] -= 1
raise ValueError("synthetic load failure")
return (key, state["source"][key])
return load, state
class CompletedValues:
"""Two admitted keys, immutable tuples, sequential callers in one loop only."""
def init(self, load):
self.load, self.values = load, {}
async def get(self, key):
if key not in {"A", "B"}:
raise ValueError("Unadmitted key")
if key not in self.values:
result = await self.load(key)
if (type(result) is not tuple or len(result) != 2 or
result[0] != key or type(result[1]) is not int):
raise ValueError("Unadmitted completed value")
self.values[key] = result
return self.values[key]
def invalidate(self, key):
self.values.pop(key, None)
async def expect_error(awaitable, kind):
try:
await awaitable
except Exception as error:
require(type(error) is kind, "Unexpected exception: " + repr(error))
return {"type": type(error).__name__, "message": str(error)}
raise RuntimeError("Expected exception was absent")
async def exercise(report):
def row(identity):
value = {"id": identity, "status": "FAIL"}
report["cases"].append(value)
return value
r = row("A1_uncached_fresh_coroutines")
load, state = producer()
x, y = load("A"), load("A")
r["same_object"] = x is y
r["before"] = [inspect.getcoroutinestate(x), inspect.getcoroutinestate(y)]
r["values"] = [await x, await y]
r["calls"] = state["calls"]
require(not r["same_object"] and r["before"] == ["CORO_CREATED"] * 2,
"Uncached construction mismatch")
require(r["values"] == [("A", 10)] * 2 and r["calls"] == 2, "Wrong baseline")
r.update(status="PASS", decision="BASELINE")
r = row("A2_cache_hit_before_first_await")
load, state = producer()
cached = functools.lru_cache(maxsize=2)(load)
x, y = cached("A"), cached("A")
r.update(same_object=x is y, state=inspect.getcoroutinestate(x),
calls_before=state["calls"], cache_info=cached.cache_info()._asdict())
r["value"] = await x
r["state_after"] = inspect.getcoroutinestate(y)
require(r["same_object"] and r["calls_before"] == 0 and
r["state"] == "CORO_CREATED" and r["state_after"] == "CORO_CLOSED",
"The cached object is not the expected coroutine")
require(r["cache_info"] == dict(hits=1, misses=1, maxsize=2, currsize=1),
"Unexpected cache statistics")
r.update(status="PASS", decision="COROUTINE_NOT_COMPLETED_VALUE")
r = row("A3_success_then_cached_reawait")
load, state = producer()
cached = functools.lru_cache(maxsize=2)(load)
r["first"] = await cached("A")
r["second_error"] = await expect_error(cached("A"), RuntimeError)
r["cache_info"] = cached.cache_info()._asdict()
require(r["first"] == ("A", 10) and state["calls"] == 1 and
r["cache_info"]["hits"] == 1, "Cached reawait control changed")
r.update(status="PASS", decision="REPAIR")
r = row("A4_failed_body_then_cached_reawait")
load, state = producer(failures=1)
cached = functools.lru_cache(maxsize=2)(load)
r["first_error"] = await expect_error(cached("A"), ValueError)
r["second_error"] = await expect_error(cached("A"), RuntimeError)
r["calls"] = state["calls"]
require(r["calls"] == 1, "Failed coroutine unexpectedly reran")
r.update(status="PASS", decision="REPAIR")
r = row("A5_clear_only_restarts_the_cycle")
load, state = producer()
cached = functools.lru_cache(maxsize=2)(load)
r["first"] = await cached("A")
cached.cache_clear()
r["after_clear"] = await cached("A")
r["next_error"] = await expect_error(cached("A"), RuntimeError)
require(r["first"] == r["after_clear"] == ("A", 10) and state["calls"] == 2,
"Cache-clear negative control changed")
r.update(status="PASS", decision="TEMPORARY_RESET_NOT_REPAIR")
r = row("A6_completed_value_reuse")
load, state = producer()
cache = CompletedValues(load)
r["values"] = [await cache.get("A"), await cache.get("A")]
r["stored_types"] = [type(v).__name__ for v in cache.values.values()]
require(r["values"] == [("A", 10)] * 2 and state["calls"] == 1 and
r["stored_types"] == ["tuple"], "Completed-value repair failed")
r.update(status="PASS", decision="ACCEPT_BOUNDED")
r = row("A7_failure_is_not_admitted_to_value_cache")
load, state = producer(failures=1)
cache = CompletedValues(load)
r["error"] = await expect_error(cache.get("A"), ValueError)
r["keys_after_failure"] = list(cache.values)
r["retry"] = await cache.get("A")
r["reused"] = await cache.get("A")
require(r["keys_after_failure"] == [] and r["retry"] == r["reused"] == ("A", 10)
and state["calls"] == 2, "Failure/retry contract failed")
r.update(status="PASS", decision="ACCEPT_BOUNDED")
r = row("A8_explicit_invalidation")
load, state = producer()
cache = CompletedValues(load)
r["initial"] = await cache.get("A")
state["source"]["A"] = 11
r["without_invalidation"] = await cache.get("A")
cache.invalidate("A")
r["after_invalidation"] = await cache.get("A")
require(r["initial"] == r["without_invalidation"] == ("A", 10) and
r["after_invalidation"] == ("A", 11) and state["calls"] == 2,
"Invalidation contract failed")
r.update(status="PASS", decision="EXPLICIT_FRESHNESS_POLICY_REQUIRED")
r = row("A9_task_is_a_different_awaitable")
load, state = producer()
task = asyncio.create_task(load("A"))
r["values"] = [await task, await task]
r["done"] = task.done()
require(r["values"] == [("A", 10)] * 2 and r["done"] and state["calls"] == 1,
"Task comparison failed")
r.update(status="PASS", decision="COMPARISON_NOT_A_TASK_CACHE_DESIGN")
require(len(report["cases"]) == len({r["id"] for r in report["cases"]}) == 9,
"Incomplete case inventory")
def main():
destination = Path(tempfile.mkdtemp(prefix="async-cache-", dir=Path(__file__).parent))
report = {"revision": "async-cache-v1", "status": "FAIL", "cases": [],
"started_utc": datetime.now(timezone.utc).isoformat(),
"python": platform.python_version(), "platform": platform.platform(),
"source_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
try:
asyncio.run(exercise(report))
report["status"] = "PASS_9_CASES"
except Exception as error:
report["error"] = {"type": type(error).__name__, "message": str(error)}
raise
finally:
report["finished_utc"] = datetime.now(timezone.utc).isoformat()
path = destination / "results.json"
path.write_text(json.dumps(report, indent=2, allow_nan=False) + "\n")
print(path)
if name == "__main__":
main()
The supplied reference report records PASS_9_CASES on CPython 3.13.5, platform Linux-6.18.44-x86_64-with-glibc2.41. Its execution began at 2026-10-14T12:08:30.665584+00:00 and finished at 2026-10-14T12:08:30.667313+00:00. These are observations from the local synthetic fixture, not production history. The complete source needed to reproduce that experiment is embedded above.
The report identifies the reference driver with SHA-256:
9c493682aa06cb3600cff866ed6766800d13043f4feaecd8b30acd7b3f7eaf47
The digest identifies the script’s exact bytes, not just its behavior. When copying the example, preserve indentation and save it as plain text. Different line endings or whitespace can change the digest without changing the test outcomes. Retain the source and digest from each actual run rather than reusing this reference value for a modified file.
These timestamps identify an execution; their difference is not a benchmark. The Python 3.13 documentation branch supplies semantic references, while 3.13.5 identifies the executed interpreter. Neither designation claims the latest Python release.
Read a diagnostic PASS correctly
PASS_9_CASES means that the authored diagnostic expectations held. A3 and A4 pass by reproducing the reuse defect. A5 passes by showing that clearing does not repair the entry policy. None accepts decorating the loader with lru_cache.
The driver creates a uniquely named async-cache-... directory beside the script and prints the path to results.json. Each row begins as FAIL before its observations are collected. An ordinary exception raised inside the protected execution block is recorded and re-raised; the finally block attempts to preserve partial results. Directory creation and source hashing occur before that block, and writing the report can itself fail. This is not a guarantee against missing evidence after setup errors, process termination, or storage failure.
Check all nine unique case IDs, not just the overall status. Exception messages are retained, while expect_error asserts their exact types. Some producer counts are asserted without being serialized as separate row fields. A2 records its returned value but does not separately assert that value’s equality; the supplied report contains the expected A tuple representation. Keep source and report together so those distinctions remain inspectable.
Establish the uncached and cached construction baselines
A1 establishes what ordinary repeated construction looks like before introducing the defective wrapper. Two calls to load("A") produce distinct raw coroutine objects. Both are inspected as CORO_CREATED, then awaited sequentially. The resulting values are [("A", 10), ("A", 10)], and the recorded producer-call count is two.
The equal values are intentional. This baseline removes changing source data as an explanation for later differences. It also establishes that creating two operations and receiving equal results does not imply that one operation was reused.
A2 changes the construction boundary. Two calls to the cached wrapper produce the same object, with one miss and one hit before execution. Its captured cache statistics are exactly hits=1, misses=1, maxsize=2, and currsize=1. Its separately captured producer count is zero. One wrapper invocation constructed the coroutine; the other retrieved it.
The first await succeeds, returning the expected A value in the report. Inspection through the second reference then finds the shared object closed. That second variable does not own a fresh operation merely because its name differs. The observed state belongs to the common object.
Read these observations in order. A1 compares two newly created coroutines. A2 compares two references to one cached coroutine. Comparing only the returned tuples would collapse these different ownership arrangements into an apparently identical success story.
Also distinguish wrapper-call counts from producer-call counts. A cache hit describes a wrapper lookup. The calls counter is incremented inside the producer body. Treating both as “requests processed” would erase the interval in A2 where the wrapper has served twice but the producer has executed zero times.
Every coroutine constructed by these successful baseline cases is eventually consumed. A2’s y is an alias, not an additional abandoned coroutine. An unawaited-coroutine warning is therefore unnecessary to prove this defect. The useful evidence is already available before shutdown: identity, creation state, one completed await, and the resulting closed state. These observations can be reviewed without guessing about cleanup timing or relying on a warning that the fixture was not designed to produce.
Follow the first success into the second-await failure
A3 models the particularly misleading sequence: a complete successful call followed by a failing repeated lookup. Its first expression, await cached("A"), returns ("A", 10). Only after that expression completes does the case request the same key again.
The second wrapper call finds the retained coroutine. Awaiting it raises RuntimeError, with the observed message cannot reuse already awaited coroutine. The producer-call count is asserted to remain one, and the recorded hit count is one. The second caller has not initiated another successful or failed producer execution.
Python’s coroutine-object data model documents RuntimeError when a coroutine is awaited more than once. This is established lifecycle behavior, not a newly introduced cache feature or a claim about a recent release.
For diagnosis, retain the first success rather than dismissing it as irrelevant. It establishes that the source value was obtainable and that the body could complete. The defect becomes visible at reuse, after that successful lifecycle has ended. A health check that performs only the first call cannot distinguish this wrapper from a working result cache.
Keep body exceptions separate from reuse exceptions
A4 starts with one synthetic producer failure available. Its first await enters the body and raises ValueError("synthetic load failure"). Its second same-key await raises the coroutine-reuse RuntimeError. The recorded producer count remains one.
Do not describe this as lru_cache directly memoizing the later ValueError. The wrapper previously returned a coroutine normally and retained that object. The exception arose when the caller subsequently drove its execution. Reusing the finished coroutine is a different failure from rerunning the loader.
The distinction is operationally important: the synthetic failure budget has already been consumed. A fresh producer coroutine could try again, but the defective lookup does not construct one. Relabeling the second exception as another source failure would send the investigation toward a producer that never received that attempt. Preserve both exception types and their order.
Show why clearing the cache only hides the next repetition
A5 tests a plausible but incomplete intervention. First, await cached("A") succeeds. Next, the case calls cached.cache_clear(). A subsequent await succeeds again with ("A", 10). Then one more same-key await reproduces the coroutine-reuse error.
The complete sequence matters: successful load, clearing, successful load, failed reuse. The two successes require two producer executions, which the case asserts. The final failure requires no third producer execution. Clearing changed which coroutine was retained; it did not change the kind of object eligible for storage.
The documented purpose of cache clearing and invalidation is to remove retained cache entries. cache_clear does not convert cached return objects into their awaited results.
A5 therefore receives TEMPORARY_RESET_NOT_REPAIR. This decision preserves the useful observation that clearing obtains another first success while rejecting the larger inference that the wrapper now satisfies repeated lookups.
A test that ends immediately after the post-clear success would miss the recurrence. So would a test arrangement that recreates the entire wrapper before every individual lookup. Such arrangements can keep showing first-use behavior while never exercising the same entry twice. For this regression, the entry must survive long enough for its next consumer to encounter it.
Do not recommend clearing on every lookup as a demonstrated cache repair. That would replace the selected reuse contract with repeated loading, and this fixture provides no reason to call that a useful completed-result cache. A reset may belong in a migration after the storage policy changes; it is not the policy change itself.
Keep the report literal. A5 records the two tuples and the final exception, and asserts the total call count. It does not record intermediate cache_info snapshots around clearing. Adding imagined snapshots to an execution summary would make the evidence appear richer than it is. The existing observations already establish the narrower conclusion: the next fresh operation works, and the next reuse of that operation still fails.
Cache completed immutable values at an explicit boundary
CompletedValues.get moves cache admission to the other side of successful completion. It first checks the requested key. On a miss, it awaits a newly created loader coroutine. It then checks the completed result and only afterward assigns self.values[key] = result.
The order is the repair. A coroutine that has merely been created is never installed as the cache entry. A producer exception interrupts the path before assignment. A completed value that fails the admission checks also does not reach that assignment.
The checks require an actual tuple of length two, a matching first element, and an actual integer second element. With the supplied producer and literal string keys, the returned tuples contain immutable string and integer values. Do not generalize the outer tuple check into a deep-immutability validator: the first-element check is equality, and arbitrary replacement loaders or exotic key objects are outside the demonstrated contract.
A6 performs two sequential get("A") calls. Both deliver ("A", 10), the producer count is asserted as one, and the serialized storage-type observation is ["tuple"]. The cache owns a completed value rather than an execution object.
Calling get still creates a coroutine for that individual lookup. The caller awaits that fresh wrapper operation, which returns the retained tuple. It does not await the tuple directly or retrieve a previously consumed get coroutine.
Under the two admitted literal keys, this dictionary needs no eviction policy to establish the illustrated bound. That is a restriction on this example’s domain, not an LRU design or a measured memory claim.
Do not quietly expand the concurrency contract
The producer’s asyncio.sleep(0) suspension point allows the event loop to run other scheduled work, but this driver never creates competing cache consumers. Its successful sequential results do not establish duplicate-work suppression or ownership under cancellation. Those requirements would need a separate design and separate evidence.
The published discussion of a different asyncio lifecycle problem concerns concurrent sibling work. It is neighboring context, not an implementation to import into this repair. Keep this cache’s acceptance boundary at the sequential calls actually exercised.
Propagate a real failure and retry with a fresh coroutine
A cache repair must do more than remove a visible RuntimeError. A7 checks whether the new wrapper preserves a genuine producer failure and leaves a usable path for a subsequent attempt.
The producer begins with one synthetic failure. The first await cache.get("A") raises ValueError("synthetic load failure"). Immediately afterward, the recorded list of stored keys is empty. No successful tuple has been admitted, and there is no finished producer coroutine occupying A’s slot.
The caller then makes an explicit second lookup. Because A remains absent, the wrapper invokes and awaits the loader again. This fresh operation succeeds with ("A", 10). A third lookup delivers the same approved tuple without another load. The case asserts two producer executions across these three lookup attempts.
That sequence distinguishes retry from repetition. The failed attempt and the successful attempt are separate producer executions. The final reuse is reuse of completed data. It does not ask either producer coroutine to run a second time.
There is no broad exception handler in CompletedValues.get that turns failure into a placeholder, an empty tuple, or a default number. The exception-handling code belongs to expect_error, which is a diagnostic helper. Its returned error dictionary goes into the report, not into the completed-value cache. Confusing those two destinations would invert the very contract A7 is checking.
The retry is caller-directed; the wrapper does not contain an automatic retry loop. That matters when writing the public interface description. Promise the observed ability to make a fresh subsequent attempt, not an unimplemented retry strategy.
Keep the scope of failure evidence equally precise. A7 begins with an empty cache and exercises one ordinary loader exception. It does not test a failed refresh while an older value remains available, cancellation, or recovery from a storage failure. The synthetic failure changes the producer’s local counters, but there is no external write to reconcile.
For the declared scenario, the acceptance evidence is the combination of propagated ValueError, empty storage after failure, a successful fresh load, and later completed-value reuse. A successful final tuple alone would not establish that the initial failure was handled honestly.
Specify freshness before calling the cache correct
A8 introduces a separate question without changing the cache implementation. The first lookup returns ("A", 10). The case then changes the synthetic source to 11. A second lookup still returns ("A", 10) because the previously admitted tuple remains stored.
Next, cache.invalidate("A") removes A’s entry. The following lookup loads again and returns ("A", 11). The final producer count is asserted as two: one initial load and one after invalidation. Changing the source alone did not request another load.
This is an explicit policy, not an accidental claim that old data is always acceptable. The example permits reuse until its owner invalidates the key. An application requiring every lookup to observe the latest source state cannot accept this demonstration as evidence of that different requirement.
The invalidation method removes cached data; it does not modify the synthetic source or retroactively replace tuples already delivered to callers. Its effect in A8 is visible at the next lookup after removal. Keep the action and its observation adjacent when reviewing the case.
The database-integration guide’s discussion of caching provides broader context for explicit cache invalidation ownership. The ownership rule here is the one demonstrated by A8, not a database mechanism imported from that guide. This laboratory contains no database, query, or remote consistency mechanism.
Separate value reuse from source freshness
The same tuple can remain a valid reusable Python value while becoming outdated against a changed business source. A6 establishes completed-value reuse. A8 establishes the selected update boundary. Treating A6 alone as a freshness test would leave that boundary unexamined.
Nothing in this cache watches state["source"], records source generations, or decides when a business change requires invalidation. Those decisions remain with the owner. No TTL, periodic refresh, or cross-process propagation is demonstrated.
Preserve A8’s decision string, EXPLICIT_FRESHNESS_POLICY_REQUIRED. Its passing assertions show the stated behavior; acceptance still requires the application to authorize that behavior. When nobody owns invalidation, or the required freshness cannot be expressed using this boundary, the appropriate application decision is HOLD, not an unqualified declaration that caching is now correct.
Use repeated Task awaits only as a comparison
A9 changes the awaitable abstraction, not the completed-value cache. It creates one Task with asyncio.create_task(load("A")), retains that Task, and awaits it twice. Both results are ("A", 10), done() is true, and the producer-call count is asserted as one.
Python’s Task object reference describes a Future-like object that runs a coroutine. In this fixture, create_task wraps the coroutine and schedules its execution. The Future object documentation explicitly permits repeated awaits with the same result. Task inherits Future’s APIs except the methods for externally setting its result or exception.
That explains why A9 differs from A3. The second await observes the successful Task’s result; it does not restart the wrapped raw coroutine. The tuple agrees because the same completed Task is being observed, not because another producer invocation occurred.
The comparison prevents an overbroad diagnosis. “Every awaitable can only be awaited once” would be the wrong lesson. The relevant question is which abstraction the wrapper returns and what lifecycle that object supports. The documented distinction between coroutine objects, Tasks, and Futures explains why their names must not be treated as interchangeable.
However, A9 contains no Task cache. There is no keyed storage, invalidation method, admission rule for failed Tasks, or demonstration involving a cancelled operation. Its decision is therefore COMPARISON_NOT_A_TASK_CACHE_DESIGN, not a second accepted repair.
Notice that the assertions also check the returned values; done() is not used as a substitute for the complete success evidence. Retaining only that Boolean would discard what the callers actually received.
Stop the inference at the demonstrated comparison. A repeatedly awaitable object may be a useful ingredient in a separately designed system, but A9 supplies no evidence for that system’s ownership or freshness contract. The accepted implementation in this article remains the bounded completed-value wrapper tested by A6–A8.
Reconcile the nine cases without hiding exceptions
Read the following matrix together with the full source and the report produced by your own run. “Asserted” means the driver checks the count but does not serialize it as a dedicated field. A2 serializes the count before its first await, not an after-count.
Case | Object or retained entry | Body-call evidence | Result or exception | Decision |
A1 | Two fresh coroutines | 2, logged | ("A", 10) twice | BASELINE |
A2 | One shared raw coroutine | 0 before await, logged | ("A", 10); CORO_CREATED → CORO_CLOSED | COROUTINE_NOT_COMPLETED_VALUE |
A3 | Previously successful coroutine | 1, asserted | ("A", 10), then RuntimeError | REPAIR |
A4 | Previously failed coroutine | 1, logged | ValueError, then RuntimeError | REPAIR |
A5 | Fresh coroutine after clearing | 2, asserted | ("A", 10) twice, then RuntimeError | TEMPORARY_RESET_NOT_REPAIR |
A6 | Completed tuple | 1, asserted | ("A", 10) twice | ACCEPT_BOUNDED |
A7 | No failed entry; successful tuple later | 2, asserted | ValueError, then ("A", 10) twice | ACCEPT_BOUNDED |
A8 | Tuple with explicit invalidation | 2, asserted | ("A", 10), ("A", 10), ("A", 11) | EXPLICIT_FRESHNESS_POLICY_REQUIRED |
A9 | One Task | 1, asserted | ("A", 10) twice; done | COMPARISON_NOT_A_TASK_CACHE_DESIGN |
The JSON report displays value pairs as arrays. That representation is not evidence that the repaired cache stored lists: A6 records the runtime storage type as tuple, and the driver contains the admission and value checks.
Confirm the exact case-name inventory as well as its size and uniqueness. Preserve interpreter identity, platform, source digest, exception messages, and any failed row. An incomplete run, missing report, unexpected exception, or contradictory observation is HOLD until explained. Do not relabel such evidence as a successful reproduction or silently substitute an older passing file.
The matrix does not upgrade known omissions into failures of the bounded demonstration. It keeps acceptance proportional to what was tested, while identifying where a larger application would need additional evidence.
Repair a deployed wrapper without rewriting its history
The following is a proposed migration procedure, not a report that this laboratory deployed or rolled back a service. The fixture establishes a local defect and a bounded alternative; it does not establish what any existing application has already done.
1. Preserve the old evidence. Retain the wrapper source, failing sequence, actual exceptions, and available lifecycle observations before resetting entries. Separate previously successful calls from failed reuses and genuine producer failures.
2. Replace the entry policy. Introduce the reviewed completed-value boundary appropriate to the application’s declared contract, then retire or invalidate obsolete cache entries. Do not merely clear the old wrapper or put lru_cache around the new async get method.
3. Requalify the changed boundary. Rerun the nine-case diagnostic fixture, then test the actual replacement wrapper and its real caller interface separately. Record new source identities for changed code instead of attributing new behavior to the original driver’s digest.
The separate work of qualifying the wider API boundary belongs to integration review. The local tuple fixture is not evidence that a deployed interface satisfies its wider obligations.
A repair also does not retroactively answer failed requests or undo external effects. Preserve that distinction in a change record. The current experiment has no external effects to reconcile, so it cannot demonstrate a recovery procedure for them.
Choose repair, bounded acceptance or hold
Use REPAIR when repeated lookups return a consumed raw coroutine. Use ACCEPT_BOUNDED for the demonstrated sequential completed-value policy, with its admitted inputs and authorized explicit invalidation rule.
Use HOLD when the application requires evidence this fixture does not provide: concurrent consumers, cancellation semantics, broader admission guarantees, or a different freshness obligation. A hold on those wider requirements does not invalidate the local diagnosis. It prevents the local diagnosis from being promoted into an unsupported deployment approval.
Assign the cache contract and its regression owner
Assign responsibilities to interfaces rather than allowing the cache decorator to become the explanation for every result. The producer owns its completed return format and genuine errors. The wrapper owner owns what enters storage, when entries may be reused, and who can invalidate them. QA owns the evidence manifest and checks that the report actually supports the proposed decision.
For the producer owner, the review question is concrete: does a successful load return the approved key and integer, and does a genuine failure remain distinguishable from data? A change to the loader’s return shape or failure behavior requires a corresponding review of admission checks and expected outcomes.
For the wrapper owner, document the sequential assumption and the explicit freshness rule next to the interface. Do not leave callers to infer either from the presence of an async method. Keep storage access disciplined: the sample exposes values for inspection, but the acceptance argument assumes callers do not insert arbitrary entries behind the wrapper’s checks.
For QA, retain more than a screenshot of PASS_9_CASES. Preserve the exact source, command, environment, complete ID inventory, and report. The broader practice of repeatable regression evidence supports that ownership model; this fixture supplies the specific observations to retain.
Reopen the acceptance gate when entry type, key domain, loader behavior, mutability, concurrency, or cache lifetime changes. A new requirement should produce an explicit evidence gap, not an assumption that an earlier passing suite covers it.
Document coverage limits alongside the manifest. B is admitted but not exercised by these nine cases. Not every admission-guard branch has been fault-injected. Reporting and storage-failure paths have not all been deliberately broken. Those omissions are visible boundaries, not implied successful tests.
Finally, distinguish the demonstration from the implementation under review. A fixture that constructs its own correct wrapper can keep passing while an application still uses the defective decorator. The regression owner must connect the reviewed application code to its own checks before treating a local demonstration as release evidence. Ownership is complete only when the decision, tested artifact, and actual calling boundary agree.
Strengthen backend design through structured practice
The scoped decision is clear: repair cached raw-coroutine reuse, accept only the demonstrated sequential completed-value policy, and hold broader claims until their missing evidence is supplied. Keep failure propagation and invalidation in the acceptance record; neither follows from a cache hit alone.
For structured practice in general backend design and testing, review Refonte Learning’s Backend Development program. Its published competencies include Node.js and Express, RESTful APIs, and testing and debugging backend services. These are general foundations for reasoning about interfaces and verifying behavior.
The published outline does not establish that Python, asyncio, or this exact coroutine-cache laboratory is taught. Consult the current syllabus for specialist coverage rather than inferring it from the program’s broader relevance. The practical lesson remains available here: identify the object being reused, verify what each caller receives, and authorize freshness explicitly before accepting a cache contract.
