Skip to content
Mohamed Naser
ع

MySQL Under Load in a Sharia-Compliant Pipeline

By Mohamed Naser Technical Lead, Lynk

The first batch test we ever ran froze three of 31 orders for over twenty minutes; the ten-order retest still lost four. Eighteen months later the same platform ran 5,200 orders end to end at 99.94%. Ten case files on what MySQL did under load, and what it took to keep the pipeline compliant and moving.

MySQL Under Load in a Sharia-Compliant Pipeline
  • sharia compliant pipeline
  • database bottlenecks
  • mysql lock contention
  • read committed isolation
  • database deadlocks
  • database performance tuning
  • missing index
  • scaling laravel
  • performance engineering
  • stress testing
  • fintech scaling

LYNK Engineering · Murabaha Platform · Part 2 of 3

10 → 5,200stress-test scale, Jan 2025 → Apr 2026
99.94%completion at 5,200 orders
11.8s → 0.26spurchase-step average, 2025 → today

In January 2025 we ran ten automated Murabaha orders through the platform. Four of them got stuck. Eighteen months later the same pipeline ran 5,200 orders end to end at 99.94%, and 5,000-order runs became a daily routine. What follows is everything that happened in between.

Volume was never the hard part. A Murabaha order is not one transaction but a strict legal sequence — buy real commodity units, transfer ownership to the borrower, sell to the open market, complete — and none of it may be skipped, faked, or reordered. Each order fans out into a chain of queued jobs landing on the hottest tables in the system: market orders, inventories, and millions of individually coded units. Making one such pipeline correct is ordinary engineering. Making five thousand of them run at once, with every legal constraint intact, is where the last two years went.

Ten battles made the difference, told in the order we fought them — the ones fought inside the code and the database. The infrastructure battles that came after, once the code had stopped being the constraint, are Part 3. We have kept the scars visible: each case tells you what was actually going wrong, what we tried — including the ideas that did not survive contact with production — and what the numbers said when the dust settled.

The stack, named once. Laravel on PHP 8, MySQL 8 (InnoDB) as the system of record, Redis behind the queues and locks, deployed as Docker Compose services on a handful of Linux VMs. Where a case turns on a specific behaviour of one of those — InnoDB gap locks, Laravel's queue defaults, Compose's scaling flags — it is named and glossed at the point it matters.

Platform and deployment architecture: a user request reaches the Laravel application on PHP 8, which writes transactional data to MySQL 8 (InnoDB) as the system of record and manages distributed job queues and locks in Redis; queue workers read those jobs and write back into MySQL. The whole thing is deployed as Docker Compose services — app, db, redis and scaled worker replicas — spread across a handful of Linux VMs.
The platform behind every case below: one Laravel application, MySQL as the system of record, Redis carrying the queues and locks, and worker replicas scaled per queue across a handful of Linux VMs. Almost every battle in this article is a fight between two of these boxes.
About these numbers. Every figure in this series comes from LYNK's pre-production stress-test program — synthetic orders against test data — not from customer traffic, and no customer data appears anywhere in it. Screenshots are of test environments. Published with LYNK's knowledge; the opinions, and any errors, are my own.

Case 01 · Everything waits for everything

Aug 2024 – Jan 2025

Impactceiling: tens → hundreds of orders

The problem

Our first real batch test, in December 2024, was humbling. We created 31 orders at once and watched three of them freeze for more than twenty minutes. When we retried with just ten orders, everything passed — and that, honestly, was the platform's ceiling at the time. A few weeks later a test run confirmed it the hard way: ten automated trade requests, four stuck in Purchasing Commodity.

Digging in, we found a platform designed for one order at a time, politely queuing the whole world behind it. Fifty simultaneous orders were processed strictly stage by stage — the first order could not buy its commodity until all forty-nine others had finished searching for theirs. Certificates rendered through a headless browser that fell over under load and took whole orders down with it. Stuck orders kept their commodity units reserved forever, so even cancelling them failed. And our very first "stuck orders" investigation ended somewhere nobody expected: the server disk was 98% full — 44 of 45 GB. Not a race condition, not a deadlock. A full disk.

