Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

shadowClone.js

v5.3.0

An in-process, lock-free contention guard for shared state. Instead of making a second caller block on a first caller's lock, or rejecting it outright, it hands the second caller an isolated shadow clone — a deep-copied, namespace-restricted view of just the scope it asked for (e.g. user:alice, not the whole table). The clone is fully read/write, and on success its writes are merged back onto the real resource before it's discarded.

import { ScopedResourceManager, ChainedHashTable } from './shadowClone.js';

const table = new ChainedHashTable();
const mgr = new ScopedResourceManager(table, { resourceId: 'MemoryLobe' });

const result = await mgr.withScope('user:alice', async (res, isEphemeral) => {
  res.set('user:alice', 42);
  return res.get('user:alice');
}, 1000); // 1000ms timeout budget

Why

Three common ways to handle two concurrent callers wanting the same piece of state: make the second one wait (blocking locks), reject it outright, or let it stomp on the first (races). This module takes a fourth path — give the second caller its own disposable copy of just the data it's touching, and reconcile that copy back once it's done.

  • No blocking on a busy-but-not-saturated scope. A busy scope spawns a clone instead of making the caller wait.
  • No silent data loss. Reading or writing a key outside a clone's namespace throws immediately instead of no-op'ing. A clone's own writes are merged back onto the primary resource when its operation succeeds; they're discarded only if the operation itself throws or times out.
  • No unbounded growth. Three independent, typed failure modes: rate (ThrottlingExhaustedError), duration (TimeoutExceededError), and concurrency — which backs off to an explicit FIFO/weighted queue (QueueOverflowError only once the queue itself is full), never open-ended.
  • No overlapping zombies. JS can't preempt a timed-out operation — it keeps running in the background. The scope's lock (or clone slot) stays held until that abandoned operation actually settles, so a new caller never mutates the primary resource concurrently with one that's already been told it timed out. Every operation also receives an AbortSignal so it can cooperate and stop early instead of running pointlessly to completion.
  • No hanging promises on shutdown. manager.shutdown() drains the queue and now waits for every in-flight operation to actually finish before resolving — "shut down" means truly idle, not just "stopped accepting new work."

Architecture

Static structure: how the entry points, the manager's internal state, the optional policy ledgers, and the resource/clone relationship fit together.

flowchart TB
    subgraph Consumers["Consumer-facing entry points"]
        WS["withScope(scopeKey, op, timeoutMs)"]
        AG["autoGuard(fns, mgr, opts)"]
        SM["secureModule(rawModule, config)"]
    end
    SM -->|pre-wires| AG
    AG -->|"Proxy get() trap"| WS

    subgraph Manager["ScopedResourceManager"]
        Throttler["StrictThrottler<br/>fixed-window token count"]
        Locks["activeLocks: Set&lt;scopeKey&gt;<br/>primary-lock state, per scope"]
        Pool["activeClones: number<br/>global clone-slot pool, shared across all scopes"]
        Queue["_pendingQueue[]<br/>admission backlog"]
        Dispatch["_dispatch()"]
    end
    WS --> Throttler --> Dispatch
    Dispatch --> Locks
    Dispatch --> Pool
    Dispatch --> Queue

    subgraph Policy["Optional policy ledgers"]
        Tie["TieLedger<br/>queue ordering"]
        Retry["RetryLedger<br/>retry-on-timeout, per scope"]
        NoClone["NoCloneLedger<br/>force queue-only, per scope"]
    end
    Tie -.setQueueComparator.-> Queue
    Retry -.getMaxRetries.-> WS
    NoClone -.isQueueOnly.-> Dispatch

    subgraph Resource["Shadow-cloneable resource"]
        Primary[("primary state<br/>e.g. ChainedHashTable")]
        ShadowClone["Shadow<br/>deep-cloned, scope-restricted"]
    end
    Dispatch -->|scope free| Primary
    Dispatch -->|"scope busy, clone slot free"| ShadowClone
    Primary -->|"shadowClone(scopeKey)"| ShadowClone
    ShadowClone -->|"mergeShadow(), success only"| Primary

    classDef ledger fill:#f5f0fb,stroke:#5c3a8b,color:#3a2159;
    class Tie,Retry,NoClone ledger;
