Two measurements, two changes. The N+1 benchmark says the library needs a
batched read; tracemalloc says the identity map needs a limit. Both are
yours to write.
The checkpoint
Day 6 checkpoint: get_many and a bounded cache
models.py has two NotImplementedErrors: _remember, which must evict, and
get_many, which must fetch what it doesn’t already have in one query. The
tests count queries — there is not a single timing assertion in the file.
Hint 1: the idea
Two halves of one promise: bounded memory and counted queries. _remember is
this lesson’s LRU in a new setting — store, mark as most recent, evict the
oldest while over cache_size. get_many serves what the identity map already
holds and fetches everything else in one where id in (...) query, then
answers in the order the caller asked.
Hint 2: a first step
Write _remember first, following its TODO — it’s the LRU put — and run: the
cache tests should pass. For get_many, de-duplicate the ids, split them into
cached and missing, and build one ? per missing id with
", ".join("?" for _ in missing).
Show one solution
def _remember(self) -> None:
"""Put this object in its model's identity map, evicting the oldest."""
assert self.id is not None
model = type(self)
cache = model._instances
cache[self.id] = self
cache.move_to_end(self.id)
while len(cache) > model.cache_size:
cache.popitem(last=False) # drop the least recently used
@classmethod
def get_many(cls, connection: Connection, ids: Sequence[int]) -> dict[int, Self]:
wanted = list(dict.fromkeys(ids)) # de-duplicate, keep order
found: dict[int, Self] = {}
missing = []
for id in wanted:
cached = cls._instances.get(id)
if cached is None:
missing.append(id)
else:
cls._instances.move_to_end(id)
found[id] = cast(Self, cached)
if missing:
marks = ", ".join("?" for _ in missing)
rows = connection.execute(
f"select * from {cls.table} where id in ({marks})", missing
).fetchall()
for row in rows:
obj = cls(**{key: row[key] for key in row.keys()})
obj._remember()
found[cast(int, obj.id)] = obj
return {id: found[id] for id in wanted if id in found}Two details the tests force and the code doesn’t advertise.
get_many routes every row through cls(**...) and _remember() — the same
path get() uses. It would be a little faster to skip both, and that is
exactly the fast path from Day 3’s bug hunt: skip the constructor and you skip
the validation, so a text value in an integer column reaches the caller.
Batching must not become a second, less careful way in.
while len(cache) > model.cache_size reads model.cache_size, not
Model.cache_size, so a model can set its own:
class Post(Model, table="post"):
cache_size = 1_000Class attributes resolved through type(self) give you per-model configuration
for free — the mechanism from Day 1, used deliberately this time.
Bug hunt: the leak that has been there all week
This version is the library as it stood before today: _remember without the
eviction.
Reproduce. Three failures, all in CacheSize:
AssertionError: 200 not less than or equal to 10
The test set cache_size to 10 and saved 200 posts. The cache holds all 200.
Inspect. There is nothing to step through: _remember inserts and never
removes. The interesting part is why nobody noticed for five days — every
other test in the suite passes, the library is correct, and the benchmark is
faster with a bigger cache. A leak makes the happy path look good.
Narrow. The reference count tells the story. Model._instances is a class
attribute, alive as long as the class, and it holds a strong reference to every
object ever loaded. Those objects can never be collected, no matter what the
caller does with them.
Pin. test_the_cache_does_not_grow_without_limit and its two neighbours.
Note what they assert — len(Post._instances), not megabytes. Memory
assertions are noisy; entry counts are exact, and the count is what causes the
megabytes.
Fix. Evict the least recently used entry once the cache is over its size, and mark an entry as used when it’s read.
Say what the fix cost
With a cap, two get() calls for the same row no longer always return the same
object. That is a weakened guarantee, and code that relies on is will fail
rarely and mysteriously. This is the sort of change that belongs in a
changelog, in the class docstring, and in tomorrow’s design write-up.
Where Day 6 leaves the library
Working on your own machine? Download the Day 6 files, then:
python benchmark.py
python -m cProfile -s tottime benchmark.py
The first prints the 1.0x and 77x numbers from this morning’s lesson on your hardware. The second shows where the time goes when there’s no latency to hide behind.
Optional: what the interpreter actually does
dis.dis(Model.get) prints the bytecode. It’s worth looking at once —
attribute lookups become LOAD_ATTR, the f-string becomes FORMAT_VALUE and
BUILD_STRING — because it makes concrete why an attribute access is not free.
It is almost never worth optimising at that level: the 77x win today came from
removing round trips, not instructions.
What you learned
- Batch reads through the same construction path as single reads, or batching becomes a way to skip validation.
type(self).cache_sizegives per-model configuration through ordinary attribute lookup.- An unbounded cache passes every functional test; only a test that asserts on its size catches it.
- Every bound you add weakens a guarantee somewhere — write that down.
Tomorrow: finish the library, test the week’s edge cases, benchmark it, and write the design document that says what all of these trade-offs were.