Skip to content

@studnicky/keyed-work-gate

Keyed single-flight and serialized work gate composing @studnicky/mutex and @studnicky/concurrency's Coalesce.

Install

bash
pnpm add @studnicky/keyed-work-gate

@studnicky/keyed-work-gate exposes KeyedWorkGate at its root and configuration contracts at @studnicky/keyed-work-gate/interfaces.

Usage

KeyedWorkGate performs no work itself — the caller's fn is the unit of work being gated. runSingleFlight collapses concurrent callers requesting the identical key into one execution via Coalesce; runSerialized bypasses coalescing entirely and routes directly through Mutex, so every call actually runs:

ts
import { Coalesce } from '@studnicky/concurrency';
import { CoalesceOptionsEntity } from '@studnicky/concurrency/entities';
import { Mutex } from '@studnicky/mutex';
import assert from 'node:assert/strict';
import { setTimeout } from 'node:timers/promises';

import { KeyedWorkGate } from '../src/index.js';

class TelemetryMutex extends Mutex<string> {
  readonly acquisitions: string[] = [];

  protected override afterAcquire(key: string, waitTimeMs: number): void {
    console.log(`[mutex] acquired '${key}' after ${waitTimeMs}ms wait`);
    this.acquisitions.push(key);
  }

  protected override onEnterKey(key: string, to: 'locked' | 'queued' | 'unlocked', from: 'locked' | 'queued' | 'unlocked'): void {
    console.log(`[mutex] '${key}' ${from} -> ${to}`);
  }
}

class TelemetryCoalesce extends Coalesce<unknown> {
  readonly leaders: string[] = [];
  readonly joiners: string[] = [];

  protected override onCoalesceStart(key: string): void {
    console.log(`[coalesce] '${key}' leader executing`);
    this.leaders.push(key);
  }

  protected override onCoalesceJoin(key: string): void {
    console.log(`[coalesce] '${key}' caller joined in-flight execution`);
    this.joiners.push(key);
  }
}

/**
 * Advanced extension: KeyedWorkGate has no hooks of its own — observability is
 * delegated entirely to the composed primitives. A subclass can still add
 * convenience behavior by reaching the composed instances through the getters.
 */
class ReportingKeyedWorkGate extends KeyedWorkGate<string> {
  readonly #telemetryCoalesce: TelemetryCoalesce;
  readonly #telemetryMutex: TelemetryMutex;

  static tracked(mutex: TelemetryMutex, coalesce: TelemetryCoalesce): ReportingKeyedWorkGate {
    const result = new ReportingKeyedWorkGate(mutex, coalesce);
    return result;
  }

  protected constructor(mutex: TelemetryMutex, coalesce: TelemetryCoalesce) {
    super({ 'coalesce': coalesce, 'mutex': mutex });
    this.#telemetryCoalesce = coalesce;
    this.#telemetryMutex = mutex;
  }

  report(): { 'coalesceJoins': number; 'coalesceLeaders': number; 'mutexAcquisitions': number } {
    return {
      'coalesceJoins': this.#telemetryCoalesce.joiners.length,
      'coalesceLeaders': this.#telemetryCoalesce.leaders.length,
      'mutexAcquisitions': this.#telemetryMutex.acquisitions.length
    };
  }
}

Try it

Loading example…

The output shows three concurrent runSingleFlight callers for user-42 collapsing into one mutex acquisition and one Coalesce leader with two joiners, then runSerialized calls against the same key each actually running rather than sharing a result.

Composition order: why Coalesce falls through to Mutex

runSingleFlight routes through Coalesce first, and the Coalesce factory itself acquires the Mutex before running fn. This order is a deliberate, non-obvious sequencing decision — not interchangeable with mutex-first:

  1. Coalesce first collapses concurrent callers requesting the identical key into a single execution — every caller in the group observes the same result, and fn runs exactly once for the whole group.
  2. Mutex fall-through still guards that one execution against unrelated exclusive work on the same key from a different call path — specifically, a concurrent runSerialized call against the same key. Coalescing only dedupes callers within runSingleFlight; it does nothing to protect the key against other call paths, so the mutex is what keeps the coalesced leader mutually exclusive against that other work.

Reversing the order (mutex-first, then coalesce) would defeat single-flight collapsing: every caller would separately queue for the lock before coalescing ever got a chance to join them, so coalescing would only ever see one queued caller at a time and would never actually collapse concurrent duplicates.

Composition contract

KeyedWorkGate introduces no hook of its own — every observable stage is already covered by the primitive it delegates to. Each composed primitive accepts either a pre-built instance (subclassed or not) or the config shape passed straight to that primitive's own create():

Config keyAcceptsDefault
mutexMutex<K> instance or Partial<MutexConfigEntity.Type>Mutex.create()
coalesceCoalesce<TResult> instance or CoalesceOptionsEntity.TypeCoalesce.create<TResult>()

Callers who supply subclassed Mutex or Coalesce instances retain those instances and inspect their hook state directly. The gate keeps its delegates private and does not duplicate stages already covered by a composed primitive.

KeyedWorkGate does not invent its own staleness ceiling for coalesced calls — that gap stays with Coalesce itself, configured via its own timeout option.

When this composition tips into orchestration

KeyedWorkGate gates a single unit of work per key. It has no concept of a node, a graph, or a dependency between multiple keyed calls. Once a workflow needs to coordinate the outcome of one keyed call to decide whether or how to run a second one — branching, fan-out across dependent keys, checkpoint/resume, or cross-call retry budgets — that is workflow orchestration, not a loop of KeyedWorkGate calls glued together by hand.

Documentation

Full reference: https://studnicky.github.io/substrate/packages/keyed-work-gate

Interfaces

@studnicky/keyed-work-gate/interfaces exports keyed work-gate configuration contracts.

typescript
import type { KeyedWorkGateConfigInterface } from '@studnicky/keyed-work-gate/interfaces';

Exports

SymbolPurposeImport path
KeyedWorkGateSerializes and coalesces work independently for each key.@studnicky/keyed-work-gate

Source on GitHub