Loading

The detail worth internalizing here: activeLocks is keyed per scope (many scopes can each have their own primary running concurrently), but activeClones is a single counter shared by every scope on the manager — maxConcurrentClones caps total live clones manager-wide, not per scope.

Two more pieces of manager state aren't in the diagram above because they're bookkeeping rather than admission logic, but they're what makes zombie-safety and shutdown() work: _activeControllers (one AbortController per in-flight primary/clone, aborted by shutdown({force: true})) and _inFlight (one settle-promise per in-flight primary/clone, awaited by shutdown() so it doesn't resolve until the manager is truly idle).

Install

No dependencies, no build step. Drop shadowClone.js into your project and import it as an ES module (Node 18+, or any modern browser/bundler).

Usage

1. Guard a single resource directly

import { ScopedResourceManager, ChainedHashTable } from './shadowClone.js';

const table = new ChainedHashTable();
const mgr = new ScopedResourceManager(table, { resourceId: 'MemoryLobe' });

const result = await mgr.withScope('user:alice', async (res, isEphemeral) => {
  res.set('user:alice', 42);   // scoped read/write; guarded either way
  return res.get('user:alice');
}, 1000);

2. Guard specific functions by naming convention

import { autoGuard } from './shadowClone.js';

const guarded = autoGuard({
  antiLock_saveUser(res, user) { /* ... */ },   // guarded (antiLock_ prefix)
  helper() { /* ... */ }                        // NOT guarded
}, { resourceId: 'Users', timeoutMs: 500 });

3. Guard an entire module in one line

import { secureModule } from './shadowClone.js';
import * as RawFunctions from './myCode.js';

const SafeFunctions = secureModule(RawFunctions, {
  resourceId: 'MemoryLobe',
  initialState: { data: [] },
  maxConcurrentClones: 20,
  ledgerWeights: { SaveData: 100, ReadData: 10 } // ties favor SaveData
});

await SafeFunctions.SaveData('hello');   // resource injected automatically

4. Graceful shutdown

await SafeFunctions.__manager.shutdown('server shutting down');
// or, to also nudge cooperative in-flight operations to stop early:
await SafeFunctions.__manager.shutdown('server shutting down', { force: true });

5. Cancel one specific call, age queue priority, and watch live metrics

import { ScopedResourceManager, ChainedHashTable, TieLedger } from './shadowClone.js';

const mgr = new ScopedResourceManager(new ChainedHashTable(), { resourceId: 'MemoryLobe' });

// Cancel one in-flight or still-queued call from the outside:
const controller = new AbortController();
const work = mgr.withScope('user:alice', async (res, isEphemeral, signal) => {
  if (signal.aborted) return;       // cooperative early exit
  res.set('user:alice', 42);
}, 1000, { signal: controller.signal });
controller.abort();                 // `work` rejects with OperationCancelledError

// Age queue priority so a low-weight scope isn't starved forever:
const ledger = new TieLedger({ SaveData: 100 }, 5); // +5 effective priority per ms waited
mgr.setQueueComparator(ledger.getComparator());

// Point-in-time observability:
mgr.getMetrics(); // { queueLength, activeClones, maxConcurrentClones, lockedScopes, inFlight, shutDown }

How it works

withScope(scopeKey, operation, timeoutMs) is the entry point:

  1. Scope freeoperation runs directly against the primary resource.
  2. Scope busy, clone capacity availableoperation runs against a deep-cloned Shadow restricted to that scope. On success, the clone's touched keys are merged back onto the primary resource (last-writer-wins per key against anything else that touched it meanwhile); on error or timeout, the clone is discarded unmerged.
  3. Scope busy, clone capacity exhausted → the request is queued (FIFO by default, or ordered by a TieLedger weight map) until a slot frees, or rejected with QueueOverflowError once the queue itself is full.

