Guide

Contention and Weighted Capacity

Put weighted capacity at the provider and choose durable Wait or typed FailFast per Input placement.

Attenuation decides whether a claim is inside delegated authority. Capacity answers the next question: may this admitted claim enter now?

External systems are finite. A model endpoint has a concurrency limit; a renderer has a fixed GPU budget; a database pool has only so many connections. Hiding that constraint inside an Activity handler turns contention into an invisible timing accident. Motive gives it an authored place in the Resource topology.

Capacity belongs to the provider occurrence

A Resource defines the kind of capability being claimed. The exact provide(...) occurrence owns the capacity available at one place in the chart:

provide(RenderPools, {
  capacity: ({ state }) => Resource.Capacity.make(state.capacity),
});

Capacity may be derived from chart state, so an accepted event can change it durably without changing the Resource contract or rewriting existing claims. Two providers of the same Resource may publish different limits because they represent different external pools.

Capacity is counted in positive PermitCount values. Every claim asks for one permit by default; RenderPool.acquire("gpu", { permits: 2 }) records one two-permit claim. Its weight remains attached to that exact durable claim—it does not become two unrelated acquisitions.

The Input placement chooses contention policy

The provider owns how much can run. The Activity Input placement owns what this particular use should do when enough capacity exists in principle but is occupied now.

WaitRenderPool.Input.pipe(Resource.wait)

Publish one durable FIFO waiter. Do not acquire or invoke the Activity yet.

FailFast · defaultRenderPool.Input

Refuse immediately with typed ResourceUnavailable evidence.

This choice belongs to the placement rather than the Resource as a whole. A background render and an interactive preview may use the same RenderPool, key, and provider while choosing different contention behavior.

Follow both policies against one weighted pool:

Weighted admission

Admission happens before acquisition.

Watch one two-permit claim wait behind a holder, enter after capacity grows, and compare an immediate typed refusal.

1 · Give one claim a weight

Permit counts are part of every Resource claim.

RenderPool.acquire requests one positive PermitCount by default and can request more explicitly. Resource.wait belongs to this Activity Input placement, so RenderFrame asks to join durable admission rather than deciding contention inside its handler.

import { Activity, Resource, State, Statechart, States } from "@motive/motive";
import { Schema } from "effect";

export const RenderPool = Resource.make("RenderPool", {
  key: Schema.String,
  resource: Schema.Struct({ worker: Schema.String }),
});

const WaitingPool = RenderPool.Input.pipe(Resource.wait);

export const RenderFrame = Activity.make("RenderFrame", {
  input: Schema.Struct({ pool: WaitingPool }),
  success: Schema.Void,
});

export class RenderJob extends State.Compound<RenderJob>()("RenderJob") {
  static states = States.make(() => [Idle]);
}

class Idle extends State.Atomic<Idle>()("Idle") {}

export const RenderJobChart = Statechart.make(RenderJob);

2 · Let the provider own capacity

A waiting claim survives until capacity admits it.

RenderPools publishes capacity 2, and HoldRenderPool owns both permits before the two-permit render claim arrives. That claim enters Waiting without acquiring a process-local worker; raising capacity to 4 admits the same Activity attempt as Running.

import { Activity, Resource, State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";

export const RenderPool = Resource.make("RenderPool", {
  key: Schema.String,
  resource: Schema.Struct({ worker: Schema.String }),
});
const RenderPools = RenderPool.as("RenderPools");

const WaitingPool = RenderPool.Input.pipe(Resource.wait);

export class StartWaiting extends Schema.TaggedClass<StartWaiting>()("StartWaiting", {}) {}
export class SetCapacity extends Schema.TaggedClass<SetCapacity>()("SetCapacity", {
  capacity: Resource.CapacityCount,
}) {}
const ImmediatePool = RenderPool.Input;

const HoldRenderPool = Activity.make("HoldRenderPool", {
  input: Schema.Struct({ pool: ImmediatePool }),
  success: Schema.Void,
});

export const RenderFrame = Activity.make("RenderFrame", {
  input: Schema.Struct({ pool: WaitingPool }),
  success: Schema.Void,
});

export class RenderJob extends State.Compound<RenderJob>()("RenderJob", {
  capacity: Resource.CapacityCount,
}) {
  static states = States.make(() => [Idle, Queued, Complete, Denied]);

  static get transitions() {
    return Transitions.make(this, ({ on, provide }) => [
      provide(RenderPools, {
        capacity: ({ state }) => Resource.Capacity.make(state.capacity),
      }),
      on(SetCapacity).update(this, ({ event }) => ({
        capacity: event.capacity,
      })),
    ]);
  }
}

class Idle extends State.Atomic<Idle>()("Idle") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(StartWaiting, Queued),
  ]);
}

