Capstone: A Practice Report Tool · practice
Freshness Needs One Injected Time
The cache now says when it was fetched. Deciding whether that is recent enough sounds like a one-line comparison, and it nearly is. The interesting part is where the current time comes from.
The obvious version, and why it hurts
Here is what most people write first:
def cache_is_stale(fetched_at, max_age):
return int(time.time()) - fetched_at > max_age
It works. Now try to test it.
You want to check that a cache which is one second past its limit counts as stale. So you need time.time() to return a particular
You could make the test sleep. Now your suite is slower on purpose and still not exact.
The
Ask for the time instead
Make the current time a
def cache_is_stale(fetched_at, now_epoch, max_age):
...
That is the entire technique. It has a name, dependency injection, which sounds much grander than what it is: pass the thing in rather than fetching it yourself.
Now a test says exactly what it means. Cache written at 100, it is now 110, the limit is 10 seconds. Is that stale? No: 110 minus 100 is 10, which is not more than 10. What about at 111? Yes, by one second. Both cases run instantly and give the same answer forever.
Where the clock actually gets read
Something has to call time.time() eventually. The rule is that it happens once, high up, at the layer that orchestrates:
if now_epoch is None:
now_epoch = int(time.time())
The orchestrating function samples the clock once when nobody supplied a time, and passes that single value down through everything else. Tests supply now_epoch and the clock is never touched.
Sampling once matters more than it looks. If three different functions each call time.time(), they get three slightly different answers, and a program can decide a cache is fresh in one place and stale in another during the same run. One read, one value, passed down.
Decide the boundary out loud
“Fresh for an hour” has an edge case, and vague code makes a silent choice about it. Say it explicitly instead:
age less than
max_age: freshage exactly equal to
max_age: freshage one second more than
max_age: stale
So the comparison is age > max_age, not >=. This is the same > versus >= decision you debugged in Chapter 9, which should tell you something about how often it comes up.
A timestamp from the future is not fresh
One case remains, and the tempting answer is wrong.
What if fetched_at is later than now_epoch? The age is negative. Negative is certainly less than max_age, so a naive comparison calls it fresh, and a cache with an invalid timestamp can be used until that future timestamp plus max_age has passed.
A future timestamp is not fresh data. It is broken data: a clock changed, a file was edited, something went wrong. Raise
All three values must be genuine non-negative integers, with the type(value) is int check from the previous lesson, because True is still an integer as far as isinstance is concerned.
Your turn
Implement cache_is_stale(fetched_at, now_epoch, max_age) in practice_report.py.
Return False for fresh, True for stale, and raise ValueError for any value that is not a non-negative integer, or when fetched_at is in the future.
Do not call time.time() inside it. Do not look at file modification times. The three numbers you were given are the whole input.
Why does cache_is_stale take now_epoch as a parameter instead of calling time.time()?
A cache says it was fetched 60 seconds from now. What should cache_is_stale do?
Why does the orchestration layer read the clock once and pass the value down?
Task
Add the exact injected freshness boundary and cover epoch zero, exact age, one-second stale, future time, and