Every operation also runs under a per-call timeout (Promise.race, throws TimeoutExceededError) and a fixed-window rate limit (StrictThrottler, throws ThrottlingExhaustedError).

Timing out doesn't stop the operation — JS can't preempt it. When the timeout wins the race, the caller gets TimeoutExceededError, but operation() itself keeps running in the background, invisible to that caller. Releasing the scope's lock (or clone slot) the instant the timeout fires would let a brand-new caller start mutating the primary resource while that abandoned operation is still mutating it too — a real race, not a hypothetical one. So the lock/slot is held until the abandoned operation actually settles; a scope can stay "busy" for longer than its configured timeoutMs if an operation ignores its cancellation signal. That signal is the other half of the fix: every operation's third argument is an AbortSignal that fires the moment its attempt times out (or the caller's own options.signal aborts), so an operation that checks signal.aborted can cut its own work short instead of running pointlessly:

const result = await mgr.withScope('user:alice', async (res, isEphemeral, signal) => {
  for (const item of workItems) {
    if (signal.aborted) break;   // stop early instead of finishing a moot job
    res.set(item.key, item.value);
  }
}, 1000);

A caller can also cancel one specific call from the outside — while it's still queued (spliced out and rejected immediately) or while it's actively running (merged into the same signal the operation receives) — via withScope's { signal } option, rejecting with OperationCancelledError. That error is deliberately distinct from TimeoutExceededError, so a configured RetryLedger never mistakes an explicit cancellation for a retryable timeout:

const controller = new AbortController();
const work = mgr.withScope('user:alice', operation, 1000, { signal: controller.signal });
controller.abort(); // work rejects with OperationCancelledError, not retried

A scope can opt into automatic retry on timeout via an optional RetryLedger — off by default, since blindly retrying against a resource that's still contended can pile on load instead of relieving it:

import { ScopedResourceManager, ChainedHashTable, RetryLedger } from './shadowClone.js';

const retryLedger = new RetryLedger({ 'user:alice': 2 }); // up to 2 retries after a timeout, this scope only
const mgr = new ScopedResourceManager(new ChainedHashTable(), { resourceId: 'MemoryLobe', retryLedger });

On timeout, withScope() re-attempts the whole dispatch (fresh throttle check, fresh primary/clone/queue decision) for that scope, up to its configured count, before finally propagating TimeoutExceededError. Only timeouts are retried — any other thrown error propagates immediately.

A scope can also opt out of cloning entirely via NoCloneLedger — for an operation that needs to observe every prior write on its scope in order (a cloned, eventually-merged snapshot would be unsafe for it), this forces a busy call to always queue and wait for the primary lock, even when a clone slot is free:

import { NoCloneLedger } from './shadowClone.js';

const noCloneLedger = new NoCloneLedger({ 'user:alice': true }); // this scope never clones, only queues
const mgr = new ScopedResourceManager(new ChainedHashTable(), { resourceId: 'MemoryLobe', noCloneLedger });

Works with any resource object implementing shadowClone(scopeKey) -> object and drop(), not just ChainedHashTable — see makeShadowCloneable() for wrapping a plain value. Implement mergeShadow(shadow) on a custom resource too, or a clone's writes will be silently dropped on that resource instead of merged.

TieLedger weights are static by default, which means a low-weight scope can in principle wait behind sustained higher-weight traffic forever. Passing a nonzero agingRate adds a wait-time-proportional bonus to each queued task's effective priority, so eventual dequeue is guaranteed regardless of how much higher-weight traffic keeps arriving:

const ledger = new TieLedger({ SaveData: 100, ReadData: 10 }, 5); // +5 effective priority per ms waited
mgr.setQueueComparator(ledger.getComparator());

manager.getMetrics() returns a point-in-time snapshot — { queueLength, activeClones, maxConcurrentClones, lockedScopes, inFlight, shutDown } — cheap enough to call from a health-check endpoint. manager.shutdown(reason, { force }) now returns a Promise that resolves once every in-flight primary/clone has also settled, not just once the queue is drained; force: true additionally abort()s every in-flight operation's signal first (speeding up cooperative operations, though JS can't guarantee an uncooperative one stops any faster).