class Queued extends State.Atomic<Queued>()("Queued") {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(HoldRenderPool, () => ({
      pool: RenderPool.acquire("gpu", { permits: 2 }),
    })),
    invoke(RenderFrame, () => ({
      pool: RenderPool.acquire("gpu", { permits: 2 }),
    })),
    on(HoldRenderPool.Done),
    on(HoldRenderPool.Error, Denied, ({ event }) => event.error),
    on(RenderFrame.Done, Complete),
    on(RenderFrame.Error, Denied, ({ event }) => event.error),
  ]);
}

class Complete extends State.Done<Complete>()("Complete") {}

class Denied extends State.Error<Denied>()("Denied", Resource.Unavailable(RenderPool)) {}

export const RenderJobChart = Statechart.make(RenderJob, {
  input: Schema.Struct({ capacity: Resource.CapacityCount }),
  init: ({ input }) => [new RenderJob(input)],
});

3 · Choose refusal at the placement

FailFast reports typed Busy instead of joining the queue.

PreviewFrame uses the same RenderPool contract through its default FailFast Input placement. With both permits occupied, its claim never waits and no provider binding runs; ResourceUnavailable carries the key, path, permits, and Busy reason into Denied.

import { Activity, Resource, State, Statechart, States, Transitions } from "@motive/motive";
import { Schema } from "effect";

export const RenderPool = Resource.make("RenderPool", {
  key: Schema.String,
  resource: Schema.Struct({ worker: Schema.String }),
});
const RenderPools = RenderPool.as("RenderPools");

const WaitingPool = RenderPool.Input.pipe(Resource.wait);

export class StartWaiting extends Schema.TaggedClass<StartWaiting>()("StartWaiting", {}) {}
export class TryImmediately extends Schema.TaggedClass<TryImmediately>()("TryImmediately", {}) {}
export class SetCapacity extends Schema.TaggedClass<SetCapacity>()("SetCapacity", {
  capacity: Resource.CapacityCount,
}) {}
const ImmediatePool = RenderPool.Input;

const HoldRenderPool = Activity.make("HoldRenderPool", {
  input: Schema.Struct({ pool: ImmediatePool }),
  success: Schema.Void,
});
const QueuedHolder = HoldRenderPool.as("QueuedHolder");
const CheckingHolder = HoldRenderPool.as("CheckingHolder");

export const RenderFrame = Activity.make("RenderFrame", {
  input: Schema.Struct({ pool: WaitingPool }),
  success: Schema.Void,
});
export const PreviewFrame = Activity.make("PreviewFrame", {
  input: Schema.Struct({ pool: ImmediatePool }),
  success: Schema.Void,
});

export class RenderJob extends State.Compound<RenderJob>()("RenderJob", {
  capacity: Resource.CapacityCount,
}) {
  static states = States.make(() => [Idle, Queued, Checking, Complete, Denied]);

  static get transitions() {
    return Transitions.make(this, ({ on, provide }) => [
      provide(RenderPools, {
        capacity: ({ state }) => Resource.Capacity.make(state.capacity),
      }),
      on(SetCapacity).update(this, ({ event }) => ({
        capacity: event.capacity,
      })),
    ]);
  }
}

