Adaptive locks and lock-free trees. I would unique the OnMission claim, not port Arctic

OSDI’s Arctic paper is the concurrency story this week. I would unique the OnMission assignment claim and retry a P2002. I would not add a Redis lock to a Stripe webhook. Here is the five-step check.

I opened the OSDI 2026 Arctic paper and then I opened the OnMission claim path. That was the useful order of events. The paper is a lock-free adaptive radix tree. They wired it into RocksDB and Turso and beat a skiplist on write-heavy benches. The feed will say lock-free like it is a personality. OnMission has two managers who can click Assign on the same open row. That is the race. I would unique that claim. I would not port a radix tree into Prisma because a conference used the word adaptive.

Two different ideas share the word adaptive this week. An adaptive lock is a mutex that spins, then sleeps. A lock-free structure never takes the mutex. Arctic is the second thing, and its adaptive part is the tree, not the wait. If you mash them together you will add Redis to a Stripe webhook and call it research. Node’s event loop is one thread. The locks that matter on this stack live in Postgres. The webhook inbox already refuses the second delivery. The assignment board should do the same job for a human click.

Arctic is a lock-free adaptive radix tree that achieves all three: high performance, lock freedom, and range scans.

Ni, Garza, Stinehour, Goppert, Friedman, Witchel — Arctic, OSDI 2026

OnMission and LOMA write assignments in Postgres through Prisma. Lahebo writes seats through Stripe. SyBazar writes orders in Mongo. None of those paths should grow a mutex this weekend. The unique key and the version column are the whole toolkit.

Step 1: Name the race before you name the lock

Do not start from Arctic’s node layout. Start from the two requests that can land in the same millisecond. On OnMission that is two managers claiming the same open assignment. On Lahebo it is checkout.session.completed and invoice.paid for the same seat. On SyBazar it is two paid webhooks. Write the pair down. If you cannot name the pair, you do not have a concurrency bug. You have a paper.

Step 2: Unique the claim. That is the lock-free shape that ships

Arctic wins with compare-and-swap on tree nodes. Postgres already has that primitive: insert, or lose. Two claim rows with the same (orgId, assignmentId) is the bug. A unique constraint makes the second insert a P2002. The loser is not blocked. They get a null and the UI says taken. Do not findFirst then create. Both reads will pass. I already said the same thing about webhook event ids.

model AssignmentClaim {
  id           String    @id @default(cuid())
  orgId        String
  assignmentId String
  employeeId   String
  version      Int       @default(0)
  claimedAt    DateTime  @default(now())

  @@unique([orgId, assignmentId])
  @@index([orgId, employeeId])
}
import { Prisma } from "@prisma/client";
import { prisma } from "../db";

export async function claimAssignment(
  orgId: string,
  assignmentId: string,
  employeeId: string,
) {
  try {
    return await prisma.assignmentClaim.create({
      data: { orgId, assignmentId, employeeId },
    });
  } catch (err) {
    if (
      err instanceof Prisma.PrismaClientKnownRequestError &&
      err.code === "P2002"
    ) {
      return null; // someone else has the row
    }
    throw err;
  }
}

That is not a Treiber stack. It is the same progress rule. One writer commits. The other does not wait on a mutex. LOMA can copy the same unique. A lock-free queue in TypeScript cannot copy a paid seat.

Step 3: Version the row you must update. That is compare-and-swap

A create is easy. A transfer is the CAS case. Manager A read employee Ravi. Manager B read employee Ravi. Both write Sita. Last write wins and the board lies. Arctic does this with an atomic on a node. Prisma does it with updateMany and the version you just read. If count is 0, you lost. Read again. Do not increment in the client and hope.

export async function transferAssignment(
  id: string,
  employeeId: string,
  expectedVersion: number,
) {
  const updated = await prisma.assignmentClaim.updateMany({
    where: { id, version: expectedVersion },
    data: {
      employeeId,
      version: { increment: 1 },
    },
  });

  if (updated.count === 0) {
    return { ok: false as const, reason: "lost_race" };
  }

  return { ok: true as const };
}

A version column is CAS. A Redis lock is still a lock SET claim:id NX EX 10 is an adaptive lock with a worse failure mode. If the process dies before DEL, you wait on the TTL. If two boxes clock-skew, you wait on a ghost. The version column does not need a janitor. Use Redis when the thing you are serialising is not a row — a third-party call, a file, a process that is not Postgres. Do not put it on the Stripe inbox.

Step 4: Retry a little, then fail. That is the adaptive lock

An adaptive mutex spins, then parks. The useful copy in a Nest service is a short retry on a write conflict, then a 409. Prisma P2034 is a serialisation failure. P2002 is a unique hit. The first one is worth two or three backoffs. The second one is usually a no, not a retry — unless you are claiming the next free slot, not a specific id. Do not loop forever. That is a spin lock with extra steps, and a slow query still wins.

import { Prisma } from "@prisma/client";

export async function withAdaptiveRetry<T>(
  work: () => Promise<T>,
  attempts = 4,
): Promise<T> {
  let delayMs = 8;

  for (let i = 0; i < attempts; i++) {
    try {
      return await work();
    } catch (err) {
      const retryable =
        err instanceof Prisma.PrismaClientKnownRequestError &&
        err.code === "P2034";
      if (!retryable || i === attempts - 1) throw err;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      delayMs *= 2;
    }
  }

  throw new Error("adaptive retry exhausted");
}

Wrap the transfer, not the whole HTTP handler. Sleeping inside a request is the park half of the adaptive lock. Four attempts at 8, 16, 32, 64 milliseconds is enough for two managers on one assignment. It is not a plan for a Black Friday checkout. That path stays on claim then worker.

Step 5: What I would copy from this paper, and what I would not

SELECT FOR UPDATE is the last tool, not the first. Use it when the rule cannot be a unique key: read a note, decide a status, write that same row. Hold the transaction short. Do not FOR UPDATE a list endpoint. That is how you lock the board Prisma was already waiting on.

-- Last resort. One row. Short transaction.
BEGIN;

SELECT id, status, version
FROM assignments
WHERE id = $1
FOR UPDATE;

-- decide, then UPDATE ... WHERE id = $1
COMMIT;

The feed will move on to the next index. The useful part is the same as the webhook note. Name the race. Unique the first write. Version the second. Retry a little. Leave the lock-free tree in the tab you are not merging.

Adaptive lock and lock-free questions

What is an adaptive lock?

An adaptive lock is a mutex that spins for a short time, then parks the waiter if the lock is still held. Spinning is cheap when the holder is about to release. Sleeping is cheap when the holder is gone for a while. Linux futexes, Java synchronized, and pthread ADAPTIVE_NP all do a version of this. The adaptation is the wait strategy, not the data structure.

What is a lock-free data structure?

Lock-free means some thread always completes even if other threads stall. Nobody holds a mutex. Updates use compare-and-swap: if this cell is still X, write Y. One writer wins. The loser reads the new value and tries again. Treiber stacks, Michael-Scott queues, ConcurrentHashMap, and Arctic’s adaptive radix tree are this family. Progress is a guarantee. Speed is a measurement.

Is Arctic the same thing as an adaptive lock?

No. Arctic is a lock-free adaptive radix tree from OSDI 2026. The adaptive part is the tree: node size and prefix fan-out change with the keys. An adaptive lock is still a lock. Do not mash the two words into one tool. I would not port Arctic into OnMission this week. I would steal the idea that a loser retries, and put that on a unique constraint.

Should I implement a lock-free queue in Node.js?

Almost never. One JavaScript thread runs your Nest handler. Worker threads and SharedArrayBuffer exist, but Lahebo and OnMission take money and assignments in Postgres. The race is two HTTP requests, not two cores on a CAS loop. A unique constraint or a version column is the lock-free shape that actually ships. A hand-rolled Michael-Scott queue in TypeScript is a blog, not a fix.

When do I use SELECT FOR UPDATE instead of a unique constraint?

Use FOR UPDATE when you must read a row, decide, then write that same row, and a unique key cannot express the rule. Two managers editing the same OnMission note is that shape. Two managers claiming the same open assignment is not — unique on (orgId, assignmentId) is enough. A Redis SET NX lock is still a lock. If the process dies you need a TTL. A unique constraint does not need a janitor.