Decision flow

withScope() — the retry loop wrapping every attempt

The outermost layer. Each pass through the loop is one full _dispatch() attempt (own throttle check, own fresh primary/clone/queue decision) — only a TimeoutExceededError, and only up to the scope's RetryLedger-configured count, loops back for another attempt. Everything else propagates on the first pass.

flowchart TD
    Enter(["withScope(scopeKey, operation, timeoutMs)"]) --> Guard{"shut down, or bad<br/>scopeKey/operation/timeoutMs?"}
    Guard -- yes --> ErrArg["RuntimeInvariantError<br/>(fails before the loop starts)"]
    Guard -- no --> Init["attempt = 0<br/>maxRetries = retryLedger ? getMaxRetries(scopeKey) : 0"]
    Init --> Consume["throttler.consumeToken()<br/>(fresh check every attempt)"]
    Consume -- exhausted --> ErrThrottle["ThrottlingExhaustedError<br/>(not retried, even mid-loop)"]
    Consume -- ok --> Dispatch[["_dispatch(scopeKey, operation, timeoutMs)<br/>see single-attempt diagram below"]]
    Dispatch -- resolves --> Return(["return result"])
    Dispatch -- "throws TimeoutExceededError" --> CheckRetry{"attempt &lt; maxRetries?"}
    CheckRetry -- yes --> Inc["attempt += 1"]
    Inc --> Consume
    CheckRetry -- no --> ErrTimeout["propagate TimeoutExceededError"]
    Dispatch -- "throws anything else<br/>(RuntimeInvariantError, QueueOverflowError, ...)" --> ErrOther["propagate immediately, unretried"]

    classDef err fill:#fdf0f0,stroke:#8b1a1a,color:#8b1a1a;
    classDef result fill:#eef4fb,stroke:#2e5fa3,color:#1a2744;
    class ErrArg,ErrThrottle,ErrTimeout,ErrOther err;
    class Return result;
Loading

_dispatch() — single-attempt admission control

One pass of the loop above: decide primary vs. clone vs. queue, then race the chosen path against the timeout budget.

flowchart TD
    Start(["_dispatch(scopeKey, operation, timeoutMs)"]) --> Locked{"scopeKey already locked?"}
    Locked -- no --> Primary[["_runPrimary() — run on the real resource"]]
    Locked -- yes --> NoCloneCheck{"NoCloneLedger.isQueueOnly(scopeKey)?"}
    NoCloneCheck -- yes --> Enqueue{"_pendingQueue.length &gt;= maxQueueSize?"}
    NoCloneCheck -- no --> Slot{"activeClones &lt; maxConcurrentClones?"}
    Slot -- yes --> Clone[["_runClone() — run on a Shadow"]]
    Slot -- no --> Enqueue
    Enqueue -- yes --> ErrQueue["QueueOverflowError"]
    Enqueue -- no --> Wait["queued — waits its turn (FIFO or TieLedger)"]
    Wait -.->|slot frees| Locked
    Primary --> Race{"_race(): Promise.race(execution, timeoutMs, externalSignal)"}
    Clone --> Race
    Race -- "timeout: controller.abort() fires" --> ErrTimeout["TimeoutExceededError<br/>(bubbles to the retry loop)"]
    Race -- "caller's options.signal aborts" --> ErrCancel["OperationCancelledError<br/>(never retried)"]
    Race -- settles --> Done(["result, or the operation's own thrown error"])
    ErrTimeout --> Done
    ErrCancel --> Done

    classDef err fill:#fdf0f0,stroke:#8b1a1a,color:#8b1a1a;
    classDef result fill:#eef4fb,stroke:#2e5fa3,color:#1a2744;
    classDef wait fill:#fff8ee,stroke:#8b5a00,color:#8b5a00;
    class ErrQueue,ErrTimeout,ErrCancel err;
    class Done result;
    class Wait wait;
