Shahzad Bhatti Welcome to my ramblings and rants!

October 16, 2026

Flow Control Without a Broker: Overlooked Fallacies of Distributed Computing

Filed under: Computing — admin @ 8:44 pm

Backpressure, AIMD concurrency, and store-and-forward for actors with only local resources.

TL;DR: The classic fallacies of distributed computing cover the network miss critical two fallacies: that you have unlimited capacity to serve requests, and that the other party is available (temporal dependence). Actor-based flow control handles both with bounded mailboxes, admission control, AIMD concurrency limits, and a DB-backed outbox.


1. Missing Fallacies

You must have heard of the famous seven fallacies from Peter Deutsch that give hard lessons for every distributed systems programmer in understanding the networks, latency, bandwidth, topology, transport cost, etc. However, they miss a couple of other key fallacies, i.e.,

1.1 The capacity fallacy: You have infinite capacity to serve all requests

This is similar to the infinite bandwidth fallacy but instead of network capacity, it focuses on the application level capacity. For example, assume a leader actor connected to 1 million IoT workers that all try to request the leader for some resource at the same time in a style of thundering herd. When working in a cloud or SaaS environment, the usual fixes assume shared infrastructure like rate limiting at a load balancer, queuing behind Kafka, shedding load at an API gateway (see Effective Load Shedding and Throttling Strategies for Managing Traffic Spikes and DDoS Attacks). But you can’t use these shared infrastructure when working in edge deployments, IoT fleets, or on-prem systems.

1.2 The availability fallacy: The other party is available when you send (Temporal Dependence)

Standard flow control assumes both sides are alive and reachable. I described CSP channels in my prior blog on Structured Concurrency with CSP and Actors, where CSP channels give you backpressure for free, but only while both parties are active. Actor mailboxes on the other hand, decouple sender and receiver in time. However, by default they can grow without bound when one side goes silent. A remote device or a worker may go offline for hours so leader can’t send it new messages. You have availability problems both on the producer side and on the consumer side where you may need to queue incoming/outgoing messages in-memory. When the worker reconnects, it may get 100,000 stale messages or the leader may simply lose messages because they were all in memory. WhatsApp built chat services with Erlang’s actor model where it stored messages on the server until the recipient came online. It used fire-and-forget for delivery receipts with a reliable fallback.

PlexSpaces framework that I built is based on actors model where every actor maintains private state and processes messages sequentially. It supports following fundamental communication patterns:

  • Tell (asynchronous): The sender dispatches a message and moves on.
  • Ask (request-reply): The sender dispatches a request and waits for a response with a timeout.
  • Tell with Outbox fallback (asynchronous): The sender attempts to dispatch a message and falls back to an outbox when the other part is not available.

1.3 Why the usual tools don’t fit

SolutionBreaks when…
Kafka / NATSIoT devices can’t run a broker due to lack of shared infrastructure
Load balancerInternal actor fan-out bypasses LB
API gatewayPeer-to-peer actor communication without centralized entry point
Shared DB queueWorker is offline or disconnected due to single point of failure

The actor model provides a flow control that works with local resources.

2. The Design: Seven Mechanisms, Three Layers

PlexSpaces implements flow control as three layers that uses local resources:

2.1 Bounded Mailboxes:

Every actor mailbox has a configurable capacity and an error strategy, which decides what happens when the mailbox is full:

  • Error returns MailboxError::Full with a retry_after_ms hint.
  • DropOldest discards the oldest queued message.
  • DropNewest discards the incoming one.

This Error allows flow control instead of silent data loss so that the sender can route to the outbox instead of spinning. Unbounded queue are generally considered harmful because they hide problems, eat memory, and eventually crash.

2.2 Backpressure

PlexSpaces actors follow the Erlang model where each actor processes one message at a time. This means that there’s no concurrency between messages inside an actor. Erlang mailbox is unbounded by default, which can result in OOM crash but PlexSpaces framework supports backpressure at enqueue. When an actor can’t keep up, its mailbox fills up and tell() returns MailboxFull immediately that maps to DeliveryError::Capacity enum. The sender can recognize this error as a capacity signal and take proper action like use the outbox relay to retry later.

crates/outbox/src/outbox_relay.rs:
#[derive(Debug, thiserror::Error)]
pub enum DeliveryError {
/// Receiver at capacity (mailbox full). Triggers AIMD multiplicative decrease.
#[error(“RESOURCE_EXHAUSTED: {message} (retry_after_ms={retry_after_ms})”)]
Capacity { message: String, retry_after_ms: u64 },
/// Transient failure. AIMD limit unchanged — not a congestion signal.
#[error(“{0}”)]
Transient(String),
}

On the WASM path, MailboxFull is mapped in crates/application/src/wasm_message_sender.rs:

registry
    .tell(&ctx, &to_id, msg)
    .await
    .map_err(|e| match e {
        ActorRegistryError::MailboxFull { retry_after_ms, .. } => {
            format!("RESOURCE_EXHAUSTED: mailbox full (retry_after_ms={retry_after_ms})")
        }
        other => other.to_string(),
    })

The AIMD loop in outbox_host.rs checks RESOURCE_EXHAUSTED error for retrying later.

2.3 AIMD – TCP Congestion Control

Instead of using static configuration for concurrency limit, which may result in wastes capacity or overload, PlexSpaces uses Additive Increase / Multiplicative Decrease (AIMD) algorithm, which is typically used for the TCP congestion control:

  • On successful delivery: limit += 1 (additive increase)
  • On a capacity rejection (mailbox full): limit /= 2 (multiplicative decrease)

The implementation, from crates/outbox/src/concurrency_limiter.rs:

/// Acquire a permit. Returns None if at capacity (non-blocking).
pub fn try_acquire(&self) -> Option<Permit<'_>> {
    loop {
        let current = self.in_flight.load(Ordering::Acquire);
        let limit = self.current_limit.load(Ordering::Acquire);
        if current >= limit {
            metrics::counter!("plexspaces_outbox_limiter_acquire_rejected_total").increment(1);
            return None;    // caller must back off
        }
        if self.in_flight.compare_exchange_weak(
            current, current + 1, Ordering::AcqRel, Ordering::Acquire
        ).is_ok() {
            return Some(Permit { limiter: self });
        }
    }
}

/// Delivery succeeded — additive increase: limit += 1, capped at max_limit.
pub fn on_success(&self, _permit: Permit<'_>) {
    metrics::counter!("plexspaces_outbox_limiter_success_total").increment(1);
    if matches!(self.strategy, ConcurrencyStrategy::ConcurrencyStrategyAimd) {
        loop {
            let current = self.current_limit.load(Ordering::Acquire);
            let new = (current + 1).min(self.max_limit);
            if self.current_limit.compare_exchange_weak(
                current, new, Ordering::AcqRel, Ordering::Acquire
            ).is_ok() {
                metrics::gauge!("plexspaces_outbox_limiter_current_limit").set(new as f64);
                break;
            }
        }
    }
}

