Skip to content

Recipe 45: Distributed Counter

What You Build

A counter shared across cells. Two tasklets running on different machines acquire the same grafos_sync::FabricMutex<u64> in leased memory, increment the protected value, and release it. Lease expiry is the synchronization point: if a holder disappears, the fabric lease expires and the counter can be acquired again by a healthy holder.

Source

cookbook/recipe-45-distributed-counter/ in the source tree.

The recipe includes the deterministic tasklet core and a real FabricMutex helper. The tasklet takes a { "current": N } JSON document and returns { "previous": N, "new_value": N+1 }. The fabric helper increments a FabricMutex<u64> directly:

Core grafOS API Path

The counter is not a special counter service. It is a value in leased memory protected by grafos_sync::FabricMutex:

use grafos_std::mem::MemBuilder;
use grafos_sync::FabricMutex;
let lease = MemBuilder::new()
.min_bytes(4096)
.lease_secs(300)
.acquire()?;
let counter = FabricMutex::new(lease, 0, 41u64)?;
let mut guard = counter.lock(7, 100)?;
let previous = *guard;
*guard = guard.saturating_add(1);
let new_value = *guard;
guard.unlock()?;
assert_eq!((previous, new_value), (41, 42));
assert_eq!(counter.generation()?, 1);
# Ok::<(), grafos_std::FabricError>(())

The helper in the recipe crate keeps that path reusable:

pub fn compute(input: &[u8]) -> Result<CounterOutput, &'static str> {
let parsed: CounterInput = serde_json::from_slice(input).map_err(|_| "invalid_input")?;
let new_value = parsed.current.checked_add(1).ok_or("counter_overflow")?;
Ok(CounterOutput { previous: parsed.current, new_value })
}
pub fn increment_fabric_counter(
counter: &FabricMutex<u64>,
holder_id: u128,
max_attempts: u32,
) -> FabricResult<CounterOutput> {
let mut guard = counter.lock(holder_id, max_attempts)?;
let previous = *guard;
*guard = guard.saturating_add(1);
let new_value = *guard;
guard.unlock()?;
Ok(CounterOutput { previous, new_value })
}

What’s interesting

  1. Typed errors. invalid_input and counter_overflow are returned as typed strings rather than Box<dyn Error>. The runtime trap path lets you map any Err(_) to a non-zero return code.
  2. The compute() separation. The host-testable function does no I/O; the wasm32 entry point handles pointer marshaling. Same pattern as Recipe 44.
  3. Real fabric path. increment_fabric_counter uses grafos_sync::FabricMutex<u64> instead of a prose-only counter API.

Failure Behavior

  • Invalid JSON returns invalid_input.
  • u64::MAX returns counter_overflow from the tasklet core.
  • A busy or expired fabric mutex returns the underlying grafos_std::FabricError; callers should retry with a new holder id only after they know the previous holder lost or released authority.
  • Saturating arithmetic in the fabric helper prevents wraparound if a long-lived fabric counter reaches u64::MAX.

Run And Verify

Terminal window
cargo test -p cookbook-recipe-45-distributed-counter

Expected: the tests cover increment from zero, overflow rejection, invalid input rejection, and the real FabricMutex helper path.

Adapt It

Change the initial mutex value, holder id convention, and retry budget to match the workload. Keep holder ids stable enough to debug ownership transitions, and expose the mutex generation counter in logs when the counter is used for coordination.