Back to Ideas 5 min read

The Bug That Resolved Successfully

E
EkoHacks Team
·
The Bug That Resolved Successfully

Every so often you meet a bug that does you the courtesy of throwing an error. This is not one of those. This one says it is ready, resolves cleanly, logs nothing, and quietly drops every document on the floor.

Here is the shape of it in EkoLite, our small reactive sync layer. You subscribe to a publication, the server streams the matching documents into a local store, a ready promise resolves, and from then on the store stays live as data changes. When you call stop(), late messages should stop touching your store. Simple enough.

So you write the most natural line a developer ever writes. You name the publication for what it means:

const store = manager.store('files');
const handle = manager.subscribe('recentFiles');

await handle.ready;        // resolves, no error
store.getById('f1');       // undefined

The server did its job. It found the document and sent it, stamped with the collection it actually lives in, files. The ready resolved, so your app believes the subscription is healthy. And yet the store is empty. Nothing complained. That silence is the whole bug.

Where did the document go?

To route an incoming document into the right store, the client has to answer one question: which collection does this belong to, and is anyone still subscribed to it? The original code answered it with a guess. It took the publication name and split it on the first dot:

name.split('.')[0]   // 'recentFiles' -> 'recentFiles'

The publication is called recentFiles. The collection is files. The guess says recentFiles. They do not match, so the gate decides nothing is live for files, and the document is dropped before it ever reaches the store.

The bug only hides when the name happens to start with the collection. Call your publication files.recent and it works, by luck. Call it anything a person would actually choose, recentFiles, archivedThisWeek, myInbox, and it fails in silence. The name is for people. The collection is the server's business. The moment those two ideas drift apart, the data goes missing and the app smiles back at you.

A question worth sitting with

So we asked the question underneath the bug: where should the collection actually come from?

Not the name. We just watched why. The honest answer is the server, because the server is the only thing that knows where a document lives. So we went looking for the place where the server tells the client, and found that it never did.

Look at the messages on the wire. A data message carries the collection but no subscription id:

{ type: 'added', collection: 'files', id: 'f1', fields: { ... } }

A ready carries the subscription id but no collection:

{ type: 'ready', id: 'sub-123' }

Neither message, on its own, ties a subscription to its collection. The client had no way to know the truth, so it guessed. The guess was not the disease. It was a symptom of a protocol that quietly forgot to say the one thing that mattered.

The fix is one field

We added the collection to ready:

{ type: 'ready', id: 'sub-123', collection: 'files' }

The server had this in hand the whole time. Now it says so out loud. The client stops guessing, binds the subscription to the collection the server names, and routes the documents it was holding into the right store. The name goes back to being what it always should have been, a label for humans.

There is a small timing wrinkle worth naming. The server sends the initial documents before it sends ready, so for a brief moment the client is holding documents it cannot place yet. It parks them, and the moment ready names the collection, it drains them into the store. The buffer solves the timing. The collection on ready solves the truth. Two different problems, two different answers.

With the collection known for certain, the job we set out to do, gating late messages after stop(), falls out for free. The gate asks "is this collection still live?" and that question finally has an honest answer, so a stray changed arriving after you have stopped lands nowhere.

The wrong turn we left in

Here is the wrong turn, kept in on purpose. The first version we shipped made the new field optional, and kept a fallback: if ready did not carry a collection, infer it from whichever document we happened to be holding in the buffer. Every test passed. It looked robust.

It was not robust. It was a story we were telling ourselves. The only thing that ever exercised that fallback was the tests, written in just the right order to keep it happy. No real server ever sent a ready without a collection, because we had just changed the one server we have to always send it. A fallback that only your own tests keep alive is not a safety net. It is dead weight in a high visibility jacket.

So we made the field required, enforced it at the point where messages are checked coming off the wire, and deleted the fallback. If a ready turns up without a collection now, it is turned away at the door rather than papered over. The type makes the gap impossible to reopen, by anyone, later, on a tired afternoon.

The move you can reproduce

If you would rather feel this one than read about it, the move is short. Pin the symptom with a failing test before you touch a line of the fix:

it('routes data when the publication name is not the collection name', async () => {
  const store = manager.store('files');
  const handle = manager.subscribe('recentFiles');   // named for meaning, not storage

  server.send({ type: 'added', collection: 'files', id: '1', fields: { name: 'a.bam' } });
  server.send({ type: 'ready', id: subId, collection: 'files' });
  await handle.ready;

  expect(store.getById('1')).toEqual({ _id: '1', name: 'a.bam' });
});

Run it against a client that guesses from the name and watch it go red. Then let the server name the collection, and watch it go green. The red is the lesson. The green is just the receipt.

Why we work this way

None of this was hard once we stopped guessing. The work was in refusing to read a green test suite as proof, and asking where the truth actually lived. That is most of the work. We let the data tell us what is true, we write the test that fails for the right reason first, and we stay suspicious of any code that only our own tests seem to need.

A subscription should never have to guess where its own data lives. Now it does not.

E

Written by

EkoHacks Team

More from Ideas

·6 min read

What the Nullable Gave Back

One file, seven behaviours held fixed, the database swapped for a Nullable: about 180 times less time inside the tests, and coverage flat to two decimals.

E
EkoHacks Team
·6 min read

Twenty Six More Tests, Four Fewer Behaviours

Removing the mocks grew the suite from 44 tests to 70 and quietly deleted four behaviours, every one of them a failure path. Test count is not coverage.

E
EkoHacks Team
·6 min read

The Best Coverage Number in the Room

Same commit, same spec, same test count. The mocked suite ran 5.6 times faster, covered 3.5 fewer points of real code, and posted the best branch coverage.

E
EkoHacks Team