/// Delivery rejected (RESOURCE_EXHAUSTED) — multiplicative decrease: limit /= 2, floored at min_limit.
pub fn on_rejection(&self, _permit: Permit<'_>) {
    metrics::counter!("plexspaces_outbox_limiter_rejection_total").increment(1);
    if matches!(self.strategy, ConcurrencyStrategy::ConcurrencyStrategyAimd) {
        loop {
            let current = self.current_limit.load(Ordering::Acquire);
            let new = (current / 2).max(self.min_limit);
            if self.current_limit.compare_exchange_weak(
                current, new, Ordering::AcqRel, Ordering::Acquire
            ).is_ok() {
                metrics::gauge!("plexspaces_outbox_limiter_current_limit").set(new as f64);
                break;
            }
        }
    }
}

Both on_success and on_rejection are lock-free CAS loops too. Configuration (from examples/rust/embedded/job_queue/src/main.rs):

let outbox_config = OutboxConfig {
    concurrency: Some(ConcurrencyConfig {
        initial_limit: 10,   // start here
        min_limit: 1,        // never go below 1 (AIMD floor)
        max_limit: 50,       // never exceed 50 (AIMD ceiling)
        strategy: ConcurrencyStrategy::ConcurrencyStrategyAimd as i32,
    }),
    retry_policy: Some(default_retry_policy()),
    relay_interval_ms: 5,    // drain every 5ms
    relay_batch_size: 20,    // up to 20 messages per drain cycle
    ..Default::default()
};

let limiter = Arc::new(ConcurrencyLimiter::new(
    outbox_config.concurrency.as_ref().unwrap(),
));

Or use the SDK helper for defaults (initial=10, min=1, max=100):

use plexspaces_sdk::default_outbox_limiter;
let limiter = default_outbox_limiter();  // Arc<ConcurrencyLimiter>

2.4 Outbox Relay: Closing the Feedback Loop

The outbox relay is where the mailbox’s capacity signal and AIMD connect. Here’s the hot path, process_message, from crates/outbox/src/outbox_relay.rs:

async fn process_message(&self, msg: Message) {
    let msg_id = msg.id.clone();

    // 1. Acquire AIMD permit — non-blocking. If at limit, nack and skip.
    let permit = match self.limiter.try_acquire() {
        Some(p) => p,
        None => {
            self.channel.nack(&msg_id, true, None).await.ok();  // requeue, no delay
            return;
        }
    };

    let t0 = std::time::Instant::now();
    match self.delivery.deliver(&msg).await {
        Ok(()) => {
            // 2a. Success ? additive increase: limit += 1
            metrics::histogram!("plexspaces_outbox_relay_delivery_seconds")
                .record(t0.elapsed().as_secs_f64());
            metrics::counter!("plexspaces_outbox_relay_delivered_total").increment(1);
            self.limiter.on_success(permit);   // limit++
            self.channel.ack(&msg_id).await.ok();
        }
        Err(err) => {
            match &err {
                DeliveryError::Capacity { .. } => {
                    // 2b. Mailbox full ? typed Capacity error ? multiplicative decrease: limit /= 2
                    metrics::counter!("plexspaces_outbox_relay_backpressure_total").increment(1);
                    self.limiter.on_rejection(permit);   // limit /= 2
                }
                DeliveryError::Transient(_) => {
                    // 2c. Non-capacity error (network, timeout) ? don't penalize AIMD limit
                    metrics::counter!("plexspaces_outbox_relay_errors_total").increment(1);
                    self.limiter.on_success(permit);   // preserve limit, not a congestion signal
                }
            }

            let err_str = err.to_string();
            let attempt = msg.delivery_count + 1;
            if should_retry(&self.retry_policy, attempt, &err_str) {
                // 3. Compute exponential backoff with full jitter
                let backoff = compute_backoff(&self.retry_policy, attempt, &mut rand::thread_rng());
                metrics::counter!("plexspaces_outbox_relay_retries_total").increment(1);
                // 4. nack with delay ? sets send_at = now + backoff in the DB
                //    The dequeue query filters WHERE send_at <= now(), so the message
                //    is physically invisible to the relay until the backoff expires.
                self.channel.nack(&msg_id, true, Some(backoff)).await.ok();
            } else {
                // 5. Max retries exceeded ? dead letter queue
                metrics::counter!("plexspaces_outbox_relay_dead_lettered_total").increment(1);
                self.channel.nack(&msg_id, false, None).await.ok();
            }
        }
    }
}

On a capacity rejection (DeliveryError::Capacity), the relay halves its concurrency limit and backs the message off in the DB so that it sends slower.

2.5 DB-Backed Store-and-Forward

The outbox is implemented with a database backend such as SQLite/Postgres (crates/channel/src/sqlite_backend.rs). The dequeue query enforces backoff at the storage layer, e.g.,

SELECT id, payload, headers_json, idempotency_key, conflict_resolution, delivery_count
FROM channel_messages
WHERE tenant_id = ? AND namespace = ? AND channel_name = ?
  AND acked = 0 AND dead_lettered = 0
  AND (send_at = 0 OR send_at <= ?)   -- ? messages with future send_at are invisible
ORDER BY send_at ASC, created_at ASC
LIMIT ?

The nack() implements the max-retries guard:

// crates/channel/src/sqlite_backend.rs
async fn nack(&self, message_id: &str, requeue: bool, delay: Option<Duration>) -> ChannelResult<()> {
    let max_retries = crate::channel::max_retries_from_config(&self.config);
    let current_delivery_count = /* fetch from DB */;

    if requeue && current_delivery_count < max_retries {
        // compute send_at timestamp: delay converts to Unix ms, 0 means immediate
        let send_at_ms = delay
            .map(|d| Self::system_time_to_unix_ms(SystemTime::now()) + d.as_millis() as i64)
            .unwrap_or(0);
        sqlx::query(&format!(
            "UPDATE {} SET acked = 0, delivery_count = delivery_count + 1, send_at = ? \
             WHERE id = ?",
            self.table_name
        ))
        .bind(send_at_ms)   // ? real backoff enforced here, not just logged
        .bind(message_id)
        .execute(&self.pool).await?;
    } else {
        // max retries exceeded ? dead letter queue (same table, dead_lettered = 1)
        sqlx::query(&format!(
            "UPDATE {} SET dead_lettered = 1, delivery_count = delivery_count + 1 \
             WHERE id = ?",
            self.table_name
        ))
        .bind(message_id)
        .execute(&self.pool).await?;
    }
}

Above code implement the transactional outbox pattern, which is commonly used by common by standards such as DTN (Delay-Tolerant Networking, RFC 9171) and real life use cases like mobile or IoT workers that might get offline temporarily.

2.6 Partitioned Relay: Scale-Out Without a Broker

When dealing with millions of actors, a single relay becomes a bottleneck so PlexSpaces defines PartitionedOutboxRelay to split the work into N independent partitions:

  • An FNV-1a hash of receiver_id picks the partition.
  • Each partition has its own channel, AIMD limiter, and relay routine.
  • Messages for the same recipient always land in the same partition, so ordering is preserved.