A dense traffic jam, cars barely moving.
The order pipeline, late 2024: every order waiting for every other order (dramatization).
What we tried

We fixed the unglamorous things first: bigger disks. Then we gave the PDF service room to breathe — longer timeouts, more browser sessions, Chrome kept warm — which helped, though in hindsight we were treating a symptom we would only cure properly a year later (Case 04). The changes that mattered were structural. Work that ran synchronously inside requests moved into queued jobs. Jobs started carrying lightweight record IDs instead of whole serialized objects, because one stuck serialization had been pinning entire orders. And the single shared queue became three dedicated worker containers. Three. It felt like a fleet at the time.

The result By mid-2025 the platform was surviving real volume: 388 orders in one hour in May, then 534 of 535 completed in June — the first run where the entire cycle finished unattended, with no human touching a single order. The ceiling had moved from ten orders to hundreds, and we finally had a platform worth stress-testing.

Case 02 · The database fights back

Aug 2024 – Aug 2025

ImpactP95 28s → 13s · slow tail 21.9% → 0

The problem

Then production started sending a new kind of message: Lock wait timeout exceeded. Orders that should have flown were standing in line behind a single SELECT … FOR UPDATElocking reads ↓, like shoppers queuing at the one open till. Underneath it sat something subtler: MySQL's default isolation level — REPEATABLE READ, safe and strict — has InnoDB lock not just the rows a transaction touches but the gaps between them, and when many transactions touch neighboring inventory rows, those gap locks collide into deadlocks.gap locks ↓ We had the strange experience of adding capacity and watching throughput fall. More concurrency meant more waiting.

What we tried — three rounds

It took three rounds to actually win. In the first, we treated each incident as it came: remove this lock, log that deadlock (innodb_print_all_deadlocks on, so at least the next one would leave a confession)deadlocks ↓. Every individual fix worked — and the class of problem kept coming back with a new address.

In the second round, April 2025, we went after the class itself and switched the platform to READ COMMITTEDisolation ↓: dramatically less lock contention, in exchange for a strictness the Murabaha flow never actually relied on. Worth being precise about that trade, because in a financial system it sounds alarming. What REPEATABLE READ buys you is a stable snapshot across a long transaction — read a row twice, see the same value both times. No step in this pipeline does that. Each step reads the state it needs, acts, and commits inside a second, and the invariants that actually matter — a unit belongs to one order, a counter never goes negative — are enforced by explicit row locks and database constraints, not by the snapshot. We gave up a guarantee we were not using; the ones we were using are still there.anomalies ↓

The third round came during the summer stress tests, when the sell path deadlocked anyway — the global setting was not reaching every transaction, so the hottest services got it explicitly. And the last deadlocks were removed the cheapest way of all: not by locking more, but by making every code path write to tables in the same order, so transactions could no longer meet head-on.

