Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
The Object Model  ›  Capstone Project

Capstone — The Inspector

40 minIdentity, freezing and reachability together: report what a loaded batch shares, repeats and keeps alive
You're building a piece ofQuarry — the inspector
This piece — inspect_batch(): Loads a batch and reports its shared fields, its duplicate rows, and what stays alive after the batch is dropped.
Scenario Before Quarry's loader is trusted with an overnight run, the inspector reports which rows secretly share their tags, which rows are repeats, and whether a finished batch is actually freed.
Your task
Write inspect_batch(spec). Load the batch with the given load_batch, then return {"rows", "shared", "duplicates", "alive_after_drop", "alive_after_collect"}: groups of "<row>.<field>" labels whose list/dict values are one object (each group sorted, groups sorted); groups of 2+ row indices with equal fields (first-seen order); and how many rows plus the source are still alive after the batch is dropped with the collector off, then after gc.collect(). Example (the orders.csv spec) → {"rows": 3, "shared": [["0.tags", "2.tags"]], "duplicates": [], "alive_after_drop": 4, "alive_after_collect": 0}.

Capstone — The Inspector

In short: the inspector runs Quarry's real loader and reports three things about a batch without fixing any of them — which fields secretly share one mutable object (lesson 01's grouping by identity), which rows are exact duplicates once frozen (lesson 02's freeze), and how many rows survive being dropped, before and after gc.collect() (lesson 03's weak probes). A report you can trust comes before a fix you can trust — and the loader below has two bugs that never raise a single error.

Row 0 and row 2 both pointing at one shared tags list while row 1 has its own; a source and three rows pointing at each other in a cycle, with a note that dropping "batch" still leaves an arrow into every box

The loader's silent bug

A batch spec names a source, the raw rows, and defaults for any field a row is missing:

python

Row 0 got a tag, and so did row 2 — both took the default, and "the default" is one list. Row 1 brought its own list, so it's unaffected. This is the mutable default argument from lesson 02 and [[0] * 3] * 3 from lesson 01, in one more disguise: one object, several names, a mutation.

What does this print?
defaults = {"tags": []}
rows = [{"id": i} for i in range(3)]
for r in rows:
    r.setdefault("tags", defaults["tags"])
rows[0]["tags"].append("vip")
print(rows[2]["tags"], defaults["tags"])

Which rows repeat

Freezing each row's fields (lesson 02) turns "are these rows duplicates?" into a set lookup. It compares values the way == does, which includes one quirk worth re-meeting here:

What does this print?
def freeze(value):
    if isinstance(value, list):
        return tuple(freeze(v) for v in value)
    if isinstance(value, dict):
        return frozenset((k, freeze(v)) for k, v in value.items())
    return value

keys = {freeze({"ok": 1}), freeze({"ok": True}), freeze({"ok": 1.0})}
print(len(keys))

What survives the drop

Lesson 03's probes measure what's still alive after the batch is dropped. A healthy loader gives 0 right after the drop. Quarry's gives rows + 1, because every object in the batch is part of a row-source cycle:

Drawing diagram…

Take away the batch bubble and every box still has an arrow coming into it, which is why counting frees none of them. Step through the drop and watch the two counts:

step through it
1import gc
2import weakref
3class Source:
4 def __init__(self, name):
5 self.name = name
6 self.rows = []
7class Row:
8 def __init__(self, source, fields):
9 self.source = source
10 self.fields = fields
11 source.rows.append(self)
12gc.disable()
13src = Source("orders.csv")
14batch = [Row(src, {"id": 1}), Row(src, {"id": 2})]
15probes = [weakref.ref(r) for r in batch] + [weakref.ref(src)]
16del src, batch
17after_drop = sum(1 for p in probes if p() is not None)
18gc.collect()
19after_collect = sum(1 for p in probes if p() is not None)
20gc.enable()

The measurement itself has a trap: any name still pointing at a row keeps it alive, including a leftover loop variable:

broken — fix it

None of these rows are in a cycle, so the count after dropping batch should be 0. Run it, work out which row survived and why, then fix the drop.

The reliable fix is structural: do the loading and inspecting inside a helper that returns only the report and the probes. When the helper returns, its whole frame — the batch, every loop variable, every temporary — goes with it.

What the report is for

A report doesn't fix anything, but it names the fix precisely:

  • Shared fields → copy the default per row. fields.setdefault(key, copy.deepcopy(value)) — lesson 01's "copy as deep as what you're about to mutate."
  • Rows alive after the drop → give the cycle an owner. Rows hold their source strongly; the source tracks its rows weakly, with a weakref.WeakSet. Lesson 03's rule: strong references run in the direction of ownership.
  • Duplicates → dedupe with frozen keys, lesson 02, before rows go anywhere else.

Quick practice

python

Calling probe_all and letting it return is enough — nothing outside keeps objs alive, so every row is already gone by the time the function hands the probes back.


🎯 Your turn

The starter gives you Source, Row, load_batch and freeze, exactly as above. Write inspect_batch(spec), returning:

  • "rows" — how many rows were loaded
  • "shared" — groups of "<row>.<field>" labels whose values are the same list/dict object (sorted labels, sorted groups)
  • "duplicates" — groups of row indices whose fields are equal (two or more, first-seen order)
  • "alive_after_drop" — how many rows + source survive dropping the batch, with the collector off
  • "alive_after_collect" — the same count after gc.collect()

For the orders.csv spec above: {"rows": 3, "shared": [["0.tags", "2.tags"]], "duplicates": [], "alive_after_drop": 4, "alive_after_collect": 0}.

Hint: a helper look(spec) loads the batch, builds the report and the probes, and returns just those two — its own frame going away is what keeps the measurement honest. inspect_batch disables the collector, calls it, counts, collects, counts again, and re-enables the collector in a finally.

Press ▶ Run, then ✓ Check. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upA default of its own

Write load_fixed(spec), a loader that gives every row its OWN copy of each default it takes, instead of the shared object. distinct_defaults(n) (given) loads n rows that all take the defaults tags: [] and meta: {}, and returns [distinct tag lists, distinct meta dicts], counted by identity.

object-modeldeep-copy
DrillWhich rows repeat

Write duplicate_groups(rows). Each row is a JSON dict, possibly nested. Return the groups of row indices whose rows are equal, keeping only groups of two or more, in the order each group first appears.

mutabilityhashable
BuildWhat the batch shares

Write shared_labels(spec). Load the batch with the given load_batch, then group the labels "<row>.<field>" by the identity of each field's value, counting only list and dict values. Return the groups of two or more labels, each group sorted, and the groups sorted.

object-modelidentityaliasing-and-copying
BossBreak the row-source cycle

Change Source and Row so that there's no cycle: the rows keep a strong reference to their source, and the source tracks its rows WEAKLY, so it can still say how many live rows it has. drop_report(n) (given) builds a source with n rows, records how many rows the source sees, drops everything with the collector off, and counts what's still alive.

memory-managementweakrefreference-cycle
CapstoneThe repaired loader passes inspection

Write load_safe(spec) and fix the model so that the inspector's report comes back clean. Every row gets its own copy of each default, and the source tracks its rows weakly while the rows own the source. safe_report(spec) (given) returns rows, shared, source_sees (how many rows the source can see) and alive_after_drop, which must be 0.

object-modelmemory-managementdeep-copyweakref
ExplainFour alive, then zero: is that a leak?

The inspector reports alive_after_drop: 4 and alive_after_collect: 0 for a three-row batch. A teammate says: "Zero after collect, so there's no leak. Ship it." Are they right? Explain what the two numbers mean and what it would cost Quarry to leave this as it is.

inspect_batch.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
inspect_batch(spec) → dictLoad a batch and report its shared fields, its duplicate rows, and what stays alive after it is dropped.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.