2.7 OVERWRITE: Last-Write-Wins for Transient Data

The outbox pattern suits idempotent transactional messages that are processed in FIFO order but you may need some use cases for transient data such as heartbeats and status updates. In those cases, you may just need to keep latest request instead of processing stale request so PlexSpaces defines ConflictResolutionPolicy::OVERWRITE that replaces existing message if it matches the idempotency_key instead of appending. It keeps a sequence_number header to prevent out-of-order overwrites.

Heartbeat dispatch in Go (examples/go/apps/job_queue_outbox/job_queue_outbox.go):

// All heartbeats use the same idempotency key with OVERWRITE so only the latest
// heartbeat is queued if the worker hasn't processed the previous one yet.
hbPayload := `{"type":"heartbeat","seq":` + strconv.Itoa(seq) + `}`
ctx := `{"tenant":"default","namespace":"default"}`

result, err := host.Outbox().DispatchWithTargets(
    ctx,
    dispatcherID,
    workers,
    hbPayload,
    "cast",
    heartbeatIdemKey,  // ? same key = last-write-wins, not append
    "overwrite",       // ? OVERWRITE conflict resolution
)

The same pattern in TypeScript (examples/typescript/apps/job_queue_outbox/job_queue_outbox_actor.ts):

// 50 rapid heartbeats with the same idempotency key — only the last survives in queue
for (let i = 1; i <= count; i++) {
  const payloadBytes = new TextEncoder().encode(
    JSON.stringify({ type: "heartbeat", seq: i, cpu: 0.42 }));
  const result = host.outbox.dispatch(
    ctx, DISPATCHER_ID, workers, payloadBytes,
    "cast",
    HEARTBEAT_IDEM_KEY,  // ? same key every iteration
    "overwrite"
  );
  totalAccepted += result[0];
  totalRejected += result[1];
}

2.8 Dead Letter Queue: Poison Message Safety

The real world applications have to deal with poison messages that consistently fail due to malformed messages so you need a bounded retry limits so that it doesn’t blocks the head of the queue forever. After max_attempts failures, nack(requeue=false), poison messages are moved to the DLQ (in the same table with dead_lettered = 1). Each retry uses exponential backoff with full jitter:

delay = random(0, min(cap, base × 2^attempt))

You can then review DLQ and replay/purge them if needed.


3. How the Flow Control Works

PlexSpaces uses composition to implement flow control, here’s what happens under load:

Step by step:

  1. OutboxRelay acquires a permit from ConcurrencyLimiter, bounded by the current AIMD limit.
  2. The relay calls tell() to enqueue the message in the actor’s bounded mailbox.
  3. Mailbox full: tell() returns MailboxFull, which becomes DeliveryError::Capacity. The relay calls limiter.on_rejection(), AIMD halves the limit, and the message gets a send_at delay in the DB.
  4. On success: the message is enqueued, the relay calls limiter.on_success() and acks, and AIMD grows the limit by 1.
  5. Non-capacity errors (network, timeout) return DeliveryError::Transient and don’t shrink the limit.

And the whole system in one picture:

ProblemMechanism
Actor can’t keep up, messages pile upBounded mailbox (the admission gate)
Sender doesn’t know the right send rateAIMD limiter
Receiver is offline or at capacityDB-backed outbox with send_at backoff
One relay can’t keep upPartitioned relay
Old status updates pile upOVERWRITE
One bad message blocks the queueRetry limit + DLQ

4. Production Patterns

Following are a few real life scenarios with working examples in the PlexSpaces repository:

4.1 Thundering Herd Protection

Let’s say you have 100,000 IoT workers that reconnect after a network partition and then send heartbeat or other request at once. Without backpressure, all of them will hit the leader together. But with a outbox relay dispatch plus AIMD, the workers can act on the leader’s MailboxFull or DeliveryError::Capacity) and retry later. The relay will halve its concurrency and backs off without dropping messages.

Go (examples/go/apps/job_queue_outbox/job_queue_outbox.go):

func (j *JobQueueLeader) handleThunderingHerdTest(payloadJSON string) string {
    targets := j.WorkerIDs  // all workers at once — the herd
    ctx := `{"tenant":"default","namespace":"default"}`
    herdPayload := `{"task":"thundering_herd","priority":"high"}`

    t0 := host.NowMs()
    // DispatchWithTargets fans out to ALL targets via the outbox.
    // The AIMD limiter throttles automatically when workers reject.
    result, err := host.Outbox().DispatchWithTargets(
        ctx, dispatcherID, targets, herdPayload, "cast", "", "")
    wallMs := int(host.NowMs() - t0)

    accepted, rejected := 0, 0
    if err == nil && result != nil {
        accepted = int(result.Accepted)
        rejected = int(result.Rejected)
    }
    // rejected messages go back to the outbox with exponential backoff send_at
    return marshal(map[string]any{
        "targets":  len(targets),
        "accepted": accepted,
        "rejected": rejected,   // non-zero = AIMD kicked in, system is adapting
        "wall_ms":  wallMs,
    })
}

Rust embedded setup (examples/rust/embedded/job_queue/src/main.rs):

// Workers have bounded mailboxes — MailboxFull is the backpressure signal.
// No in-flight limit needed: the actor event loop is sequential, one message at a time.
// AIMD outbox: starts at 10, backs off when workers' mailboxes fill up
let outbox_config = OutboxConfig {
    concurrency: Some(ConcurrencyConfig {
        initial_limit: 10,
        min_limit: 1,
        max_limit: 50,
        strategy: ConcurrencyStrategy::ConcurrencyStrategyAimd as i32,
    }),
    relay_interval_ms: 5,
    relay_batch_size: 20,
    ..Default::default()
};

4.2 Wave-Based Config Deployment

Let’s say you have 100,000 workers and need to rollout a firmware update. Config servers that serve firmware updates have bounded mailboxes and process one download at a time so deploying to all workers will fill mailboxes of the config servers. Instead of updating all workes simultaneously, you can update workers in waves of 2000. When a config server’s mailbox is full, MailboxFull becomes DeliveryError::Capacity, and the outbox relay halves its concurrency and backs off. The workers can then retry later with exponential backoff.

Go (examples/go/apps/job_queue_outbox/job_queue_outbox.go):

func (j *JobQueueLeader) handleConfigWaveDeploy(payloadJSON string) string {
    waveSize := 20  // process 20 workers per wave
    ctx := `{"tenant":"default","namespace":"default"}`
    configPayload := `{"task":"config_download","version":"2.1.0"}`

    totalAccepted, totalRejected := 0, 0
    for start := 0; start < len(j.WorkerIDs); start += waveSize {
        end := start + waveSize
        if end > len(j.WorkerIDs) { end = len(j.WorkerIDs) }
        targets := j.WorkerIDs[start:end]

        t0 := host.NowMs()
        result, err := host.Outbox().DispatchWithTargets(
            ctx, dispatcherID, targets, configPayload, "cast", "", "")
        wallMs := int(host.NowMs() - t0)

        if err == nil && result != nil {
            totalAccepted += int(result.Accepted)
            totalRejected += int(result.Rejected)
        }
        // rejected messages are in the outbox with backoff — they'll retry automatically
        waves = append(waves, waveResult{accepted: accepted, rejected: rejected, wallMs: wallMs})
    }
}