Loading

Zombie-safe release — the lock/slot outlives a losing race

_race() reports a TimeoutExceededError back to the caller the instant the timeout fires, but execution — the actual operation() call — is a separate promise that keeps running regardless, since JS can't preempt it. Releasing activeLocks/activeClones as soon as the race is lost (the pre-v5.3 behavior) would let a brand-new caller start mutating resource while that abandoned call is still mutating it too. Instead, release is wired to execution's own eventual settle, not to the race:

sequenceDiagram
    participant Caller
    participant Mgr as ScopedResourceManager
    participant Op as execution (the real operation call)

    Caller->>Mgr: withScope(scopeKey, operation, 10)
    Mgr->>Mgr: activeLocks.add(scopeKey)
    Mgr->>Op: operation(resource, false, controller.signal)
    Note over Op: still running — e.g. an un-cooperative 60ms operation

    Mgr->>Mgr: _race(): setTimeout(10ms) fires
    Mgr->>Op: controller.abort() — signal.aborted becomes true
    Mgr-->>Caller: reject(TimeoutExceededError)
    Note over Mgr: activeLocks still holds scopeKey — Op hasn't settled yet

    Note over Caller: caller has already moved on;<br/>a NEW withScope() call on scopeKey right now gets a clone, not a second primary

    Op-->>Mgr: Op finally settles (~60ms in, ignored its signal)
    Mgr->>Mgr: activeLocks.delete(scopeKey)
    Mgr->>Mgr: _processQueue()
    Note over Mgr: only now is scopeKey actually free
Loading

An operation that instead checks signal.aborted and returns promptly closes this window itself — the lock releases the moment it does, not timeoutMs later.

Shadow clone lifecycle — merge on success, discard on failure

The part that fixes the naive "clone is just a scratchpad" design: a clone's writes only reach the primary resource if its operation actually succeeds.

flowchart TD
    Spawn(["_runClone(): resource.shadowClone(scopeKey)"]) --> Run["operation(shadowRef, isEphemeral = true, signal)"]
    Run --> Outcome{"race settles within budget?"}
    Outcome -- "yes: resolves" --> Merge["resource.mergeShadow(shadowRef)\nlast-writer-wins, per dirty key"]
    Outcome -- "no: throws / times out / cancelled" --> Skip["writes discarded — merge skipped entirely<br/>(activeClones-- still waits for the real settle)"]
    Merge --> Drop["shadowRef.drop()"]
    Skip --> Drop
    Drop --> Return(["return the result, or propagate the error"])

    classDef ok fill:#f0faf2,stroke:#1a5c2a,color:#1a5c2a;
    classDef bad fill:#fdf0f0,stroke:#8b1a1a,color:#8b1a1a;
    class Merge ok;
    class Skip bad;
Loading

Out-of-scope guard

Every get/set on a Shadow — including the ones destined to be merged back — is checked against its own namespace before touching anything.

flowchart TD
    Call(["shadow.get(key)  /  shadow.set(key, value)"]) --> Check{"key === scopeKey OR key.startsWith(scopeKey + ':')?"}
    Check -- yes --> Exec["read/write against entries[]  (and mark key dirty on set)"]
    Check -- no --> Err["RuntimeInvariantError — out-of-scope access"]

    classDef err fill:#fdf0f0,stroke:#8b1a1a,color:#8b1a1a;
    class Err err;
Loading

Queue & TieLedger ordering

Once both the primary lock and every clone slot for a scope are exhausted, admission falls back to an explicit, ordered queue instead of rejecting the caller outright.

