Let's talk
engineering

Six quizzes created 0.6 seconds apart, and the child was served the wrong one

A parent accepted seven AI-generated questions onto a lesson and published it. The child opened that lesson and was given a quiz with one question in it.

The data explained it immediately. That lesson had six quizzes attached to it, created within six-tenths of a second of each other, holding one question each.

Two entirely separate bugs, both individually survivable, combined into a child being served content that nobody had ever created.

The first bug: a stale value captured in a loop

Adding the accepted questions ran through a helper whose job is to make sure a quiz exists before adding a question to it. Create one if there is none, otherwise use the existing one.

The helper read the quiz identifier from component state. State, in this framework, is captured by the closure of the render in which the function was defined. Within a single render that value never changes, no matter how many times you set it.

So the loop ran seven times. Each iteration read the same captured value — empty — decided there was no quiz, and created one. Six quizzes were created and one question was added to each. A seventh call landed on one of them.

Every iteration was correct in isolation. The bug is that “otherwise use the existing one” refers to a fact that changes during the loop, and the loop was reading a snapshot taken before it started.

Any ensure-or-create written against a value that a previous iteration might have set needs to read that value from somewhere that updates synchronously. A mutable reference works. A local variable in the loop works. Component state does not, because state is deliberately a snapshot and that is the entire point of it.

The general form is broader than any one framework. If you have a get-or-create inside a loop and the “get” side is served by a cache, a memo, a captured variable or anything else that is refreshed between iterations rather than during them, you have written a loop that creates N rows.

The second bug: a query with no ordering

On its own, six quizzes on a lesson would have been messy and visible. What made it serve wrong content was that the child’s reader takes the first quiz from a list, and that list came from a query with no ordering clause at all.

With one quiz, an unordered query returns the right answer every time, forever. There is no way to notice. The defect only becomes real the moment the collection has two members, at which point the answer is whatever the storage engine finds convenient, which can change after a rewrite, a vacuum, an index change or a restore.

An unordered query returning one row is a decision nobody made. It is not a bug that was introduced; it is a bug that was always there and had no way to manifest.

After finding it, I checked every edge in the content hierarchy — the path from a subject down through lessons and quizzes to questions — for the same problem. Exactly one edge was broken: the one in the middle, the only one with neither a stored sort order nor an explicit ordering in its query. That is worth doing after any bug of this class. The mistake is rarely unique; it is just that only one instance had a collection big enough to show it.

The one still unfixed, and why the fix is a hack

A related ordering problem was found in the child’s lesson list, and I want to record it because the fix is bad and I know it is bad.

Lessons have no sort order column at all. The device orders them by the time they were last updated, newest first. Which means a child was being taught three-digit arithmetic before single-digit arithmetic, because that is the order the rows had been written in, and a later batch of lessons came out scrambled for the same reason.

The stopgap was to stagger the update timestamps so the sequence comes out right. It works today and it is not a fix. Any later edit to a lesson bumps its timestamp and jumps it back to the front of the child’s list, so the ordering silently breaks the first time somebody corrects a typo.

Ordering by a timestamp that something else is allowed to change is ordering by a side effect. The correct fix is an explicit sort order column, which is a schema change and was not that day’s work. Writing down why the stopgap is wrong, in the same place as the stopgap, is the least I owed whoever picks it up.

What the multiplicity broke underneath

Turning one quiz per lesson into several forced two decisions that had been hiding in a constant.

Points were a flat value per quiz. With one quiz per lesson that reads as the price of the lesson. With six, a lesson’s payout multiplies by however many quizzes happen to hang off it, so a parent splitting one long quiz into three has tripled what it pays without ever seeing a number. Each quiz now carries its own price, chosen when it is created.

And the progress record was keyed on the child and the lesson. That key cannot distinguish one quiz finished out of six from all six finished, so a lesson’s progress became meaningless the moment a second quiz existed. Progress is now keyed on the quiz, with the lesson kept as an indexed non-key column so the roll-up still works.

Both of those had been correct, invisible and unremarkable while the collection had one element.

The rule

Cardinality changes are not additive. Going from one to many does not add a case; it invalidates every assumption that a single element let you avoid stating — which one, in what order, at what price, counted how.

The practical version, and the one I would put in a review checklist: when a relationship becomes one-to-many, go and find every place that takes the first element, every query without an ordering, and every constant that was standing in for a per-item value. All three were present here, and all three had been correct the day before.

Working on something like this?

We build this kind of software, and we staff the teams that do.

Get in touch