The sell-order state job — isolation set on the transaction itself
public function handle(): void
{
    DB::statement('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
    DB::beginTransaction();

    try {
        $this->inventoryService->assertOrderHasInventories($this->localMarketOrder);
        // ...
The result — 1,000-order stress test, Aug 2025 We ran 1,000 orders through the fixed pipeline and measured. Average purchase time fell from 11.81s to 7.8s. P95 from 28s to 13s. The worst order of the entire run: 14 seconds, down from 65. And the slow tail — the 21.9% of orders that used to take over 15 seconds — vanished entirely. Every one of the thousand finished within 3–14 seconds.
Average P95 Worst order Average before — 11.81s Average after — 7.8s P95 before — 28s P95 after — 13s Worst order before — 65s Worst order after — 14s 11.81s 7.8s 28s 13s 65s 14s before after
The 1,000-order run of August 2025, before and after. The average is the least interesting bar: the win is in the tail. P95 more than halved, the worst order in the entire run fell from 65 seconds to 14 — and the 21.9% of orders that used to take longer than 15 seconds became zero.

Case 03 · The purchase-path campaign

Jul – Sep 2025

Impact1,000 orders, all within 14s

The problem

That summer we set ourselves a target — 500 orders without a single error — launched a 300-order run, and watched the first eleven orders die before doing anything at all. The culprit was a setting nobody had ever had a reason to think about: Laravel's default 60-second job timeout. No job was ever supposed to run for a minute. The timeout was not the disease; it was the thermometer — and it was telling us the purchase path simply did too much, one thing after another, for every single order.

What we tried

We raised the timeout to 120 seconds the same day — not as a fix, but as anesthesia, so testing could continue while we operated. Then came the actual surgery, a deliberate summer campaign. Buying commodities, creating trader orders, and holding units each got their own queue instead of sharing one line. The search for eligible commodities was separated from order initiation, so five workers could do in parallel what one had been doing alone. Inventory scans switched to cursors. We even dropped foreign keysforeign keys ↓ from the busiest join table — heresy in most engineering conversations, but their integrity checks were pure cost on a table written millions of times a day, and every write to it already goes through a single service that enforces the same invariants. It is a real trade rather than a free one: the guarantee moved out of the engine and into code, where it stays honest by review and tests instead of by construction. We would not make the same trade on a table that a dozen code paths could write to. And the units table finally got its first serious indexes.

The result The 1,000-order August test in Case 02 was this campaign's report card too: every order within 3–14 seconds. But the quieter outcome mattered more. From August 2025 the business team began running weekly performance tests with recorded percentiles — the moment performance stopped being a firefight and became a habit.

Case 04 · The certificate tax

Dec 2024 – Aug 2025

Impact0 seconds per step

The problem

Every legal step of a Murabaha produces its evidence — a commodity certificate, an ownership certificate, a sell confirmation. For a long time nobody questioned where those documents were made: inside the step itself, by a headless Chrome browser that opened a full browser session for every single PDF and produced, at best, about 18 documents a minute. It was like closing the till to print each receipt on a printing press in the back room. Under load the press jammed, its queue backed up, and whole orders stood still — waiting not for money or commodities, but for paperwork.

A letterpress printing machine producing one card at a time.
The certificate press at work: one document at a time, on the critical path of every order.
What we tried — three attempts

First, we tuned the press. Longer timeouts, more concurrent sessions, Chrome kept warm and ready. It bought headroom for the next test — and changed nothing about the economics. Still one browser per document, still on the critical path.

Then we replaced the engine. A native PHP library went head-to-head against the browser and won convincingly: ~128 documents a minute against ~18 — seven times the throughput — with no external service, no per-document fees, documents staying on our own infrastructure, and proper Arabic right-to-left out of the box. Better in every way. And still seconds of rendering inside every step.

The winning move came from a different room. Sitting with the business and the Sharia requirements, we asked what the certificate actually had to be — and the answer was: it must exist and be obtainable, not be rendered before the step may complete. So the steps stopped generating PDFs entirely; a certificate renders the first time someone downloads it. And here is the quiet punchline: most certificates are never downloaded at all. Most of that work never needed speeding up. It needed to stop existing.

ATTEMPT 1 · BROWSERLESS ATTEMPT 2 · mPDF ATTEMPT 3 · ON DEMAND ~18 docs/min ~128 docs/min 0s on the step one Chrome session per document, on the critical path native PHP engine, 7× throughput — still seconds inside each step rendered only when someone actually downloads it
The three attempts at the certificate tax, at their true relative speeds: a browser session per document (~18/minute), a native PHP engine (~128/minute) — and finally no render at all unless a user asks for the document. The fastest PDF is the one you never generate.
The result The certificate cost on the pipeline dropped to effectively zero seconds per step — you can see it in any order today, where Borrower Ownership and Murabaha Completed finish in 0 seconds (the live order in Part 1 shows exactly this). When someone does request a certificate, it renders in about 3 seconds at seven times the old throughput. At 5,000 orders and five certificates each, the on-demand decision removed tens of thousands of renders from every test run — the largest single time sink we ever took off the critical path. And it came out of a business conversation, not a profiler.

Case 05 · Which units can you even buy?

Dec 2024 – 2026

Impactscan millions of rows → read a counter

The problem

Before a single unit can be purchased, the platform has to answer a question that sounds like a database query and is actually a small legal investigation: which of the millions of units is this particular customer allowed to buy, right now? A unit qualifies only if it is free, its supplier is active, and buying it would not violate the trade-rotation rules for that specific buyer — a unit you recently owned cannot rotate straight back to you.

In the early days we answered honestly and expensively: scan the units table, per order, with that customer's conditions. Millions of rows, thousands of concurrent orders, the same question over and over. For a long time, if you asked where the platform's time was going, a large part of the answer was: re-deriving an answer that had barely changed since the last time we derived it.

What we tried — three generations

Generation one: compute the truth on demand. Correct, simple, and ruinously expensive — every order paid the full scan, and those scans were most of the read pressure behind the 94-million-read index in Case 09.

Generation two: the "settled" feature. When an order completes, mark its units as settled back to the market — in bulk, with a cheap flag — so eligibility queries can skip whole classes of units without inspecting them. The scans shrank. But the settlement chain groaned under its own weight (its jobs kept blowing past their time limits and eventually needed a full bulk rewrite), and the per-order scan was still there. Smaller, not gone.

Generation three: stop scanning altogether. If the question is always the same, keep the answer written down. A dedicated table now holds an eligible-quantity counter for every (customer, inventory) pair, and every event that changes the answer — a purchase, a settlement, a supplier switching on — updates the counter in that same moment, atomically. The schema itself refuses to let a counter go negative, and rebuild jobs can reconstruct any counter from the ground truth whenever we have a reason to doubt it.

Migration — make the eligibility counter unable to go negative
public function up(): void
{
    // Make eligible_quantity non-negative by using an UNSIGNED integer.
    DB::statement('ALTER TABLE `local_market_eligible_quantities` '
        .'MODIFY `eligible_quantity` INT UNSIGNED NOT NULL');
}
The result "Find eligible commodities" went from scanning millions of unit rows on every order to reading a handful of precomputed counters — roughly 0.1 seconds inside today's sub-second purchase step. The cost of eligibility moved off every order's critical path and onto the rare events that actually change the answer. That is the whole trick, and it took three generations to find it.

Case 06 · The exact-change problem

Jan 2025 – 2026

Impactgateway timeouts → milliseconds

The problem

Two constraints stack here, and it is the stacking that makes this hard. The first is exactness: a Murabaha for SAR 1,000,000 must be covered by whole units at fixed prices adding up to precisely that amount. One halala over or under is not a smaller trade; it is no trade at all. The second is eligibility. As Case 05 describes, a unit is not eligible just because it exists and is unsold — trade-rotation rules and previous ownership decide, per institution, whether this particular buyer may hold this particular unit. So the two questions the platform is really asking are: which units may this institution hold, and which subset of those adds up to the amount exactly.

That is why "the market has plenty of stock" is not an answer. An order can fail with millions of units sitting in the market, because most of them are ineligible for this buyer, and the eligible remainder happens to have no subset that lands on the amount. And the search space is not small: hundreds of eligible inventories, each with its own price and available count, combine into more candidate baskets than anything can enumerate. Mathematicians know this family as "exact change" problems, famous for one thing — naive solutions explode.

Failure is expensive at both ends. A search that finds nothing ends the order as "no eligible commodities available", and the customer's financing is cancelled — so an algorithm that gives up too early cancels trades the market could actually have covered. A search that refuses to give up is worse: on inputs of a few hundred inventories our first implementation searched until the gateway hung up on it, and a slow search does not stall one order, it stalls the purchase step of every order queued behind it.

Two panels. Left: the dual constraints. Exactness — whole units at fixed prices must add up to precisely the amount, so one halala over or under is no trade. Eligibility — trade-rotation rules, ownership histories and institutional profiles decide which units a given institution may hold, leaving an eligible remainder that is searched for a basket combination; most candidate baskets are wrong and naive solutions explode. Right: the two ways it fails. A search that gives up too early returns no eligible commodities available and the customer's financing is cancelled even though the market could have covered it; a search that refuses to give up runs until the gateway times out and stalls the purchase step of every order queued behind it.
Both constraints, and both ways of failing them. The search has to satisfy exactness and eligibility at once — and it has to do it inside a time budget, because giving up too early cancels a financing the market could have covered, while never giving up holds up every order behind it.
Two algorithms

The first one lived in the database. Ask for the eligible inventory that can absorb the most value — ordered by available quantity times unit price — take as many units as fit in what is left, exclude that inventory, ask again. Preferred commodity types first, then any type. It was simple, it was one query per pick, and while the market was small it worked. Its limit was structural: it could only ever walk one path forward. Having taken the biggest bucket first, it had no way to reconsider that choice, so it reported "no eligible commodities" on markets that genuinely did contain an exact answer — just not one reachable greedily. Every such report was a cancelled order.

The second one moved the whole search into memory. Load the eligible inventories once, then work on them without touching the database again. It still starts greedy — one linear pass, taking as many units of each inventory as still fit — because for the overwhelming majority of orders that single pass lands exactly on zero. What is new is what happens when it does not: it backtracks, putting the most-used inventory back, swapping in different ones, and repeating until the remainder closes or the options run out. Two things bound it. Before searching at all, a fast pre-check asks whether an answer can exist — if the most valuable 10,000 units on the table cannot reach the amount, the order fails immediately with a clear message instead of hunting for a solution that is not there. And the search itself is on a deliberate leash: at most 10,000 units per trade, at most three seconds of thinking. One order failing fast with a useful message beats five thousand orders waiting behind a search that refuses to give up.

Two details matter more than they look. It counts in whole halalas, never fractions — floating-point money that drifts by 0.01 is the difference between exact change and no change at all. And it writes down every gap it fails to close, so a failure arrives as a diagnosis rather than an error: the market lacks units at these prices. That list is market feedback; it tells a supplier what to list next.

For a year both algorithms ran side by side behind one interface so they could be judged on identical inputs. In 2026 the greedy one was deleted and the backtracking one became the only path.

What made that judgement possible was a harness. Neither algorithm could be trusted on intuition, and production is a terrible place to discover that a search has a blind spot — the evidence arrives as a cancelled customer order. So we built an internal tool that runs the algorithms against invented markets: thousands of synthetic price-and-quantity mixes crossed with thousands of financing amounts, the same inputs fed to both, recording for each run whether it found an exact cover, how long it took, and what remained uncovered. It turned "seems fine" into a table you could read. It is what caught the amount smaller than the cheapest unit on the market, the combinations only backtracking could reach, and the inputs where the search would have run past its time budget — each one fixed before an order ever met it.

The commodity-combination search — bounded by config, not by hope
private const DECIMAL_SCALE = 2;

public function __construct()
{
    $this->maxUnitsPerTrader = config('trader.providers.lynk.max_units_per_trader', 10000);
    $this->loanCoverageTimeout = config('trader.providers.lynk.loan_coverage_timeout', 3);
}

// ...

    $elapsedTime = microtime(true) - $startTime;
    if ($elapsedTime >= $this->loanCoverageTimeout) {
        // ...
        throw new Exception('Request timeout!', 408);
    }

// ...

private function toMinorUnits(float $value): int
{
    return (int) round($value * (10 ** self::DECIMAL_SCALE));
}
The result Combinations for the overwhelming majority of orders are found in milliseconds, inside a purchase chain whose entire CPU budget is about half a second. The orders that used to be cancelled as "no eligible commodities" while a covering basket existed are found now. The genuinely impossible ones fail immediately — and instead of a timeout, they leave behind a list of the missing price points: market feedback, not just an error.

Case 07 · The platform attacking itself

Oct 2025

Impactdatabase load −85%

The problem

One October morning the monitoring dashboard reported 228,291 database queries in a few hours — on a system doing almost nothing. We went looking for the traffic and found ourselves. A scheduler ran every minute and, with no duplicate check, had dispatched the same background job 22,400 times. A single unindexed history lookup had run 114,000 times.n+1 ↓ The first alarm had been, embarrassingly, the monitoring bill: millions of recorded events per day, all of them faithfully documenting our own waste.

That lookup existed because of how an order remembers itself. Every step an order takes is appended to its status history as a new row, and nothing is ever overwritten — in a regulated pipeline the trail is the point. But almost everything the platform wanted to know came down to a single question: what did this order just do? The only way to answer it was to read the order's whole history and take the newest row. One query, per order, every time — on every screen, in every scheduler sweep, before every job decided whether it was allowed to run. A list of a hundred orders asked it a hundred times.

Two identical Spider-Men pointing at each other.
The moment we traced the 228,291 queries back to their source.
What we tried

The fixes read like an apology to the database. One composite index turned the hot lookup from a scan into a seek.indexes ↓ Then we stopped asking the question and started remembering the answer: the last action an order took now lives on the order itself, written in the same transaction that appends the history row — the trail stays the record of truth, the column is just its most recent line, already read. The cost moved from every read to one write per transition, and roughly 90% of those history queries stopped running at all. And the scheduler grew up: it loads work in batches, skips anything it touched in the last five minutes, checks what is already queued before dispatching again, and staggers its dispatches with a small random delay — so a thousand jobs stop arriving in the very same second.

The result Platform query volume dropped roughly 85% in every environment — one environment alone went from 5.5 million to 825 thousand queries a day — and monitoring event volume fell about 72%, which also ended the debate about observability costs. The report's own conclusion said it best: the monitoring "wasn't the problem — it was faithfully monitoring legitimate performance bottlenecks."

Case 08 · The regression the baseline caught

Nov 2025

Impact2s per order recovered in a week

The problem

In November, the weekly numbers twitched. The purchase step, steady around seven seconds for weeks, came in at nine — then nearly ten. Nothing had failed: every order completed, no alarms fired, nobody complained. Which is exactly what makes this kind of bug dangerous. Without a baseline, two extra seconds on every order quietly becomes the new normal, and nobody ever asks why.

What we tried

The percentile history let us bisect by deployment, and the answer was uncomfortable: the slowdown came from a bug-fix. A certificate had been naming the wrong previous owner; the correction was right, but it did its checking in the middle of every single purchase. We reverted it in production the same week — protecting the step while the fix stayed alive in testing — and then rebuilt the same correction so it did its work off the critical path.

The result Purchase times returned to baseline, with the certificate fix intact. The deeper win was cultural: this became the standing proof that every functional change is also a performance change until the percentiles say otherwise. Per-step duration tracking was built into the product itself soon after, so today every order records how long each of its steps took.

Case 09 · The coded-unit ledger battles

2024 – 2026

Impact3,300 orders · 100% · ledger intact

The problem

As Part 1 explains, stock lives as individually coded units, each tracked to its owner, with every ownership change appended to a legal ledger by a database trigger. That units table is the hottest object in the platform — one of its indexes alone had served 94 million reads when we first profiled it — and it answers to three masters at once: no two orders may ever hold the same unit, the ledger may never miss a row, and finding eligible units among millions has to stay fast. Almost every scaling battle we fought eventually landed on this table.

What we tried — including what failed

Exclusive holds took years of iteration: counters that tolerate crashes without drifting, a repair job for corrupted holds, overlap middleware on the jobs that could collide — and one queue deliberately left at a single worker, because for that step, correctness genuinely could not be parallelized. Knowing when not to scale is also scaling.

The table's indexes have a history like a battlefield map:query plans ↓ added, removed, rebuilt as virtual-column composites, extended, pruned again. Every index makes the reads faster and the millions of ownership writes slower, so each one had to earn its keep with measurements — index hygiene became a recurring chore, not a one-time setup.

Index migrations on the units table, by month
2024-11  add previous-owner index
2024-12  add indexes to the inventory-units table
2024-12  add more index columns to the same table
2025-01  remove indexes from the inventory-units table
2025-02  create a new index on the inventory-units table
2025-03  add index (market inventory units)
2025-07  add index (market inventory units)
2025-10  add index on the last purchasing order
2025-11  drop the previous-owner grouping index   (three times)
2026-04  add a covering index
2026-07  drop unused indexes

One experiment deserves its own confession. We added model-level caching to the hot tables to save reads — and months later reverted it for the write-heavy ones, because under real load the caching cost more than it saved. It stung. It is also exactly why we measure.

And the trigger met its match at settlement scale. The per-row trigger guarantees no ledger row is ever missed — and it became the slowest part of bulk settlement. We did not remove the guarantee; we changed who writes. The settlement rewrite produces the very same ledger rows through set-based bulk statements, thousands at a time, with the trigger stepped around only for that audited path.

The result March 2026: 3,300 orders, 100% completed — millions of unit holds, transfers, and ledger rows, with zero double-holds and zero missing links in any chain of ownership. The ledger held at every scale the stress tests ever reached.

Case 10 · When the jobs themselves fail

2024 – 2026

Impactfailed job ≠ failed transaction

The problem

Everything in this platform is a job — every step transition, every certificate, every settlement. That is what makes 5,000 concurrent orders possible, and it carries a sober corollary: whatever happens to a job happens to a customer's transaction.

In the early platform, a job's life could end three ways, and all of them were bad. A failed job simply died — there was no retry, so a database hiccup lasting one second froze a customer's Murabaha until an engineer went in by hand. Nothing stopped two workers on two different servers from grabbing the same work — and in a system that moves money and transfers ownership, doing something twice is as dangerous as not doing it at all. And when we first added locks to prevent that, the locks never expired: a worker killed mid-job left its lock behind forever, blocking the order with a "max attempts exceeded" error whose cause you could never find — because the cause was a ghost.

What we built

The platform we run now starts from a different assumption: every job will eventually die mid-flight — the work is making that boring. Jobs retry on their own, waiting 1, 2, 3, 5, then 30 seconds between attemptsretries ↓: patient enough for a transient fault to clear, quick enough that the order barely notices. If we had to name the single most important mechanism behind zero failed transactions, it is this one — most failures are temporary, and a platform that retries well simply does not lose them.

Critical jobs are declared unique per order across the whole fleetunique jobs ↓ — Laravel's ShouldBeUnique, backed by the Redis lock store — so a duplicate dispatch from any server is refused at the queue itself, not caught after the damage. The locks learned to die: they expire shortly after the worker timeout, so a crashed worker's lock heals on its own, and an overlapping dispatch politely re-queues after a short delay instead of burning through its retries on the spot. Even deploys stopped being a hazard — a worker being shut down now finishes or hands back its job instead of taking it to the grave.

The ownership-transfer job — unique per order, with escalating backoff
class TransferOwnershipToCustomer implements ShouldBeUnique, ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, StopsOrderOnJobFailure;

    public $tries = 5;

    public $backoff = [1, 2, 3, 5, 30];
Queue middleware — WithoutOverlapping with locks that expire
/**
 * WithoutOverlapping with safe defaults.
 *
 * Laravel's WithoutOverlapping defaults to expiresAfter = 0, meaning the lock
 * never expires. When a worker is killed mid-job (timeout, OOM, deploy) the
 * lock is orphaned forever, permanently blocking that order's job and producing
 * MaxAttemptsExceededException with no underlying error. These defaults make a
 * dead worker's lock self-heal:
 *
 * - expiresAfter: comfortably above the worker --timeout (120s) so a legitimately
 *   long-running job keeps its lock, while a crashed worker's lock is reclaimed.
 * - releaseAfter: re-queue an overlapping dispatch after a delay instead of
 *   spinning and burning all retry attempts instantly.
 */
class PreventOverlapping extends WithoutOverlapping
{
    public function __construct($key = '', $releaseAfter = 10, $expiresAfter = 180)
    {
        parent::__construct($key, $releaseAfter, $expiresAfter);
    }
}
The result A failed job stopped meaning a failed transaction. Transient faults heal themselves within seconds, a duplicate dispatch is refused at the queue instead of repaired after the damage, and dead workers clean up after themselves. Uniqueness here is a lock, not a law of physics — it is only ever as good as the lock store and the expiry window — which is why the money-moving steps also carry their own duplicate check on the order reference underneath it. (That check is the very lookup that turns out to need an index in Part 3, Case 11.) The handful of orders per 5,000-run that still strand — the genuinely non-transient failures — are exactly what the audited admin "Force Forward" tooling exists for. The 99.9%+ completion rates in this article are not the absence of failures. They are failures, handled.

The scaling ladder

2024 – 2026
0 1,000 2,000 3,000 4,000 5,000 Dec 2024 Jan 2025 May 2025 Jun 2025 Aug 2025 Feb 2026 Mar 2026 Apr 2026 Jun 2026 → Dec 2024 — 31 orders (3 stuck; disk 98% full) Jan 2025 — 10 orders (4 of 10 stuck) May 2025 — 388 orders in one hour Jun 2025 — 535 orders, 534 completed Aug 2025 — 1,000 orders, P95 13s, zero over 15s Feb 2026 — 2,500 orders per run × 5, 99.92–100% Mar 2026 — 3,300 orders, 100% completed Apr 2026 — 5,200 orders end-to-end, 99.94% From Jun 2026 — 5,000 orders, routine daily test 31 10 388 535 1,000 2,500 × 5 runs 3,300 5,200 5,000 daily
Largest successful stress-test scale per period, in orders per run. The bars for 31 and 10 orders are drawn at minimum visible width — at this scale they are two pixels, which is the point. The lighter final bar marks 5,000-order runs becoming a routine daily test rather than a one-off campaign.
DateTest scaleOutcome
Dec 202431 orders3 stuck >20 min; 10-order retest passed — root cause: disk 98% full
Jan 202510 orders4 of 10 stuck in Purchasing Commodity
May 2025388 orders / hrHandful stuck at initiation and ownership
Jun 2025535 orders534 completed — first full auto-sell cycle
Aug 20251,000 ordersPost-fix: P95 13s, zero orders over 15s
Feb 20262,500 × 5 runs99.92–100% success, ~48–50 min per run
Mar 20263,300 orders100% completed
Apr 20265,200 orders99.94% — 3 stuck, end-to-end
Jun 2026 →5,000 ordersRoutine daily performance test; create-order API ~1.2s avg
12s 8s 4s 0 Aug 2025, before READ COMMITTED — 11.81s average Aug 2025, after READ COMMITTED — 7.8s average Jul 2026, before the queue split — 3.6s Jul 2026, immediately after the queue split — 9.9s (the regression of Case 11) Aug 2026 — 0.26s average 11.81s 7.8s 3.6s 9.9s 0.26s Aug 2025 Aug 2025 Jul 2026 Jul 2026 Aug 2026 before READ COMMITTED after READ COMMITTED before the queue split after the split — regression after the missing index
The purchase step, end to end, as one line: every number in this chart is quoted somewhere in this series. Two things are worth staring at. The first is the amber column — the fix of Case 11 briefly made this step almost three times slower, because removing the queue's backpressure exposed a missing index upstream (Part 3). The second is the last column: 0.26 seconds, roughly 45× faster than where this chart starts, and small enough that it is barely a bar at all.

Business logic as a performance lever

Throughout

Threaded through the cases above are wins that were not technical at all — product decisions about what the Murabaha flow actually requires:

  • Auto Complete Sell. Allowing companies to opt into automatic contract, wakala, and sell steps is what made unattended 5,000-order runs possible at all — and mirrors how high-volume lenders want to operate.
  • Certificates on demand. Sharia-required documents are still produced — but at download time, off the order's critical path (Case 04).
  • Trade rotation count. A controlled pre-production A/B (five 2,500-order runs before and after) proved that reducing commodity trade rotations from 2 to 1 carried no performance penalty, clearing the way for the Specialized Entity flow on business merits alone.
  • Force Forward. At scale, some tiny fraction of orders will always strand. Rather than database surgery by engineers, admins got an audited per-step "force forward" action, with automatic order expiry as the second safety net.
  • A standing performance program. Weekly (now daily) pre-production stress tests with recorded percentiles, quarterly performance epics, and dedicated worst-case scenarios — performance became a product feature with an owner, not an emergency.

Ten orders once broke this platform. By the end of these ten cases, five thousand ran end to end at 99.94% — and the last of them had already changed the shape of the problem. With the code no longer the constraint, the waiting had moved somewhere else entirely: into the space between the jobs, and into a worker fleet nobody had designed on purpose. That is Part 3.

Further reading

This series names more than it can teach — a contract type here, an isolation level there, a queueing effect somewhere else. Where a term is carrying real weight in the text above, this is the primary source to go and read properly, rather than a summary of a summary. Every link was checked when this was published.

← Blog