flowchart TD
    Enq(["task enqueued"]) --> Size{"_pendingQueue.length &gt;= maxQueueSize?"}
    Size -- yes --> Overflow["QueueOverflowError"]
    Size -- no --> Push["push {scopeKey, operation, enqueueTime, externalSignal}"]
    Push --> AbortListen{"externalSignal supplied?"}
    AbortListen -- yes --> Listen["listen for abort while queued"]
    AbortListen -- no --> Sort
    Listen --> Sort["_processQueue(): sort by comparator"]
    Listen -.->|"caller aborts before its turn"| Cancelled["splice out + OperationCancelledError<br/>(never runs)"]
    Sort --> Ledger{"TieLedger weights equal<br/>(after any aging bonus)?"}
    Ledger -- no --> Weighted["higher effective weight runs first"]
    Ledger -- yes --> Coinflip["coinflipComparator: Math.random() - 0.5"]
    Weighted --> Peek["peek front task"]
    Coinflip --> Peek
    Peek --> CanRun{"scope free OR clone slot free?"}
    CanRun -- yes --> Dequeue["shift + run as primary or clone"]
    CanRun -- no --> Hold["stays queued for the next drain"]

    classDef err fill:#fdf0f0,stroke:#8b1a1a,color:#8b1a1a;
    class Overflow,Cancelled err;
Loading

API

Export Purpose
ScopedResourceManager The core contention guard — withScope(scopeKey, operation, timeoutMs, {signal}), setQueueComparator(), getMetrics(), shutdown(reason, {force}) (returns a Promise), forceDropBaseTable().
ChainedHashTable / Shadow Default namespaced, shadow-cloneable key/value store.
makeShadowCloneable(value) Wraps a plain value so it satisfies the shadow-clone contract.
autoGuard(fns, managerOrOptions, options) Proxy-wraps a module's exports; guards antiLock_-prefixed functions by default, or all of them with guardAll: true.
secureModule(rawModule, config) One-line whole-module guarding — autoGuard + TieLedger + ScopedResourceManager, pre-wired.
TieLedger(priorityWeights, agingRate) / coinflipComparator Weighted queue ordering, with a random tie-break when weights match. agingRate (default 0) adds a wait-time-proportional bonus so a low-weight scope isn't starved forever.
RetryLedger Opt-in, per-scope retry-on-timeout policy — getMaxRetries(scopeKey). Off by default. Never triggered by OperationCancelledError, only TimeoutExceededError.
NoCloneLedger Scopes that must always queue instead of cloning — isQueueOnly(scopeKey). Off by default.
explicitTry(fn) async (fn) -> { ok, data, error } — Result-envelope wrapper; never throws.
RuntimeInvariantError, ThrottlingExhaustedError, TimeoutExceededError, QueueOverflowError The typed error taxonomy. Every failure is a thrown, typed error — never a boolean flag or a silently dropped write.
OperationCancelledError Thrown when a caller-supplied withScope(..., {signal}) fires before the call settles — while queued or while running. Distinct from TimeoutExceededError so it's never retried.
EphemeralMemoryExhaustedError Deprecated since v4.0 — hitting maxConcurrentClones now enqueues instead of rejecting. Kept only so older imports don't break; nothing in this module throws it anymore.
djb2Hash(key, size) The hash function backing ChainedHashTable's bucket placement. Exported mainly for testing/introspection, not routine use.
VERSION The module's version string ("5.3.0"), also the default export's .VERSION.

Use cases

  • In-memory caches or stores accessed by concurrent async handlers where a hot key's reads/writes shouldn't block each other.
  • Multi-tenant or multi-user in-process state (per-user session data, per-room game state) where "scope" is naturally a user/room ID.
  • Wrapping an existing module you don't want to hand-instrument: secureModule() guards every export in one call.
  • Producer/consumer patterns where some operations must be prioritized over others under load (TieLedger weights, optionally aged to prevent starvation).
  • Long-running or user-cancellable operations (e.g. a request aborted by a disconnecting client) where the caller needs to cancel one specific call — via withScope's { signal } option — without tearing down the whole manager.