Config server with a bounded mailbox in app-config.toml:

[[supervisor.children]]
name = "config-server"
actor_type = "ConfigServer"
mailbox_capacity = 2

4.3 Heartbeat Coalescing

Again, let’s assume you have 100,000 workers that send heartbeats with some metrics and environment data every 5 seconds, but the leader only processes every 30 seconds. Without coalescing, 6 × 100,000 = 600,000 heartbeats queue up and 500,000 requests are stale by the time they’re processed. Instead, each sender can use one fixed idempotency_key for its heartbeats and OVERWRITE replaces the queued heartbeat if there is already pending request.

In Go, the key is a single constant reused across all heartbeat dispatches (examples/go/apps/job_queue_outbox/job_queue_outbox.go):

const heartbeatIdemKey = "hb-leader-worker-101"  // same key for all heartbeats to leader

func (j *JobQueueLeader) handleSendHeartbeat(payloadJSON string) string {
    seq := j.HeartbeatCount
    j.HeartbeatCount++

    hbPayload := `{"type":"heartbeat","seq":` + strconv.Itoa(seq) + `}`
    ctx := `{"tenant":"default","namespace":"default"}`

    result, err := host.Outbox().DispatchWithTargets(
        ctx,
        dispatcherID,
        workers,
        hbPayload,
        "cast",
        heartbeatIdemKey,  // ? same key every time = last-write-wins
        "overwrite",       // ? OVERWRITE: replaces existing unacked message with same key
    )
    // result.Accepted = 1 (new message), result.Rejected = 0, OR
    // if OVERWRITE replaced: result.Accepted = 1 (replace), previous message gone
}

Python (examples/python/apps/job_queue_outbox/job_queue_outbox_actor.py):

HEARTBEAT_IDEM_KEY = "hb-leader-worker-101"  # constant key = only latest survives in queue

def handle_send_heartbeat(self, payload: dict) -> dict:
    seq = self.heartbeat_count
    self.heartbeat_count += 1
    hb_payload = json.dumps({"type": "heartbeat", "seq": seq})
    result = host.outbox.dispatch(
        ctx, DISPATCHER_ID, self.worker_ids,
        hb_payload.encode(), "cast",
        HEARTBEAT_IDEM_KEY,  # ? constant idempotency key
        "overwrite"          # ? OVERWRITE resolution
    )

4.4 Store-and-Forward: Tell with Outbox Fallback

In this case workers send telemetry every second using fire-and-forget style. Assume, the collector is at capacity half the time and metrics must not be silently dropped. You can then try tell() (fire and forget) first, which delivers straight to the mailbox with zero overhead. If it fails for any reason (mailbox full, target offline, network error), fall back to the outbox for durable retry. This is similarl to earlier WhatsApp architecture where it used Erlang actors to deliver directly when you can but then store them on the server when you are offline.

It’s also a first-class API in the core ActorRegistry:

// crates/actor/src/actor_registry.rs
/// Try direct tell() first. On any failure, store in outbox for async retry.
/// Returns Ok(true) = delivered directly, Ok(false) = stored in outbox.
/// Returns Err only if BOTH paths fail.
pub async fn tell_reliable(
    &self,
    ctx: &RequestContext,
    actor_id: &ActorId,
    message: Message,
    outbox_channel: &dyn plexspaces_channel::Channel,
) -> Result<bool, ActorRegistryError> {
    let start = std::time::Instant::now();
    match self.tell(ctx, actor_id, message.clone()).await {
        Ok(()) => {
            metrics::counter!("plexspaces_tell_reliable_total", "path" => "direct").increment(1);
            Ok(true)   // ? fast path: zero outbox overhead
        }
        Err(tell_err) => {
            tracing::debug!(actor_id = %actor_id, error = %tell_err,
                "tell_reliable: direct delivery failed, falling back to outbox");
            let mut outbox_msg = message;
            if outbox_msg.receiver_id.is_empty() {
                outbox_msg.receiver_id = actor_id.to_string();
            }
            match outbox_channel.send(outbox_msg).await {
                Ok(_msg_id) => {
                    metrics::counter!("plexspaces_tell_reliable_total", "path" => "outbox").increment(1);
                    Ok(false)  // ? stored durably, OutboxRelay will deliver
                }
                Err(channel_err) => {
                    metrics::counter!("plexspaces_tell_reliable_total", "path" => "failed").increment(1);
                    Err(ActorRegistryError::SendFailed(format!(
                        "tell_reliable: direct failed ({}), outbox also failed ({})",
                        tell_err, channel_err
                    )))
                }
            }
        }
    }
}

SDK wrapper (sdks/rust/plexspaces-sdk/src/lib.rs):

pub async fn tell_reliable(
    ctx: &plexspaces_actor::RequestContext,
    service_locator: &dyn ServiceLocator,
    actor_id: &ActorId,
    message: Message,
    outbox_channel: &dyn plexspaces_channel::Channel,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
    let registry = service_locator.actor_registry().await
        .ok_or("ActorRegistry not available")?;
    registry.tell_reliable(ctx, actor_id, message, outbox_channel).await
        .map_err(|e| Box::new(e) as _)
}

WASM actors implement the same pattern by hand (examples/go/apps/job_queue_outbox/job_queue_outbox.go):

// examples/go/apps/job_queue_outbox/job_queue_outbox.go
func (j *JobQueueLeader) handleTelemetryTest(payloadJSON string) string {
    fastPathOK, fallbackOK := 0, 0
    for i := 0; i < count; i++ {
        targetID := workers[i%len(workers)]
        telPayload := `{"type":"telemetry","cpu":0.5,"mem":0.8,"seq":` + strconv.Itoa(i) + `}`

        // Fast path: direct ask() — zero overhead if worker is up and has capacity
        _, err := host.Ask(targetID, "process",
            map[string]any{"type": "telemetry", "seq": i}, 2000)
        if err == nil {
            fastPathOK++
        } else {
            // Fallback: outbox — durable, will retry with AIMD + backoff
            _, dispErr := host.Outbox().DispatchWithTargets(
                ctx, dispatcherID, []string{targetID},
                telPayload, "cast", "", "")
            if dispErr == nil { fallbackOK++ }
        }
    }
    return marshal(map[string]any{
        "fast_path_success": fastPathOK,
        "outbox_fallback":   fallbackOK,
    })
}

Python (examples/python/apps/job_queue_outbox/job_queue_outbox_actor.py):

def handle_telemetry_test(self, payload: dict) -> dict:
    fast_path, fallback = 0, 0
    for i in range(count):
        target = self.worker_ids[i % len(self.worker_ids)]
        try:
            # Direct ask — zero overhead when worker is available
            host.ask(target, "process", {"type": "telemetry", "seq": i}, timeout_ms=2000)
            fast_path += 1
        except Exception:
            # Outbox fallback — durable when worker is at capacity or offline
            host.outbox.dispatch(ctx, DISPATCHER_ID, [target],
                               json.dumps({"type": "telemetry", "seq": i}).encode(),
                               "cast", "", "")
            fallback += 1
    return {"fast_path_success": fast_path, "outbox_fallback": fallback}

4.5 Reliable Offline Delivery

In this scenario, workers go offline mid-deployment but the firmware update messages must survive. When workers reconnect they should get the messages they missed. This can be implemented using a SQLite-backed outbox channel. The delivery_count persists in the DB so retry counts survive restarts. The send_at enforces a backoff delays and the DLQ catches poison messages after max_attempts. The channel survives node restarts because we are using the DB as a queue.

Rust embedded SQLite setup (examples/rust/embedded/job_queue/src/main.rs):

let channel_config = ChannelConfig {
    name: "job-outbox".to_string(),
    provider: ChannelProvider::ChannelProviderSqlite as i32,
    delivery: DeliveryGuarantee::DeliveryGuaranteeAtLeastOnce as i32,
    ordering: OrderingGuarantee::OrderingGuaranteeFifo as i32,
    storage_path: "./job-queue.db".to_string(),  // survives restarts
    ..Default::default()
};
let channel = Arc::new(SqliteChannel::new(channel_config).await?);

let relay = Arc::new(OutboxRelay::new(
    &outbox_config,
    channel.clone(),    // DB-backed: messages persist across restarts
    limiter.clone(),    // AIMD: adapts to receiver capacity
    delivery.clone(),   // callback: delivers to actor mailbox
));

// Start the relay loop — drains DB and delivers messages
let relay_handle = relay.clone();
tokio::spawn(async move { relay_handle.run().await });

5. Safety Rules

Above Outbox pattern work best when the rest of the system follows a few widely used practices such as (see From Code to Production: A Checklist for Reliable, Scalable, and Secure Deployments):

  • Idempotency. At-least-once delivery means duplicates will happen with retries so use idempotency_key to dedupe and design handlers to handle duplicate requests (see How Duplicate Detection Became the Dangerous Impostor of True Idempotency).
  • Retry at one layer. HTTP clients, gRPC, and SDKs often retry by default. This can amplify when retries happen at multiple layers. You can send the remaining time budget down the call chain. I discussed this in my earlier blog Robust Retry Strategies for Building Resilient Distributed Systems.
  • Permanent errors. Capacity errors, timeouts, and network errors are worth retrying but a malformed payload is not retryable so send it to the DLQ.
  • Retry with jitter and backoff. Add a random offset with exponential backoff on the client side when connecting and retrying to prevent the thundering herd attack.
  • TTL. You can add a TTL to your messages so that recipient can discard stale requests.
  • Bulkheads. Keep separate limits per downstream dependency or message class so that only one dependency’s limit can be adjusted as needed.
  • Watch queue age and depth. Monitor both age of the oldest unacknowledged message and queue depth to track whether you’re keeping up. You can add other metrics like current limit, in-flight count, and rejections.
  • Metastable failures. Keep bounded-queue size and retry budget small as a growing backlog with retries can keep a system overloaded after the original trigger is gone. See Effective Load Shedding and Throttling Strategies for Managing Traffic Spikes and DDoS Attacks.
  • Use typed errors for control signals. Instead of matching on error strings, use proper error codes and enums for error types (e.g., DeliveryError::Capacity).

Related Posts

This post is part of a series on building distributed systems with actors:


GitHub: https://github.com/bhatti/PlexSpaces

Clone the repo. Read examples/README.md. Run ./test.sh in any example directory.

October 9, 2026

Building Production Data Pipelines with SEDA and Actors Model

Filed under: Computing — admin @ 8:44 pm

1. Introduction

The data pipelines come in different shapes such as streaming telemetry data, machine learning, messaging, or other real-time data feed. Common solutions to these problems include messaging middlewares like Kafka, Apache Flink/Spark, Airflow DAGs, Temporal, CDC via Debezium, etc. These tools may differ, but the design underneath is the same: ingest data, pipe it through a chain of stages that may hold state, and then send it to the destination after some filtering/decoration/massaging of the data. This post argues that this kind of stateful pipe and filter based architecture can be build with a simple primitive: durable actor connected by channels, which is what I built PlexSpaces around. This architecture is also similar to the SEDA (Staged Event-Driven Architecture) that builds applications as a network of stages joined by event queues. Each stage has its own workers with a queue that give you backpressure, isolation, and a place to observe load, e.g, :

An actor is a SEDA stage in miniature:

By default actor mailbox is in-memory state and it doesn’t connect a stage to Kafka, SQS, etc. PlexSpaces addresses these gaps with:

  • Durable actors. The durability facet journals state changes and checkpoints.
  • Channels. Queue-based coupling between stages.

In addition, PlexSpaces supports APIs for TupleSpace for coordination state, scatter/gather, and shard groups, and worker pool. It supports native Rust as well a WASM runtime with SDKs for Python, Go, TypeScript, and Rust.


2. The Primitive Stack

PlexSpaces provides following set of coordination primitives:

PrimitiveWhat It Does
Shard GroupPartition data across N actors; scatter-gather with aggregation
Worker PoolStateless actor pool with load balancing
Process GroupErlang pg2-style dynamic membership; broadcast
TupleSpacePattern-matched shared memory; Linda-model coordination
ChannelsQueue-based stage coupling; 6 backends (Kafka, Redis, SQS, PG, …)
Workflow ActorMulti-step durable orchestration; pause/resume/cancel
Distributed LockLease-based mutual exclusion across actors
BroadcastSend data to all actors in a process group
Collective ReduceSum/min/max across all actors; return to coordinator
Scatter/GatherFan-out to N workers, fan-in aggregated results

2.1 Facets: Zero-Code Cross-Cutting Capabilities

PlexSpaces supports dynamic composition using Facets abstraction that allows plugging capabilities via configuration at deployment time:

FacetWhat It Adds
securityJWT validation, RBAC enforcement
loggingStructured log correlation with trace IDs
metricsPrometheus histograms per handler, per actor
memoizeDeterministic response caching
virtual_actorOrleans-style activate-on-demand lifecycle
schema_validationMessage schema enforcement
durabilityJournaling + checkpoint + crash replay
execution_traceFull execution trace capture
timer / reminderDurable delayed messages (survive crashes)
event_sourcing / cachingFull audit trail / response caching
kv / locks / registry / process_groupsState, coordination, discovery
http_clientOutbound HTTP calls
event_emitterPub/sub event broadcasting

Behaviors:

BehaviorAnnotationPattern
GenServer@actor / #[gen_server_actor]Request-reply (ask/tell)
GenEvent@event_actorFire-and-forget event handling
FSM@fsm_actorState machine with transitions
Workflow@workflow_actorMulti-step durable orchestration

2.2 Polyglot SDK

PlexSpaces provides polyglot SDK for Python, TypeScript, GO and Rust:

Python:

from plexspaces import actor, handler, init_handler, host, state

@actor
class Counter:
    count: int = state(default=0)

    @init_handler
    def on_init(self, config: dict):
        self.count = int(config.get("args", {}).get("initial", 0))

    @handler("increment")
    def increment(self, amount: int = 1) -> dict:
        self.count += amount
        return {"count": self.count}

GO:

type CounterActor struct {
    plexspaces.BaseActor
    Count int `json:"count"`
}

func (a *CounterActor) Init(config map[string]interface{}) error {
    if args, ok := config["args"].(map[string]interface{}); ok {
        if v, ok := args["initial"].(float64); ok { a.Count = int(v) }
    }
    return nil
}

func (a *CounterActor) Handle(op string, payload map[string]interface{}) (interface{}, error) {
    switch op {
    case "increment":
        amount := 1
        if v, ok := payload["amount"].(float64); ok { amount = int(v) }
        a.Count += amount
        return map[string]interface{}{"count": a.Count}, nil
    }
    return nil, fmt.Errorf("unknown op: %s", op)
}

Typescript:

import { PlexSpacesActor, host } from "@plexspaces/sdk";

interface CounterState extends Record<string, unknown> {
  count: number;
}

class CounterActor extends PlexSpacesActor<CounterState> {
  onInit(config: Record<string, unknown>): CounterState {
    const args = (config.args as Record<string, unknown>) || {};
    return { count: Number(args.initial || 0) };
  }

  onIncrement(payload: Record<string, unknown>): Record<string, unknown> {
    const amount = Number(payload.amount || 1);
    this.state.count += amount;
    return { count: this.state.count };
  }
}

2.3 How Deployment Works

Every actor gets deployed via a TOML config file and a WASM binary. The config declares the supervision tree, actor roles, facets, and resource requirements:

[application]
name = "log-pipeline"
version = "1.0.0"

[supervisor]
strategy = "one_for_one"
max_restarts = 5
max_seconds = 60

[[supervisor.children]]
id = "LeaderActor"
actor_type = "LeaderActor"
behavior_kind = "GenServer"
role = "leader"
facets = ["virtual_actor", "metrics", "durability"]

[[supervisor.children]]
id = "PipelineWorker"
actor_type = "PipelineWorkerActor"
behavior_kind = "GenServer"
count = 8
role = "worker"
facets = ["virtual_actor", "metrics"]

The supervisor strategy (one_for_one, one_for_all, rest_for_one) comes directly from Erlang/OTP. If a pipeline worker crashes, only that worker restarts. If the leader crashes, the supervisor can optionally restart all children.

Deploy with a single API call:

zip app.zip pipeline_actor.wasm app-config.toml
curl -X POST http://node:8091/api/v1/applications/deploy \
  -F "application_id=log-pipeline" \
  -F "app_file=@app.zip"

3. Observability Pipelines

3.1 Log Ingestion & Routing Pipeline

In the log-pipeline example, the leader uses scatter/gather APIs to fan out event batches in parallel. Each PipelineWorkerActor receives a shard of events and runs them through a configurable function chain such as JSON parse, regex extract, PII masking, field enrichment, drop rules, etc. while keeping timing-metrics in state. The pipelines are partitioned by source hash across a shard group for horizontal scaling. After each scatterGather returns, the leader aggregates timing and throughput across all workers.

Python actor (excerpt):

@actor
class LeaderActor:
    compute_ms: float = state(default=0.0)
    coord_ms: float = state(default=0.0)

    @handler("run")
    def run(self, event_count=10000, worker_count=8, pipeline_fns=None, **kw):
        pipeline_fns = pipeline_fns or ["json_parse", "regex_extract", "mask_pii", "enrich"]
        group_id = host.create_shard_group(self.app_id, "PipelineWorkerActor", worker_count)
        events = generate_log_events(event_count)
        batches = partition_events(events, worker_count)

        t0 = host.now_ms()
        results = host.scatter_gather(group_id, "process_batch",
            [{"events": b, "pipeline_fns": pipeline_fns} for b in batches], timeout_ms=60000)
        coord_ms = host.now_ms() - t0

        total_compute = sum(r.get("compute_ms", 0) for r in results)
        return {"status": "ok", "events_per_sec": event_count * 1000 / coord_ms, ...}

Pipeline functions implemented across all languages:

  • fn_json_parse: Parse raw JSON body
  • fn_regex_extract: Extract fields via regex (timestamps, IPs)
  • fn_mask_pii: Mask email, SSN, credit card patterns
  • fn_enrich: Add geo, datacenter, environment metadata
  • fn_rename_fields: Normalize field names across sources
  • fn_drop: Filter events by rules (debug level, internal sources)

Benchmark: Go WASM, 100K events, depth=5, 2 nodes (tested on my MacPro laptop)

MetricValue
Throughput177,304 events/sec
Wall time564 ms
Compute time1,585 ms (73%)
Coordination time564 ms (26%)
Granularity ratio2.8×
Events processed86,384 / 100,000 (13,616 dropped by fn_drop)

Benchmark: Python WASM

WorkersTotal EventsEvents/secEfficiency
2200~10,000100%
4400~15,000150%
8800~22,000220%
161,600~30,000294%

Benchmark: TypeScript WASM

WorkersEvents/secWall msComp msCoord msComp%GranSpeedupEff%
22,25044527616962%1.6×1.00×100%
41,12089355833662%1.7×0.50×25%
86771,4781,11636276%3.1×0.30×8%
163502,8692,42644385%5.5×0.16×2%

Go actor

func (a *WorkerActor) handleProcessBatch(payload map[string]interface{}) (interface{}, error) {
    events := payload["events"].([]interface{})
    fns := payload["pipeline_fns"].([]interface{})

    t0 := plexspaces.NowMs()
    var processed []map[string]interface{}
    for _, ev := range events {
        event := ev.(map[string]interface{})
        for _, fn := range fns {
            event = applyPipelineFunction(fn.(string), event)
            if event == nil { break }
        }
        if event != nil { processed = append(processed, event) }
    }
    computeMs := plexspaces.NowMs() - t0

    return map[string]interface{}{
        "processed_count": len(processed),
        "compute_ms": computeMs,
    }, nil
}

3.2 Metrics Aggregation Pipeline

In the metrics_aggregation example, the leader uses scatter/gather APIs to provision N aggregation shards and then route metric batches. The framework hashes each metric name to a shard and each AggregationShard maintains a tumbling window in its actor state. After scatterGather returns, the leader collects shardResponses, merges rollup results, and runs z-score anomaly detection. Anomalies trigger alert events delivered via actor APIs and the state stays in the actor.

Rust embedded actor:

#[gen_server_actor]
struct AggregatorShard {
    shard_id: usize,
    windows: HashMap<String, Vec<f64>>,
    total_metrics: u64,
}

#[plexspaces_handlers(gen_server)]
impl AggregatorShard {
    #[handler("ingest_batch", cast)]
    async fn handle_ingest_batch(&mut self, _ctx: &ActorContext, msg: &Message) -> Result<(), BehaviorError> {
        let batch: Vec<Metric> = serde_json::from_slice(&msg.payload)?;
        for m in &batch {
            self.windows.entry(m.name.clone()).or_default().push(m.value);
        }
        self.total_metrics += batch.len() as u64;
        Ok(())
    }

    #[handler("get_aggregates")]
    async fn handle_get_aggregates(&self, _ctx: &ActorContext, _msg: &Message) -> Result<Value, BehaviorError> {
        // Compute count/sum/avg/min/max per metric name, return as JSON
    }
}

Benchmark: Go WASM (100K metrics, 8 workers)

MetricValue
Throughput480,769 metrics/sec
Compute time208 ms (69%)
Coordination time91 ms (30%)
Granularity ratio2.30×

Benchmark: Rust Embedded

MetricValue
Throughput86,372 metrics/sec
Compute time361 ms (62%)
Coordination time220 ms (38%)
Granularity ratio1.64×

Benchmark: Python WASM

WorkersTotalAgg/sEfficiency
22K444K100%
44K451K101%
88K901K202%
1616K1.44M323%

3.3 Distributed Tracing Pipeline

In the tracing_pipeline example, the leader creates a shard group and then uses scatter/gather to send only the spans whose trace_id hashes to that shard. All spans for a given trace land on one worker and each WorkerActor holds partial traces in its actor state and then runs tail-based sampling. Workers accumulate compute_ms in state and the leader sums shardResponses[*].compute_ms to compute aggregate compute time.

Go actor (excerpt):

type Span struct {
    TraceID      string            `json:"trace_id"`
    SpanID       string            `json:"span_id"`
    ParentSpanID string            `json:"parent_span_id,omitempty"`
    ServiceName  string            `json:"service_name"`
    Operation    string            `json:"operation"`
    StatusCode   int               `json:"status_code"`
    DurationMs   float64           `json:"duration_ms"`
    StartTimeMs  int64             `json:"start_time_ms"`
    Tags         map[string]string `json:"tags,omitempty"`
}

Benchmark: Strong Scaling

WorkersSpans/secWall msComp msCoord msGranSpeedupEff%
2242,7442952951611.8x1.00x100.0%
4193,3713733732071.8x0.79x39.5%
8218,4243303301262.6x0.89x22.3%
16221,2153213211192.7x0.92x11.5%

Service graph output example:

{
  "nodes": [
    {"name": "api-gateway", "span_count": 1000, "error_count": 48, "avg_latency_ms": 145.2},
    {"name": "user-service", "span_count": 800, "error_count": 39, "avg_latency_ms": 32.1},
    {"name": "order-service", "span_count": 600, "error_count": 31, "avg_latency_ms": 45.8},
    {"name": "payment-service", "span_count": 400, "error_count": 22, "avg_latency_ms": 89.3}
  ],
  "edges": [
    {"source": "api-gateway", "target": "user-service", "call_count": 800, "p99_latency_ms": 195.0},
    {"source": "api-gateway", "target": "order-service", "call_count": 600, "p99_latency_ms": 210.5},
    {"source": "order-service", "target": "payment-service", "call_count": 400, "p99_latency_ms": 380.2}
  ]
}

Benchmark: Python WASM

WorkersTotal TracesSpans/sGranEff%
2500105,7521.4×100%
41,000104,7791.5×99%
82,000213,5202.6×202%
164,000432,4174.4×409%

Benchmark: TypeScript WASM

ShardsTotal SpansSpan/sWall msComp msCoord msGranEff%
21,0005,988167109741.5×100.0%
42,0005,7643472271501.5×96.3%
84,0006,9205784551752.6×115.6%
168,0007,1051,1269872454.0×118.7%

4. ML Pipelines

4.1 Batch Inference

The parallel_ai_inference example demonstrates shard-group-based batch inference with model caching per worker. A benchmark actor then tries three ways of distributing that work and measures speed. The three ways are: broadcast like MPI, scatter/gather and elastic pool.

The batch_image_classification example shows ViT-style image classification across shards. A leader actor splits a pile of images across N worker actors, runs the work in rounds, and merges the results.

Benchmark: Python WASM Parallel AI Inference

ShardsReq/secComp%GranSpeedupEfficiency
151262%1.7×1.00×100%
4~800——~1.56×~39%
81,45391%10.5×2.84×70%
32~2,100——~4.1×16%

4.2 Parameter Server

The ParameterServerActor (leader) in parameter_server example creates shard group to provision worker shards, then broadcasts the current weight matrix via scatter/gather API. Each WorkerActor stores its local data shard in actor state, computes a mini-batch gradient locally, and returns a gradient summary. The leader collects shardResponses, averages the gradients across all workers.

Benchmark: TypeScript WASM

MetricValue
param_count6,464 (input_dim=100 × hidden_dim=64 + 64)
Gradient opsworkers × 20 rounds
Compute time~2 ms (gradient averaging + weight update)
Coordination time~4,000 ms (scatter-gather round trips)
Granularity ratio~0.00× (coordination-dominated on single node)
Errors0

4.3 Federated Learning

The federated_learning example shows a federated learning simulation. An aggregator coordinates many client actors, each holding its own private dataset. Every round, the aggregator sends the current model out. Each client trains locally and returns gradients. The aggregator then averages them weighted by sample count.

Benchmark: Federated Learning

ClientsWall msCompute msCoord msGranSpeedupEfficiencyAccuracy
23402091311.6×1.00×100%93.7%
46824292531.7×0.50×25%93.6%
81,1388582793.1×0.30×7.5%93.6%
162,1061,7713345.3×0.16×2.0%93.6%

4.4 LLM Serving with Request Batching

The llm_serving simulates LLM serving pipeline similar to vLLM. A router generates a mix of requests and sends each one to a model tier: small, medium, or large. Cheap tasks go to the small model and heavy ones to the large mode. The router groups requests into batches per tier and scatters each batch to model workers, which simulate inference. The router then totals up throughput, tokens per second, cost, etc.

Benchmark: Python WASM

ReplicasReq/s (aggregate)Tok/s (aggregate)Wall msSpeedup
2~37,000~2.1M541.00×
4~39,000~2.2M511.05×
8~43,000~2.5M461.17×

Benchmark: TypeScript WASM

ShardsTotal ReqReq/sTok/sWall msComp msCoord msGranEff%
21,55286,22223,325,778182552.6×100.0%
43,09696,75027,481,5003249200.6×112.2%
64,644132,68638,290,2863573230.5×153.9%
86,144170,66744,575,1113697240.5×197.9%

4.5 Feature Store

The feature_store simulates a feature store like Feast or Tecton. A leader generates features and ingests the features into worker shards, which keep a versioned key-value store. The leader then serves batched lookups, where each worker checks its cache first and falls back to its store. At the end the leader collects stats and reports lookups per second, cache hit rate, etc.

TypeScript actor (excerpt):

class WorkerActor extends PlexSpacesActor<WorkerState> {
  onIngestBatch(payload: Record<string, unknown>): Record<string, unknown> {
    const records = payload.records as FeatureRecord[];
    for (const rec of records) {
      const key = `${rec.entity_id}:${rec.feature_name}`;
      const versions = this.state.features[key] || { versions: [] };
      versions.versions.push({ value: rec.value, version: rec.version, timestamp: rec.timestamp });
      if (versions.versions.length > 3) versions.versions.shift(); // Keep last 3
      this.state.features[key] = versions;
    }
    return { status: "ok", ingested: records.length };
  }

  onLookup(payload: Record<string, unknown>): Record<string, unknown> {
    const entityId = payload.entity_id as string;
    const cacheKey = entityId;
    // Check LRU cache first
    if (this.state.cache[cacheKey]) {
      this.state.cache_hits++;
      return { ...this.state.cache[cacheKey], cache_hit: true };
    }
    this.state.cache_misses++;
    // Assemble feature vector from versioned store
    // ...
  }
}

Benchmark: Python WASM

WorkersLookups/secSpeedupEfficiency
2~10,0001.00×100%
4~13,0001.30×65%
8~17,0001.70×43%
16~21,0001.95×24%

Benchmark: TypeScript WASM

ShardsTotal LookupsLkup/sWall msComp msCoord msGranEff%
21,6003,6704361123800.1×100.0%
43,2003,7788472177930.1×102.9%
86,4007,2988774288230.1×198.9%
1612,8006,5981,9408861,8850.0×179.8%

5. Data Processing & ETL

5.1 Streaming Window Aggregation

The streaming_pipeline example simulates streaming pipeline for event processing like a log or telemetry pipeline. A leader runs a series of batch rounds. Each round it tells all worker shards to process a batch of events. Workers simulate filtering, enriching, and transforming events. The worker then write a stage summary to a shared tuple space and return counts, bytes processed, etc. The leader reads the stage summaries back and reports events per second.

Benchmark: TypeScript WASM Streaming Pipeline

ShardsTotal EventsEvt/sWall msComp msCoord msGranEff%
29,6001,600,0006150.2×100.0%
419,2002,742,8577260.2×171.4%
838,4004,800,0008080.0×300.0%
1676,8005,485,714148130.1×342.9%

5.2 Data Lake RAG Ingestion

The data_lake_rag and agentic_rag_pipeline exampless demonstrate document ingestion, chunking, embedding, vector store and retrieval-augmented generation. Workflow actors manage the multi-step pipeline with checkpointing, so a crash during embedding resumes from the last chunk.

Benchmark: Data Lake RAG

MetricValue
Workload100 queries × 500 chunks × 2048 bytes
Wall time1,429 ms
Compute time3,984 ms (75.6%)
Coordination time1,285 ms (24.4%)
Granularity3.10×

5.3 CDC Pipeline

The cdc_pipeline example simulates PostgreSQL-style WAL events (INSERT, UPDATE, DELETE) on four tables: users, orders, products, inventory. Workers maintain LSN (Log Sequence Number) tracking per table, apply schema transformations and fan out to three sink types (search index, analytics warehouse, cache invalidation).

Benchmark: CDC Pipeline (10K WAL events, 4 tables)

WorkersEvents/secSpeedupEfficiency
2~1,4001.00×100%
4~1,9001.36×68%
8~2,8002.00×50%
16~3,1002.20×28%

Python CDC actor (excerpt):

@actor
class WorkerActor:
    lsn_watermarks: dict = state(default_factory=dict)
    seen_event_ids: set = state(default_factory=set)

    @handler("process_events")
    def process_events(self, events=None, **kw):
        results = {"search_index": [], "analytics": [], "cache_invalidation": []}
        for event in events:
            if event["event_id"] in self.seen_event_ids:
                continue  # Dedup
            self.seen_event_ids.add(event["event_id"])
            self.lsn_watermarks[event["table"]] = max(
                self.lsn_watermarks.get(event["table"], 0), event["lsn"])
            transformed = self.transform(event)
            for sink in ["search_index", "analytics", "cache_invalidation"]:
                results[sink].append(self.format_for_sink(transformed, sink))
        return {"status": "ok", "processed": len(events), "sinks": results}

5.4 Web Crawler

The web_crawl example simulates parallel web crawler. It includes aan orchestrator that runs a breadth-first crawl from seed URLs. For each URL it checks a fetcher using an elastic pool, fetches the page and checks the fetcher back in. After the crawl, the results are split between two analyzer shards that merge word counts into a global top-10. Also, the orchestrator asks the process group of fetchers how many pages each one handled.

Benchmark: TypeScript WASM Web Crawl

WorkersTotPagesPg/sWall msComp msCoord msSpeedupEff%
24001,95120520051.00×50.0%
48001,9234162022140.99×24.6%
81,6003,8834122012111.99×24.9%
163,2007,6924162012153.94×24.6%

6. Learnings

This post covered several use cases and examples for building data pipelines in different languages using PlexSpaces framework. Here are key lessons when building these pipelines:

  1. Stateful Actors. Actors are natural fit for building data pipelines. Facets allow adding cross-cutting concerns dynamically like metrics, durability, etc.
  2. Granularity ratio matters. There is a communication overhead when communicating with lots of actors/processes. The granularity ratio is compute time divided by coordination/communication time. So pay attention to this and test proper batch size for the parallel work
  3. Batch messages. Instead of sending one event per tell/cast, batch 100-1000 events per message.
  4. Broadcast scatter-gather. The scatter/gather APIs have been used in high performance computing for decades and are proven to scale for building highly parallel and concurrent applications.
  5. Reduce shard count when coordination dominates. More shards means more scatter-gather rounds. If your per-shard work is small, you are paying coordination tax for parallelism you do not need.
  6. Use cast (fire-and-forget) instead of call (request-reply). PlexSpaces supports both cast/tell and call/ask. Cast does not wait for a response, so the sender can continue immediately. Call blocks until the handler returns so use cast where possible such as data ingestion.
  7. TupleSpace for coordination. TupleSpace is based on Linda memory model, which is designed for coordination, synchronization.
  8. Durable actors. Stateless functions retry the whole batch on failure. Actors with the durability facet journal state before applying it. This means that a crash replays from the last journal entry instead of start.
  9. WASM Gotchas. WASM has high serialization overhead (~3.5ms for store reinstantiation). PlexSpaces supports native Rust SDK for microsecond-latency workloads.

Amdahl’s Law in practice. The parallel fraction of your workload determines the theoretical maximum speedup. From our benchmarks:

WorkloadParallel FractionMax Speedup (Amdahl)Measured at 16 workers
Log pipeline (batch=500)94%16.7x8.8x
Metrics aggregation86%7.1x5.2x
Batch inference97%33.3x12.0x
Ring AllReduce92%12.5x8.4x
Federated learning84%6.3x0.16×

GitHub: https://github.com/bhatti/PlexSpaces

Clone the repo. Run ./test.sh in any example directory.

Related reading


Powered by WordPress