class Idle extends State.Atomic<Idle>()("Idle") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(StartWaiting, Queued),
    on(TryImmediately, Checking),
  ]);
}

class Queued extends State.Atomic<Queued>()("Queued") {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(QueuedHolder, () => ({
      pool: RenderPool.acquire("gpu", { permits: 2 }),
    })),
    invoke(RenderFrame, () => ({
      pool: RenderPool.acquire("gpu", { permits: 2 }),
    })),
    on(QueuedHolder.Done),
    on(QueuedHolder.Error, Denied, ({ event }) => event.error),
    on(RenderFrame.Done, Complete),
    on(RenderFrame.Error, Denied, ({ event }) => event.error),
  ]);
}

class Checking extends State.Atomic<Checking>()("Checking") {
  static transitions = Transitions.make(this, ({ invoke, on }) => [
    invoke(CheckingHolder, () => ({
      pool: RenderPool.acquire("gpu", { permits: 2 }),
    })),
    invoke(PreviewFrame, () => ({ pool: RenderPool.acquire("gpu") })),
    on(CheckingHolder.Done),
    on(CheckingHolder.Error, Denied, ({ event }) => event.error),
    on(PreviewFrame.Done, Complete),
    on(PreviewFrame.Error, Denied, ({ event }) => event.error),
  ]);
}

class Complete extends State.Done<Complete>()("Complete") {}

class Denied extends State.Error<Denied>()("Denied", Resource.Unavailable(RenderPool)) {}

export const RenderJobChart = Statechart.make(RenderJob, {
  input: Schema.Struct({ capacity: Resource.CapacityCount }),
  init: ({ input }) => [new RenderJob(input)],
});

Wait publishes durable queue membership

In the waiting branch, HoldRenderPool owns both available permits before RenderFrame requests two more. The request could fit at this provider, but not yet. Motive records the Activity attempt as Waiting without calling the Resource provider's acquire binding or the Activity handler.

When capacity rises from two to four, the same attempt becomes Running and receives its grant. Recovery can reproduce that decision because the waiter, its weight, its owning Activity occurrence, and the capacity-changing event are durable facts.

Weighted waiters are strict FIFO. A four-permit claim at the head may hold a later one-permit claim even while one permit is free. This avoids allowing a stream of small claims to starve an earlier large one. Leaving the Activity's owning state withdraws its waiter durably.

Lowering capacity does not revoke grants already in use. It prevents new admission until releases bring usage back within the provider's current limit.

FailFast makes immediate refusal typed

Plain RenderPool.Input uses FailFast. When the pool could satisfy the preview's one-permit claim but its permits are occupied, the Activity settles through ResourceUnavailable with reason Busy. The evidence retains the Resource tag, key, Input field path, and requested permits. Neither the provider binding nor the Activity handler runs.

FailFast is useful where joining a queue would make the result stale: interactive previews, best-effort refreshes, speculative work, or calls whose caller already owns retry policy.

Busy and impossible are different refusals

Busy means the provider's capacity is sufficient for the claim, but current grants occupy it. A later release or capacity increase could admit the same request.

ExceedsCapacity means the claim itself is larger than the provider's current limit. A three-permit request against capacity two cannot enter even when the pool is empty. Motive refuses that request for both Wait and FailFast placements; queueing cannot make an impossible claim fit.

Test the admission boundary

  1. Hold two permits at capacity two, publish a two-permit Wait claim, and verify it is Waiting.
  2. Verify neither Resource acquisition nor the Activity handler begins while the claim waits.
  3. Raise capacity to four and verify the same Activity attempt becomes Running.
  4. Repeat with a FailFast placement and verify typed Busy evidence with no waiter.
  5. Request three permits at capacity two and verify ExceedsCapacity, even under Wait.
  6. Queue claims of different weights and verify strict FIFO ordering.
  7. Exit a waiting claim's owning state and verify its durable queue membership is withdrawn.

Next, Resource Settlement and Retention follows an admitted claim through success and asks whether its durable Resource identity should remain available or be consumed.