Not a fit for: cross-process or cross-machine coordination (this is single-process, in-memory only), or state that can't be deep-cloned cheaply (huge objects, functions, other structuredClone-incompatible values).

Concurrency model, precisely

Because callers on the same scope can be interleaved across a primary and one or more clones, the merge-back is last-writer-wins per key, not full transactional isolation — that's the deliberate tradeoff this module makes in exchange for never blocking a caller. If two operations concurrently write the same key in the same scope, whichever one's clone (or primary) finishes and merges last wins; there's no conflict detection or rollback. If you need strict serializability for a given scope, this module is not the right tool — use a real lock or a transactional store instead.

The sequence below is the concrete case: Caller A holds the primary lock on user:alice and is mid-write when Caller B arrives on the same scope. B doesn't wait — it gets a Shadow cloned from the state at that instant. A finishes first and writes directly to the primary; B finishes after and merges last. The result is B's value, even though A's write happened later in real time — because "wins" here means merges last, not executes last.

sequenceDiagram
    participant A as Caller A
    participant Mgr as ScopedResourceManager
    participant Res as primary resource
    participant B as Caller B
    participant Shad as Shadow (B's clone)

    A->>Mgr: withScope("user:alice", opA, 1000)
    Mgr->>Res: activeLocks.add("user:alice")
    Mgr->>Res: opA(resource, isEphemeral=false)
    Note over Res: opA reads x=0, preparing to write x=1

    B->>Mgr: withScope("user:alice", opB, 1000)
    Note over Mgr: scope already locked -> spawn a clone instead of waiting
    Mgr->>Res: resource.shadowClone("user:alice")
    Res-->>Shad: deep copy of in-scope entries (x=0 at this instant)
    Mgr->>Shad: opB(shadowRef, isEphemeral=true)
    Shad->>Shad: set(x, 2) — key "x" marked dirty

    Res->>Res: opA finishes: set(x, 1)
    Mgr->>Mgr: activeLocks.delete("user:alice")
    Note over Res: primary now holds x=1

    Shad->>Mgr: opB resolves
    Mgr->>Res: resource.mergeShadow(shadowRef)
    Res->>Res: set(x, 2) — B's dirty entry overwrites A's x=1
    Note over Res: final state: x=2 — B "wins" by merging last,<br/>not by writing last in real time
    Mgr->>Shad: shadowRef.drop()
Loading

If opA had instead thrown or timed out, its write to the primary already happened directly (primaries write in place, no merge step) — only clone writes are conditional on success. That asymmetry is why a scope that must observe every prior write in strict order belongs on NoCloneLedger, not left to clone under contention.

Testing

npm test

Runs the regression suite (test/shadowClone.test.mjs, Node's built-in test runner, 57 tests) covering: primary read/write, clone merge-back on success, clone write discard on failure, out-of-scope guarding (including that a clone never internally carries another scope's data), hash-bucket collisions, uncloneable-value and circular-reference handling, throttling and its window rollover, timeouts and RetryLedger retry-on-timeout, NoCloneLedger forcing a scope to always queue instead of cloning, queue overflow and recovery, constructor/argument fail-fast validation, autoGuard/secureModule wrapping (including the Proxy's non-function passthrough and custom scopeKeyFn), TieLedger weighted ordering, and explicitTry — plus, new in v5.3.0: a timed-out primary keeping its scope locked until the abandoned operation truly settles (so a same-scope call right after gets a clone, not a second primary), the operation's AbortSignal firing on timeout, withScope({signal}) cancelling one specific active or still-queued call with OperationCancelledError (and never being retried by a RetryLedger), TieLedger aging overtaking a static weight, getMetrics()'s snapshot shape, and shutdown() both waiting for in-flight work to settle and ({force: true}) aborting it early.

License

MIT

About

Lock-free contention guard for shared JS state: clones a scoped copy for busy resources instead of blocking or rejecting, merging writes back on success.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages