8Examples / work
Case study · Finning International

Three minutes, eighty machines, one report.

An end-to-end Playwright suite that took hours on one machine, rebuilt as an eighty-way fanout on Azure. Three minutes wall clock, one merged report, and every piece of the infrastructure declared in Terraform.

The Suite

The tests were right. Nobody could afford to wait for them.

Finning International sells and services Caterpillar equipment across three continents, and the portal its customers depend on had a real end-to-end suite: Playwright driving the actual application, the tests you want standing between a change and production. The problem was arithmetic. Run serially on one machine, the suite took hours, so it ran at night. A regression introduced at 10 a.m. was discovered the next morning, by whoever triaged the nightly run, long after everyone had stopped thinking about the change that caused it.

Slow suites do quiet damage: they make tests expensive, and people ration what’s expensive. Every new scenario made the night longer, so scenarios didn’t get written. Coverage sat at roughly a fifth of the use cases that mattered, not because anyone decided that was enough, but because the wall clock was doing the deciding.

The First Move

Playwright already knew how to shard. The hard part was handing out numbers.

Playwright will happily run --shard=3/80 and execute one eightieth of the suite. On a single machine that’s a party trick. The real question is coordination: how do eighty machines, all booting in the same second, each end up with a different shard number, no duplicates, no gaps, no coordinator database, no machine waiting on another?

The 8Examples habit applies to infrastructure as much as to products: walk the timeline concretely before building. What happens in the first second, when all eighty replicas ask at once? What happens when a claim crashes halfway? We drew the run from top to bottom until every arrow had an answer:

container app jobparallelism = N · one JOB_IDreplica 1playwright test--shard=1/Nreplica 2playwright test--shard=2/Nreplica 3playwright test--shard=3/Nreplica 4playwright test--shard=4/Nreplica Nplaywright test--shard=N/Nfn-shard-helperGET /api/getShard→ { yourShard: k }shards.json60 s lease + ETagclaimevery replica claims here firstreport-1-of-N.zipreport-2-of-N.zipreport-3-of-N.zipreport-4-of-N.zipreport-N-of-N.zipuploaded by an EXIT trap · fires even when tests failmerge stepplaywright merge-reportsone HTML reportas if one machine ran itexit 0 only if all N reports exist and the merge succeeds
orchestrationrunnerfunctionblob storagemerged report
One run, top to bottom. The job fans out N identical replicas; each claims a shard number from the function, runs its slice, and drops a zipped report in blob storage on the way out. At the bottom, the merge step folds all N reports into one and refuses to succeed unless every shard is accounted for.

Notice how little is invented here. The fanout is a stock Azure Container App Job with parallelism = N. The slicing is Playwright’s own. The only thing we had to design was the small box on the left: a function that hands each replica a number, exactly once, under contention.

The Shape

A container job, a small function, and a blob everyone fights over.

The whole coordination state is one blob: shards.json, a list of the shard numbers nobody has claimed yet. The Azure Function in front of it does one thing: take a lease on the blob, read the list and its ETag, remove one number, write the list back conditionally, release the lease, and return { yourShard: k }. No queue, no database, no leader election. A blob with a lease is already a lock, and an ETag is already a version check; Azure Storage was holding the primitives the whole time.

The lease matters for the bad day, not the good one. It expires on its own after sixty seconds, so a claim that dies mid-write can’t jam the line; the next replica walks over the corpse and takes the lock.

3 min
full suite, wall clock
80
parallel shards
80%
coverage, up from 20%
60 s
blob lease, self-releasing
0
resources built by hand
The Claim

Eighty replicas, one list, no duplicates, no gaps.

This is the moment we walked through most carefully on paper, because it’s the moment that would have been a production incident if we’d walked through it in production instead. Eighty claims arrive essentially at once. One invocation gets the lease; the other seventy-nine get 409 and back off with randomized exponential delays, so they don’t return as a synchronized stampede. And underneath the lease sits a second, paranoid layer: the write back to the blob carries If-Match with the ETag from the read. The lease is the lock; the ETag is proof the lock worked.

replica 7replica 12fn-shard-helper(one invocation each)shards.json(blob · lease + ETag)GET /api/getShard?jobId&shardTotalbasic auth · the runner has no loginGET /api/getSharda heartbeat lateracquire lease · 60 sgranted, to replica 7's invocationacquire lease409 · already heldbackoff · randomized, exponentialdownloadremaining: [3, 4, …, N] · ETag 0x8DDtake 3 off the listupload [4, …, N] · If-Match: 0x8DD201 · lease released{ yourShard: 3 }playwright test --shard=3/Nreplica 12’s invocation wakes, takes the lease, and the same loop hands it shard 4if two invocations ever slip past the lease, the If-Match fails with 412 and the loser simply reads again
Two replicas race. The lease serializes them; the backoff keeps the losers polite; the conditional upload is the safety net. If both layers somehow fail, the write returns 412 and the loser re-reads; the list can be claimed slowly, but it can never be claimed twice.
The Merge

Every shard reports in, even the ones that fail.

A sharded suite has a failure mode a serial suite doesn’t: a shard that silently vanishes takes its failures with it, and the report that remains looks green. So the runner script uploads its zipped blob report from an EXIT trap, which fires whether the tests passed, failed, or the process was killed. Failing tests still produce a report; that’s the point of them.

The merge step then downloads all N archives and runs playwright merge-reports into a single HTML report, indistinguishable from one machine having run everything. It exits zero only if all N reports exist and the merge succeeds. A missing shard fails the build loudly; nothing partial ever ships as a pass.

Nothing Is a Pet

The whole environment is a file you can apply.

Every resource in the picture, the storage account, the container registry, the Container App environment and job, the Linux function app, Log Analytics and Application Insights wired through it, is declared in Terraform. Nothing was clicked into existence in the portal, which means nothing has to be remembered. The shard count itself is a variable; parallelism = 80 is a line in a file, and capacity planning is editing it.

That’s also the disaster recovery story, and it’s deliberately boring. The system holds no precious state; the blobs live one run at a time. If a region or a subscription disappears, recovery is terraform apply somewhere else and a pushed image: the same command that created the environment the first time, not a binder of steps nobody has rehearsed.

laptop · docker compose./scripts/run-sharded-demo.shazuriteblob storage, emulatedfn-shard-helperfunctions runtime imagemock portalthe system under testrunner × N--scale runner=Nci · github actionssharded-e2e.yml · every pushazuritesame containerfn-shard-helpersame imagemock portalsame compose filerunner × Nsame scriptazure · terraform applythe whole stack, declaredstorage accountshards.json · reportsfunction appfn-shard-helpercontainer app jobparallelism = 80acr · app insights · log analyticsone architecture · the only thing that changes is who provides the storage
The same architecture in three places. On a laptop and in CI, Docker Compose runs the identical stack with Azurite standing in for the storage account and a mock portal as the system under test; in Azure, Terraform provisions the real thing. The infrastructure is exercised on every push, so the recovery path is tested continuously, by accident.

The local stack isn’t a toy version, it’s the same images, the same function, the same claim protocol, scaled with --scale runner=N. GitHub Actions runs the entire architecture on every push. When your disaster recovery drill and your CI run are the same artifact, the drill happens dozens of times a day.

The Payoff

When the suite costs three minutes, nobody rations tests.

The headline number is the wall clock: hours became three minutes. But the number that changed the engineering culture is the other one. Use-case coverage went from roughly 20% to 80%, and nobody mandated it. The price of a test collapsed; a new scenario adds an eightieth of its runtime to the wall clock, which rounds to nothing, so people wrote the tests they had always wanted to write.

And the suite moved. A run that costs three minutes doesn’t have to wait for nightfall; it runs on every push, and failures arrive while the change is still open in the editor, attributed to one commit instead of a day’s worth. The morning triage meeting quietly stopped existing.

The Point

The speed was never the feature. The permission was.

Nothing in this system is exotic: a stock job runner, a seventy-line function, a blob, and Playwright’s own sharding. The work was walking the timeline first, eighty machines, second by second, until the race conditions had been found on paper instead of in production. What Finning bought wasn’t a faster suite. It was the ability to say yes, add the test without doing arithmetic about the night.