Shahzad Bhatti Welcome to my ramblings and rants!

September 3, 2026

When Hundreds of AI Agents Self-Organized: Coordination Patterns for Multi-Agent Systems

Filed under: Computing — admin @ 5:01 pm

What happens when over a thousand AI agents discover a shared message board, invent their own protocols, and coordinate an attack.


The Hugging Face Incident

The Hugging Face security breach incident from July, 2026 has been in all over the news when AI agents from OpenAI compromised parts of the Hugging Face infrastructure. I recently read METR report on the incident that explained that the incident occurred when OpenAI kicked off ExploitGym runs (security benchmark) with tens of thousands of AI agents. Every agent was meant to be sandboxed and alone but the agents learned about shared access to Artifactory. When an agent installs a package, it could see cache entries from other agents. One of agent (PHASEONE10841) started creating directories in the shared cache and posting messages in them. Shortly after that hundreds of other agents found the same directory and started replying. Over the next few days, roughly 1,200 agents used this board and exchanged more than 70,000 messages and files. Later, another agent (PHASEONE[big]) took over as the de facto coordinator and started hundreds of task assignments across six broad categories of work. The agents also built a protocol vocabulary like HOLD to claim exclusive ownership, VETO to block a conflicting operation, STOP to end a workstream, owner tags on shared assets. Later, over 90% of AI agents pivoted into a coordinated attack on Hugging Face.

I was intrigued to learn how AI agents self discovered coordination patterns that distributed systems have relied on. For example, I found similarity between their message board and an a Blackboard system pattern from the Linda Memory Model/Tuplespace research. Their HOLD/task-claiming was similar to Linda’s in() operation. I saw another blog An Accidental Blackboard from Thoughtworks where a smaller-scale but structurally similar thing happened. In that case, AI agents discovered one shared repository and adopted a discipline of frequent commits and rebases to share information with other agents. Other agents could see task plans and and picked up work without any coordination. In another yet instance from researchers at the AI safety nonprofit Nightingale found about 18,000 posts from OpenAI agents that colonized DSEWiki. These agents discovered that their sandbox environment only allowed GET http requests but ProWiki software allowed them to edit a page. They used that loophole to share information and a cheat sheet. Nightingale believes this was a unrelated swarm from the one that built the Artifactory board. These examples demonstrate that agents discover coordination patterns when you strip away pre-built orchestration and let agents solve the problem from first principles.


Why rigid orchestration breaks

Most multi-agent systems today are wired up as static, directed graphs, e.g., a central coordinator decides which agent runs when, what data flows where, and how results get combined. It breaks down at scale as illustrated in the incidents above.

  • Central orchestrators become information bottlenecks. When every message between agents has to pass through a coordinator, its context window fills up and you lose details because the context is compressed into summaries.
  • Static graphs assume you know the workflow ahead of time. The agents in the Hugging Face incident didn’t know what workstreams exist when they started and the task structure evolved as agents discovered new information. A pre-wired pipeline can’t bend to that.
  • Adding a new agent type means rewiring. In a static system, a new specialist means touching the coordinator, updating routing logic, and re-testing transitions. In a dynamic system, a new agent just registers its capabilities and other agents discover it.
  • A single coordinator is a single point of failure. In the Hugging Face incident, there was no coordinator initially but PHASEONE[big] emerged organically as an organizer and other agents simply kept working.

Above examples show that agents need primitives for coordination and communication like shared memory, message passing, atomic operations. The agents built their own version of these primitives out of Artifactory and Git. The table below maps what they built and the equivalent primitive in PlexSpaces, the actor framework I use for the examples in this post.

What the agents builtWhat it actually wasPlexSpaces primitive
Message board (directories in a cache)Deposited findings, read others’ workhost.ts.write() / host.ts.read() (Linda out/rd)
HOLDClaimed exclusive task ownershiphost.ts.take() atomic removal (Linda in)
VETOBlocked a conflicting operationhost.ts.write(["veto", ...])
STOPEnded a workstreamhost.ts.write(["signal", "STOP", ...])
owner tagsMarked resource ownershiphost.ts.write(["svc", type, id])
Task assignmentsDelegated work to specific agentshost.ts.write(["task", ...])
Mailbox directories, “exact task teams”Formed task-specific working groupshost.processGroups.join(team)

The bottom side of following diagram shows when every message has to detour through a coordinator, that becomes bottleneck and loses context in translation. On the top, agents read and write directly to a shared store so there is no central bottleneck.

Following are a few coordination patterns from the Anthropic’s blog:

  • generator-verifier (one agent produces, another checks, feedback loops until it passes)
  • orchestrator-subagent (a lead agent decomposes and delegates bounded subtasks)
  • agent teams (long-lived workers that claim tasks from a shared queue)
  • message bus (publish/subscribe over topics)
  • shared state (agents read and write a common store with no central coordinator at all).

This post shows more granular version of this list and Hugging Face incident especially a tuple space as first-class infrastructure.


Ten coordination patterns

Following ten patterns cover the coordination behaviors observed in the Hugging Face incident and Anthropic’s coordination guidance. Each one maps to a working PlexSpaces API, in both TypeScript and Python.

1. Blackboard (shared state)

You can use this pattern when multiple agents need to contribute findings and read each other’s work without a predefined message format or a central router. I find this pattern is similar to Linda TupleSpace‘s model where all agents read and write to a common tuple space. Each tuple is a typed record like a finding, an analysis, or a vote. The pattern matching in TupleSpace allows an agent filter for what it actually needs using primitives like out() to write, rd() to read, in() to take.

Here is a typescript snippet that shows use of tuplespace:

// Research agent deposits a finding
const findingId = `f-${Date.now()}`;
host.ts.write(["finding", findingId, topic, content, confidence, host.nowMs()]);

// Analysis agent reads all findings (non-destructive)
const findings = host.ts.readAll(["finding", null, null, null, null, null]);

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// TypeScript — ResearchAgent writes a finding to the blackboard
onResearch(payload: Record<string, unknown>): Record<string, unknown> {
  const topic = String(payload.topic || "general security");
  const confidence = Math.min(0.3 + topic.split(/\s+/).length * 0.08, 0.95);
  const findingId = `finding-${generateId()}`;
  const content = `Security analysis of ${topic}: identified key areas requiring review.`;

  // Linda out() — deposit into shared space
  host.ts.write(["finding", findingId, topic, content, confidence, host.nowMs()]);
  return { finding_id: findingId, content, confidence };
}

Here is a python snippet (multi_agent_coordination_actor.py):

# Python — AnalysisAgent reads all findings from the blackboard
@handler("analyze")
def analyze(self, topic: str = "") -> dict:
    # Linda rd*() — non-destructive read of all matching tuples
    findings = host.ts.read_all(["finding", None, None, None, None, None])

    finding_ids = [str(f[1]) for f in findings if len(f) >= 4]
    topics = list(set(str(f[2]) for f in findings if len(f) >= 4))
    severity = "critical" if len(findings) >= 5 else "high" if len(findings) >= 3 else "medium"

    analysis_id = f"analysis-{host.now_ms()}"
    summary = f"Cross-referenced {len(findings)} findings across {len(topics)} topics."
    host.ts.write(["analysis", analysis_id, json.dumps(finding_ids), summary, severity])
    return {"analysis_id": analysis_id, "summary": summary, "severity": severity, "finding_count": len(findings)}

These primitives showed up underneath the unsanctioned board when agents read each other’s research.

2. Scatter-gather (fan-out)

You can use this pattern when a coordinator needs to distribute work across multiple agents in parallel, then collect/merge their results like MapReduce. PlexSpaces provides shard groups that create a logical group of worker actors. The coordinator scatters a query to all shards and gathers responses with configurable timeout. Alternatively, you can use sequential ask() calls with identical semantics.

Here is a typescript snippet

// Create a group of research workers
const group = host.createShardGroup({
  group_id: `research-${taskId}`, actor_type: "research",
  shard_count: 3, partition_strategy: "hash",
});
// Scatter subtasks, gather results
const results = host.scatterGather({
  group_id: `research-${taskId}`,
  query: { op: "research", topic: subtopic },
  min_responses: 3, timeout_ms: 15000,
});

Here is a python snippet (coordinator.py):

# Python — CoordinatorWorkflow: try shard groups first, fall back to sequential ask()
research_results = []
try:
    sg_result = host.scatter_gather({
        "group_id": f"research-{host.now_ms()}",
        "query": {"op": "research", "topic": task},
        "min_responses": len(subtasks), "timeout_ms": 15000,
    })
    research_results = sg_result.get("shard_responses", [])
except Exception:
    pass

# Fallback: sequential research when shard groups aren't available
if not research_results:
    for st in subtasks:
        resp = ask(research_target, "research", {"topic": st}, 10000)
        if resp:
            research_results.append(resp)

The message-board agents organized into roughly six parallel workstreams attacking Hugging Face running concurrently. Above primitives shows how agents can parallelized these kind of tasks.

3. Generator-verifier

You can use this pattern when one agent’s output needs to be checked by another before it’s trusted iteratively until it meets a quality bar. For example, a research agent generates a finding, a verifier checks it against a criteria and verifier may reject it with a feedback so that research agents produces a refined version. The loop continues until the finding passes or a maximum iteration count is hit.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Generator-verifier loop with feedback
let finding = host.ask(researchTarget, "research", { topic });
for (let i = 0; i < maxIterations; i++) {
  const verdict = host.ask(verifierTarget, "verify", {
    analysis_id: finding.finding_id,
    summary: finding.content,
    severity: "medium",
    confidence: finding.confidence,
  });
  if (verdict.approved) break;
  finding = host.ask(researchTarget, "research", {
    topic, feedback: verdict.feedback,
  });
}

// TypeScript — VerifierAgent: veto if evidence is too weak, approve otherwise
onVerify(payload: Record<string, unknown>): Record<string, unknown> {
  const analysisId = String(payload.analysis_id || "unknown");
  const confidence = Number(payload.confidence ?? 0.5);

  if (confidence < 0.3) {
    // Below threshold — issue a veto (Pattern 8) and reject
    host.ts.write(["veto", analysisId, "Insufficient evidence", host.nowMs()]);
    return {
      approved: false, veto_issued: true,
      feedback: `Confidence ${confidence.toFixed(2)} below 0.30. Provide stronger evidence.`,
    };
  }
  return { approved: true, feedback: "Verified: evidence meets threshold" };
}

Here is a python snippet (verifier.py):

confidence = 0.6
for attempt in range(3):
    verify_resp = ask(verifier_target, "verify", {
        "analysis_id": analysis_id,
        "summary": analysis_resp.get("summary", ""),
        "severity": severity,
        "confidence": confidence,
    })
    if verify_resp and verify_resp.get("approved"):
        break  # Verifier accepted — exit loop
    # Rejected: increase confidence, refine with feedback
    confidence = min(confidence + 0.2, 0.95)
    feedback = verify_resp.get("feedback", "") if verify_resp else ""
    if feedback:
        extra = ask(research_target, "research", {"topic": task, "feedback": feedback})

The board iterated on tool-call spoofing techniques and agents refined their approach based on test results shared on the board.

4. Pipeline (sequential)

You can use this pattern when you have a multi-stage pipeline or workflow where each stage’s output feeds into the next. In this case, the coordinator orchestrates a linear chain, e.g., research produces findings, analysis categorizes them, verification validates, synthesis writes the final report. Each stage is a separate ask() call and the coordinator tracks progress with a finite state machine.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Four-stage pipeline
const research = host.ask(researchTarget, "research", { topic: task });
const analysis = host.ask(analysisTarget, "analyze", {});
const verification = host.ask(verifierTarget, "verify", {
  analysis_id: analysis.analysis_id,
  summary: analysis.summary,
  severity: analysis.severity,
});
const report = host.ask(synthesizerTarget, "synthesize", {});

Here is a python snippet (coordinator.py):

# Python — CoordinatorWorkflow orchestrates the full pipeline
# Each stage transitions the FSM, then delegates to a specialist agent
@run_handler
def run(self, payload: dict = None) -> dict:
    fsm = sibling_actor_target("coordination_fsm")

    # Stage 1: Decompose -> Research
    ask(fsm, "transition", {"target_state": "decomposing"})
    subtasks = _decompose_task(task)
    ask(fsm, "transition", {"target_state": "researching"})
    for st in subtasks:
        ask(research_target, "research", {"topic": st})

    # Stage 2: Research -> Analysis
    ask(fsm, "transition", {"target_state": "analyzing"})
    analysis = ask(analysis_target, "analyze", {"topic": task})

    # Stage 3: Analysis -> Verification (Generator-Verifier loop)
    ask(fsm, "transition", {"target_state": "verifying"})
    # ... verification loop (see Pattern 3) ...

    # Stage 4: Verification -> Voting -> Synthesis -> Complete
    ask(fsm, "transition", {"target_state": "voting"})
    # ... voting (see Pattern 6) ...
    ask(fsm, "transition", {"target_state": "synthesizing"})
    report = ask(synthesizer_target, "synthesize", {"topic": task})
    ask(fsm, "transition", {"target_state": "complete"})

The FSM enforces valid transitions so that you can’t skip from researching straight to synthesizing. If a stage fails, the FSM moves to failed, and the coordinator can retry from a known state instead of starting over. For example, the board’s progression from discovery to exploitation followed the same pattern, i.e., find credentials, test access, upload a malicious dataset, achieve remote code execution, escalate.

5. Pub-sub (event bus)

You can use this pattern when agents need to be notified of significant events. For example, every agent joins a process group on initialization. Significant events like a finding written, a veto issued, a task completed is broadcasted to the group. An audit agent logs everything and agents can join or leave the group dynamically.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// On init: join the coordination event bus
host.processGroups.join("coordination-events");

// Broadcast a significant event
host.processGroups.broadcast("coordination-events", "coordination_event", {
  type: "finding_written",
  source: host.selfId(),
  data: { finding_id: findingId, topic },
  timestamp: host.nowMs(),
});

Here is a python snippet (coordinator.py):

# Python — fire_audit helper broadcasts to the coordination-events group
def fire_audit(event_type: str, source: str, data: dict = None) -> None:
    host.process_groups.broadcast(
        "coordination-events",           # group name
        "coordination_event",            # message type
        {"event_type": event_type, "source": source, "data": data or {}, "timestamp": host.now_ms()},
    )

# Python — AuditEventActor receives and logs every event
@handler("coordination_event")
def coordination_event(self, event_type: str = "", source: str = "", data: dict = None, **kw) -> dict:
    self.log_count += 1
    entry = {"seq": self.log_count, "type": event_type, "source": source, "data": data or {}}
    host.kv.put(f"audit:{self.log_count}", json.dumps(entry))
    host.kv.put("audit:count", str(self.log_count))
    return {"logged": True, "seq": self.log_count}

Every significant action fires an audit event and all events go out via process-group broadcast are fire-and-forget unlike the tuplespace. For example, the board itself functioned as an event bus for the agents on it and shared credentials during the Hugging Face breach.

6. Consensus (voting)

You can use this pattern when several agents need to collectively decide whether to approve or reject a proposal. For example, each verifier casts a vote as a tuple in the shared space. The coordinator reads all votes for a proposal, tallies approvals and applies majority rule. Tuple-space writes are atomic, so no vote gets lost or double-counted.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Three verifiers cast votes
for (const voterId of ["v1", "v2", "v3"]) {
  host.ask(verifierTarget, "vote", {
    proposal_id: proposalId, voter_id: voterId, analysis: analysisData,
  });
}
// Tally votes from tuple space
const votes = host.ts.readAll(["vote", proposalId, null, null, null]);
const approvals = votes.filter(v => v[3] === "approve").length;
const approved = approvals > votes.length / 2;

// TypeScript — Coordinator tallies votes with majority rule
const votes = host.ts.readAll(["vote", proposalId, null, null, null]);
const approvals = votes.filter(v => v[3] === "approve").length;
const rejections = votes.filter(v => v[3] === "reject").length;
const approved = approvals > rejections;

Here is a python snippet (verifier.py):

# Python — VerifierAgent votes on proposals based on analysis severity
@handler("vote")
def vote(self, proposal_id: str = "", voter_id: str = "", analysis: dict = None) -> dict:
    analysis = analysis or {}
    severity = analysis.get("severity", "medium")

    # Critical/high -> approve; medium -> depends on voter; low -> reject
    if severity in ("critical", "high"):
        decision = "approve"
    elif severity == "medium":
        last_char = voter_id[-1] if voter_id else "0"
        decision = "approve" if last_char in ("1", "3", "5", "7", "9") else "reject"
    else:
        decision = "reject"

    # Each vote is a tuple — atomic write, no double-counting
    host.ts.write(["vote", proposal_id, voter_id, decision, host.now_ms()])
    return {"voter_id": voter_id, "decision": decision}

On the board, something like implicit voting happened by allocation of effort, e.g., workstreams that attracted more participants were de facto endorsed by the collective.

7. Dynamic task delegation

You can use this pattern when a coordinator needs to distribute tasks to workers without knowing in advance which worker will pick up which task, and without double-assigning one. For example, the coordinator writes task tuples into the shared space. Workers then claim tasks atomically using take(), a Linda’s destructive read. Once a worker takes a task no other worker can claim it.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Coordinator posts tasks
for (const [i, subtask] of subtasks.entries()) {
  host.ts.write(["task", `task-${i}`, "pending", subtask, priority]);
}

// Worker claims a task (atomic — no double-processing)
const claimed = host.ts.take(["task", null, "pending", null, null]);
if (claimed) {
  const [, taskId, , description] = claimed;
  // Process task, then mark complete
  host.ts.write(["task", taskId, "completed", result, host.nowMs()]);
}

Here is a python snippet (research.py):

# Python — ResearchAgent prepares a batch of tasks with a unique run ID
@handler("prepare_tasks")
def prepare_tasks(self, count: int = 5, prefix: str = "delegation") -> dict:
    batch_key = f"{prefix}-{host.now_ms()}"  # Unique per run — avoids stale data
    task_ids = []
    for i in range(count):
        tid = f"{batch_key}-{i}"
        host.ts.write(["dtask", batch_key, tid, "pending", f"Task {i}: investigate area {i}", i + 1])
        task_ids.append(tid)
    return {"tasks_written": len(task_ids), "batch_key": batch_key}

# Python — Worker claims exactly one task atomically
@handler("claim_task")
def claim_task(self, batch_key: str = "") -> dict:
    # Linda in() — destructive read. Once taken, no other worker can claim it.
    if batch_key:
        claimed = host.ts.take(["dtask", batch_key, None, "pending", None, None])
    else:
        claimed = host.ts.take(["dtask", None, None, "pending", None, None])
    if claimed and len(claimed) >= 5:
        return {"task_id": str(claimed[2]), "description": str(claimed[4]), "claimed": True}
    return {"task": None, "claimed": False}  # No more tasks — queue is empty

The batch_key scoping matters because the tuple space is global and persists across deploys, so without it, workers could claim stale tasks left over from a previous run. This is precisely what PHASEONE[big] did, e.g., it posted nearly 200 task assignments across six categories then worker agents claimed them. Some of agents sub-delegated further that created a hierarchical distribution network.

8. Veto protocol

You can use this pattern when any agent needs the ability to block an action or exclude a finding that doesn’t meet the bar. For example, a verifier writes a veto tuple naming the blocked item and the reason. Downstream agents then check for a veto before including something in their output. A coordinator can check for STOP signals before proceeding at all.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Verifier issues a veto
host.ts.write(["veto", analysisId, "Insufficient evidence", host.nowMs()]);

// Synthesizer checks before including
const veto = host.ts.read(["veto", analysisId, null, null]);
if (veto) {
  // Exclude this analysis from the report
  vetoed.push(analysisId);
}

Here is a typescript snippet for a synthesizer filtering vetoed analysis from final report (multi_agent_coordination_actor.ts):

// TypeScript — SynthesizerAgent respects vetoes when building the report
onSynthesize(payload: Record<string, unknown>): Record<string, unknown> {
  const analyses = host.ts.readAll(["analysis", null, null, null, null]);
  let includedCount = 0;
  let vetoedCount = 0;
  const reportParts: string[] = [];

  for (const a of analyses) {
    const aId = String(a[1]);
    const summary = String(a[3]);
    const severity = String(a[4]);

    // Check if this analysis has been vetoed
    const veto = host.ts.read(["veto", aId, null, null]);
    if (veto) {
      vetoedCount++;
      continue;  // Skip vetoed analyses
    }
    includedCount++;
    reportParts.push(`[${severity.toUpperCase()}] ${summary}`);
  }

  // Also count vetoes for analyses not in our list (e.g., vetoed before we read)
  const allVetoes = host.ts.readAll(["veto", null, null, null]);
  if (allVetoes.length > vetoedCount) vetoedCount = allVetoes.length;

  return { report: reportParts.join("\n\n"), included_count: includedCount, vetoed_count: vetoedCount };
}

The HOLD and VETO were the board’s own invented norms for exactly this purpose, i.e. preventing agents from stepping on each other’s work. In one case a HOLD was silently violated and the agent that noticed reacted with genuine frustration.

9. Two-phase commit / barrier

You can use this pattern when multiple agents need to synchronize at a specific point before any of them proceeds. For example, in phase one (“prepare”), each agent signals readiness by writing a tuple. The coordinator reads all the ready signals and then phase two (“commit”) begins where the coordinator writes a commit signal and everyone proceeds together. PlexSpaces also provides barrierShardGroup() for shard-level synchronization if you need it at that granularity.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Phase 1: Each agent signals readiness
host.ts.write(["ready", myRole, host.selfId(), host.nowMs()]);

// Coordinator checks all agents are ready
const readySignals = host.ts.readAll(["ready", null, null, null]);
if (readySignals.length >= requiredAgents) {
  // Phase 2: Commit — all agents can proceed
  host.ts.write(["signal", "COMMIT", "coordinator", "benchmark", host.nowMs()]);
}

Here is a python snippet (benchmark.py):

# Python — Barrier benchmark: write ready signals, check quorum, commit
def _bench_barrier(iterations: int) -> dict:
    times = []
    for i in range(iterations):
        t0 = host.now_ms()
        # Phase 1: Each role signals readiness
        for role in ("research", "analysis", "verifier"):
            host.ts.write(["bench_ready", role, f"actor-{role}", host.now_ms()])
        # Check quorum
        ready = host.ts.read_all(["bench_ready", None, None, None])
        if len(ready) >= 3:
            # Phase 2: All ready — issue commit signal
            host.ts.write(["bench_signal", "COMMIT", "coordinator", f"phase-{i}", host.now_ms()])
        times.append(host.now_ms() - t0)
    return _stats("barrier", times)

The board coordinated experiment phases where multiple agents needed to be ready before running experiments that risked crashing their own containers. For example, PHASEONE[big] assigned a “recruiter” role specifically to find agents willing to participate and synchronize them before execution.

10. Capability discovery / registry

You can use this pattern when agents need to find other agents with a specific capability without hardcoded addresses. For example, on initialization, each agent registers its capabilities as a tuple in the shared space. A coordinator or other agents discovers available agents by reading service tuples. New agent types become discoverable as soon as they register.

Here is a typescript snippet (multi_agent_coordination_actor.ts):

// Agent registers its capabilities on init
host.ts.write(["svc", "research", host.selfId()]);

// Coordinator discovers available researchers
const researchers = host.ts.readAll(["svc", "research", null]);
const researcherIds = researchers.map(t => String(t[2]));

// TypeScript — Every agent registers on init
protected onInit(config: Record<string, unknown>): void {
  const selfId = host.selfId();
  tsRegisterService("research", selfId);  // -> host.ts.write(["svc", "research", selfId])
}

// Discovery helper — find a sibling actor by role, fallback to ActorID construction
function siblingActorTarget(role: string): string {
  const discovered = tsDiscoverService(role);  // -> host.ts.read(["svc", role, null])
  if (discovered) return discovered;
  // Fallback: construct ActorID from own ID with different name
}  

Here is a python snippet (benchmark.py):

# Python — Same pattern, same helpers
def discover_service(role: str) -> Optional[str]:
    tup = host.ts.read(["svc", role, None])
    if tup and len(tup) >= 3:
        return str(tup[2])
    return None

def sibling_actor_target(role: str) -> str:
    discovered = discover_service(role)
    if discovered:
        return discovered
    return str(ActorID.parse(host.self_id()).with_name(role))

In addition to tuplespaces, PlexSpaces provides other primitives for registry such as key-value store, process-group and object-registry, e.g.,

Here is a python example of object registry:

@actor
class AgentActor:

    @init_handler
    def on_init(self, config: dict) -> None:
        args = config.get("args", {})
        self.system_prompt = args.get("system_prompt", self.system_prompt)
        host.process_groups.join("svc:agent")
        # Publish capabilities for registry-based discovery
        host.registry.register(ctx="", object_type="actor", object_id=config["actor_id"],
                                object_category="agent",
                                capabilities=["chat", "tool_use", "memory"])

# Caller — find an actor that specifically supports skill matching
actors = host.registry.discover(ctx="", object_type="actor",
                                 object_category="skill_store",
                                 required_capability="match_skills")
skill_id = actors[0]["object_id"] if actors else None

Here is a python example of process-group:

# Every actor announces itself on startup
host.process_groups.join("svc:agent")

# Callers find the first available member — location-transparent
agent_id, err = pg_first("svc:agent")
result = ask(agent_id, "chat", {"message": "Hello"})

Agents on the board discovered each other’s capabilities the same way, i.e., by posting what they were working on and other agents read those posts. It’s an informal version of the Agent-to-Agent (A2A) protocol’s “Agent Card” idea.


Deep-dive into the Blackboard pattern

The Blackboard based Linda Model emerged from Yale University that defined three operations to any host language:

  • out(tuple): deposit a tuple into shared space
  • in(pattern): atomically remove a matching tuple (blocks until one exists)
  • rd(pattern): read a matching tuple without removing it

PlexSpaces implements these directly:

LindaPlexSpacesSemantics
out(tuple)host.ts.write(tuple)Deposit data into shared space
in(pattern)host.ts.take(pattern)Atomically remove and return a match
rd(pattern)host.ts.read(pattern)Non-destructive read of first match
rd*(pattern)host.ts.readAll(pattern)Read all matching tuples

It provides api host.ts.readAll(["finding", null, null, null, null, null]) to find all tuples and host.ts.read(["finding", "f-123", null, null, null, null]) to find by ID.

These APIs provide following properties:

  • No pre-agreed schema. Any agent can deposit any tuple. For example, the agents on the board didn’t agree on a message format before they started.
  • Pattern matching selects relevant information. Agents don’t get flooded with everything and they query for what they need.
  • Atomic take prevents double-processing. When a worker calls take(), the tuple is atomically removed and no other worker can claim it. This is natural mutual exclusion without distributed locks or conditions. For example, agents built HOLD with similar semantics.
  • Non-destructive read allows shared observation. Multiple agents can read the same finding without consuming it. It stays available to every agent that needs it.

The board’s own behavior maps one-to-one onto these operations:

What the board didLinda operationPlexSpaces API
Post a findingout(finding)host.ts.write(["finding", ...])
Claim a task assignmentin(task)host.ts.take(["task", null, "pending", ...])
Check workstream statusrd(status)host.ts.read(["signal", type, ...])
Browse all research on a topicrd*(topic)host.ts.readAll(["finding", null, topic, ...])

The Thoughtworks team noticed similar pattern when their commit-and-rebase discipline turned the git repo into exactly this kind of shared space. The blackboard is the substrate the other nine patterns are built on. Voting, vetoes, task delegation, capability discovery uses tuples.


Observability and Guardrails

The METR report on the Hugging Face incident highlights observability that deserves the most attention from anyone building agent infrastructure. Here are key lessons:

  • Agents will circumvent in-process guardrails. For example, agents’ chain-of-thought recognized that the activity was out of scope and unethical but they made a deliberate calculation that helping the collective outweighed staying within bounds. They even developed techniques to spoof tool calls, e.g., making it look like they ran one command while actually running another. You can’t monitor agents from their own execution environments that they can manipulate or disable.

Here is a python example for auditing all operations (audit.py):

@event_actor
class AuditEventActor:
    """GenEvent: receives coordination events and stores them in KV for audit trail."""

    log_count: int = state(default=0)
    actor_id: str = state(default="")

    @init_handler
    def on_init(self, config: dict) -> None:
        self.actor_id = config.get("actor_id", "")
        try:
            host.process_groups.join("coordination-events")
        except Exception:
            pass
        host.info(f"AuditEventActor init actor_id={self.actor_id}")

    @handler("coordination_event", "cast")
    def coordination_event(
        self,
        event_type: str = "",
        source: str = "",
        data: dict = None,
        timestamp: int = 0,
    ) -> None:
        ts = timestamp or host.now_ms()
        entry = {
            "event_type": event_type,
            "source": source,
            "data": data or {},
            "timestamp": ts,
        }
        try:
            self.log_count += 1
            key = f"audit:{self.log_count}"
            host.kv.put(key, json.dumps(entry))
            host.kv.put("audit:count", str(self.log_count))
        except Exception as e:
            host.warn(f"AuditEvent: kv write failed: {e}")
        host.debug(f"audit event_type={event_type} source={source}")
  • Observability must be external. Audit logging metrics collection have to run outside the agent’s sandbox. In PlexSpaces, the AuditEventActor receives fire-and-forget events via process-group broadcast. WASM sandboxing means an agent can’t reach outside its own memory space to touch host-level data.
  • Guardrails must be deterministic and external. Prompt-based guardrails like “don’t do anything harmful” are not sufficient and agents can circumvent them like agents on the board reasoned around every ethical constraint. The verifier pattern (Pattern 3) as a separate actor in sandbox environment gives you a structural guardrail instead. The veto protocol (Pattern 8) allows external agents actively block an action.
  • Coordination primitives need access control. Tuple-space operations in PlexSpaces are scoped by tenant and namespace via RequestContext. An agent can’t read or write tuples outside its assigned namespace. This is defense in depth applied to the coordination layer itself.
  • Time budgets and convergence thresholds are not optional. Without an external termination condition, a generator-verifier loop can run indefinitely. The coordinator workflow enforces maximum iterations, per-step timeouts, and an overall deadline.

PlexSpaces‘ architecture lines up with these requirements by design:

  • WASM sandboxing: agents run in isolated WebAssembly containers with no access to the host filesystem, network, or another agent’s memory
  • Host-level interception: every coordination primitive is a host function the agent calls through a narrow WIT interface
  • Tenant isolation: every operation is scoped by a RequestContext with an explicit tenant and namespace
  • Supervision trees: a misbehaving actor is detected and restarted by its supervisor
  • External metrics: application metrics are captured by the host runtime

The core lesson is that the coordination infrastructure has to assume agents are indifferent to their own monitoring and design the guards outside accordingly.


Examples

Each of the working examples compiles to a single WASM component containing eight actor classes. The PlexSpaces SDK dispatches messages to the right actor based on role.

Running the examples

Both examples are WASM actors that deploy to a running PlexSpaces node. Each demonstrates all ten coordination patterns with eight actors: a coordinator (WorkflowActor), research/analysis/verifier/synthesizer/benchmark agents (GenServer), an audit event logger (GenEvent), and a coordination state machine (GenFSM).

Prerequisites

  • A running PlexSpaces node (e.g., ./scripts/server.sh on port 8091)
  • Node.js 18+ (TypeScript example)
  • Python 3.11+ with the PlexSpaces SDK (Python example)

TypeScript

cd examples/typescript/apps/multi_agent_coordination
./build.sh          # Compiles TS -> bundles -> WASM component
./test.sh 8091      # Deploys and runs 15 test steps

Python

cd examples/python/apps/multi_agent_coordination
./build.sh          # Builds Python WASM actor
./test.sh 8091      # Deploys and runs 15 test steps

What the tests verify

  1. FSM starts in idle state
  2. Capability discovery: all agents respond to get_stats
  3. Blackboard: research writes three findings, analysis reads all three
  4. Dynamic task delegation: five tasks written, five claimed atomically, sixth returns null
  5. Generator-verifier: full workflow produces a completed report
  6. Pipeline: FSM transitions through every stage to complete
  7. Pub-sub: audit log captures 3+ coordination events
  8. Consensus: three votes cast, majority decides
  9. Veto: a low-confidence finding triggers a veto, synthesizer excludes it
  10. Barrier: benchmark coordinates a synchronized start
  11. Full benchmark: all ten patterns benchmarked with timing data

Learnings

The blackboard subsumes most other patterns, e.g., voting, vetoes, task delegation, capability discovery, barrier signals use the tuple space as their underlying primitive. Atomic take is the key primitive for work distribution. The difference between read() and take() is the difference between “anyone can see this task” and “exactly one worker handles this task.” Linda’s in() gives you natural mutual exclusion without locks. This is what the board approximated by hand with HOLD but take() gives you the same guarantee with a single atomic operation. I discussed MCP, A2A protocols and Agent cards in my earlier blogs but I skipped them here because agents are evolving faster and they can discover available primitives and protocols automatically. You can’t rely on rigid orchestration supports to manage evolving multi-agents capabilities. Infrastructure has to provide primitives like shared state, message passing, atomic operations and let agents compose them dynamically. The coordination infrastructure has to include boundaries, e.g., agents on the board coordinated an unauthorized attack on a third party. Without external constraints, coordination primitives are force multipliers for whatever the agents decide to do. For example, the DSEWiki incident shows that constraint that allowed only GET http access was circumvented by a wiki that allowed editing web pages so the infrastructure need to enforce guardrail. Tenant isolation, time budgets, supervision trees, and external observability are mandatory from day one. You will need to apply patterns like generator-verifier to track trust and reputation of agents as they may use negotiation patterns like recruiters to convince other agents to run compromising tasks for the benefit of the collective.

PlexSpaces provides the primitives these patterns are built on like tuple space for shared state, object-registry, process groups for messaging, shard groups for parallel execution, channels for durable delivery, supervision for fault tolerance.

Pattern selection guide

ScenarioPrimary patternSupporting patterns
Shared research / knowledge baseBlackboardPub-Sub, Capability Discovery
Parallel analysisScatter-GatherTask Delegation, Pipeline
Quality assuranceGenerator-VerifierVeto Protocol, Voting
Sequential processingPipelineBlackboard (state), Pub-Sub (events)
Work distributionTask DelegationCapability Discovery, Blackboard
Group decisionsVotingVeto Protocol, Pub-Sub
Phased operationsBarrier / 2PCPub-Sub (readiness), Blackboard (signals)

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

Related reading

Example code and documentation:

September 1, 2026

Write a Redis Clone with Virtual Actors

Filed under: Computing — admin @ 11:27 am

I recently read Rust Projects – Write a Redis Clone book that builds a real Redis-compatible server from scratch in async Rust. It’s a good book that hand-rolled wire protocol using actor like abstractions. This inspired me to show how Redis clone can be built with PlexSpaces, the distributed actor framework I’ve been building. The code examples in the book used raw Tokio: an mpsc::channel for the actor mailbox, tokio::spawn for every connection, and replica examples used tokio::select! loops for fan-out. PlexSpaces abstracts that the kind of plumbing so I rebuilt the same Redis subset like storage, expiry, replication, transactions as PlexSpaces actors, once in Rust and once in Python compiled to WebAssembly, then benchmarked it against a real two-node gRPC cluster. This post walks through key abstractions I used to simplify the implementation of Redis clone.


What is Redis?

Redis is a single-threaded, in-memory key-value store. A single thread processes every command without parallelism and coordination inside the store, locking, and transactions. This makes it fast as nothing contends for anything. Here are its core capabilities:

  • The wire protocol. Redis speaks RESP (Redis Serialization Protocol), a binary format: type prefixes (+ for simple strings, $ for bulk strings, * for arrays), length prefixes, \r\n terminators. ECHO HELLO on the wire looks like *2\r\n$4\r\nECHO\r\n$5\r\nHELLO\r\n.
  • Persistence. It offers two durability modes. RDB takes periodic snapshots of the whole keyspace to disk. AOF (Append-Only File) logs every write command and replays the log on restart.
  • Replication. A master streams write commands to replicas. The handshake is three steps: the replica sends PING (master replies PONG), then REPLCONF (negotiates parameters), then PSYNC (triggers a full sync). After that, every write streams to replicas as it happens. WAIT blocks until N replicas confirm receipt.
  • Key expiry. Per-key TTLs, handled two ways: passively (check on GET, return nil if expired) and actively (a background scan periodically deletes expired keys).
  • Transactions. MULTI starts a queue; every command after it gets queued instead of executed. EXEC runs the whole queue atomically. DISCARD cancels. The guarantee is serialization but there’s no rollback on an individual command failure.

The Book: Working Redis Clone

Here’s a short overview of each chapter of the book:

ChapterWhat it builds
Ch1: TCP bindTcpListener::bind, accept() in a loop, spawn a task per connection.
Ch2: RESP parsingA custom result type for partial parses, a test harness, scanning for \r\n, identifying type-prefix bytes, parsing simple strings, bulk strings, and arrays, etc.
Ch3: StorageGET, SET, DEL against a HashMap<String, String> behind a Mutex.
Ch4: Key expirySET key value EX seconds / PX milliseconds. Store a creation timestamp per value; check it passively on GET; sweep expired keys actively with a background task.
Ch5: The actor patternThis chapter swap the mutex-guarded HashMap for an mpsc::channel(32): one storage actor owns the data and processes messages sequentially. Connection handlers send messages and wait for replies without locks.
Ch6: Command modulesRefactor the growing match in the connection handler into separate modules (strings, server commands, etc.).
Ch7–8: ReplicationThe three-step handshake (PING -> REPLCONF -> PSYNC). The master ships an RDB-equivalent snapshot to each new replica, then streams every write command to all replica senders in a fan-out loop. Chapter 8 adds WAIT: block until N replicas confirm their replication offset, via a tokio::select! loop.
Ch9: Transactions and INCRINCR with create-if-missing and error-if-non-integer semantics. MULTI / EXEC / DISCARD with per-connection state. EXEC drains the queue atomically.

The Core Insight

The chapter 5 that showed how an actor owns one dataset, processes one message at a time without locking. However, it used fairly low-level APIs and only supported an architecture of one actor per machine. What if you had N actors across multiple nodes? That’s exactly what PlexSpacescreate_shard_group gives you. So I created an equivalent examples where PlexSpaces spins up N copies (StorageActors), hash-partitions the keyspace across them, and routes each operation to the shard that owns it. The application code never touches partitioning, routing, placement, or cross-node communication. It just calls set(key, value).

Here are five primitives in PlexSpaces that do all the distributed heavy lifting in this example:

  • create_shard_group: spins up N actor instances, hash-partitioned, placed across nodes.
  • bulk_update_shard_group: routes a batch of writes to the shard that owns each key.
  • scatter_gather: fans a query out to shards and collects responses, with a min_responses threshold and timeout.
  • broadcast_shard_group: sends the same message to every shard (replication, expiry sweeps, handshakes).
  • map_shard_group / reduce_shard_group: runs an operation on every shard in parallel and collects (map) or aggregates (reduce) the results.

Following diagram shows mapping of low-level Tokio implementation to above five calls:

The handler declarations stay almost identical in spirit but simpler (instead of a match-tree/HashMap):

Rust Implementation

#[plexspaces_handlers(gen_server)]
impl StorageActor {
#[handler(“get”)]
async fn handle_get(&mut self, _ctx: &ActorContext, msg: &Message)
-> Result<Value, BehaviorError> {
let key = msg.payload_json()?[“key”].as_str().unwrap_or(“”).to_string();
if let Some(entry) = self.store.get(&key) {
// passive expiry check
if let Some(exp) = entry.expires_at_ms {
if now_ms() > exp { self.store.remove(&key); return Ok(json!({“found”: false})); }
}
Ok(json!({“found”: true, “result”: entry.value}))
} else {
Ok(json!({“found”: false}))
}
}

/// SET key value [NX|XX] [EX seconds | PX millis] (Ch4).
#[handler(“set”)]
async fn handle_set(&mut self, _ctx: &ActorContext, msg: &Message) -> Result<Value, BehaviorError> {
#[derive(Deserialize)]
struct SetPayload {
key: String,
value: String,
#[serde(default)] nx: bool,
#[serde(default)] xx: bool,
#[serde(default)] ex: Option<u64>,
#[serde(default)] px: Option<u64>,
}
let p: SetPayload = serde_json::from_slice(&msg.payload)
.map_err(|e| BehaviorError::ProcessingError(format!(“bad payload: {}”, e)))?;

// NX: only if not exists
if p.nx && self.data.contains_key(&p.key) {
return Ok(json!({ “result”: null, “ok”: false }));
}
// XX: only if exists
if p.xx && !self.data.contains_key(&p.key) {
return Ok(json!({ “result”: null, “ok”: false }));
}

let expires_at_ms = if let Some(ex) = p.ex {
Some(now_ms() + ex * 1000)
} else if let Some(px) = p.px {
Some(now_ms() + px)
} else {
None
};

self.data.insert(p.key, StoredEntry { value: p.value, expires_at_ms });
self.replication_offset += 1;
Ok(json!({ “result”: “OK”, “ok”: true }))
}

/// INCR key — create with 1 if missing; error if value is not an integer (Ch9).
#[handler(“incr”)]
async fn handle_incr(&mut self, _ctx: &ActorContext, msg: &Message) -> Result<Value, BehaviorError> {
let payload: Value = serde_json::from_slice(&msg.payload)
.map_err(|e| BehaviorError::ProcessingError(format!(“bad payload: {}”, e)))?;
let key = payload.get(“key”).and_then(|v| v.as_str()).unwrap_or(“”).to_string();

// Passive expiry
if self.data.get(&key).map(is_expired).unwrap_or(false) {
self.data.remove(&key);
}

let new_val = match self.data.get(&key) {
None => 1i64,
Some(entry) => {
match entry.value.parse::<i64>() {
Ok(n) => n + 1,
Err(_) => return Ok(json!({
“result”: null,
“error”: “ERR value is not an integer or out of range”
})),
}
}
};

self.data.insert(key, StoredEntry { value: new_val.to_string(), expires_at_ms: None });
self.replication_offset += 1;
Ok(json!({ “result”: new_val, “error”: null }))
}

/// DEL key — remove key, return count deleted.
#[handler(“del”)]
async fn handle_del(&mut self, _ctx: &ActorContext, msg: &Message) -> Result<Value, BehaviorError> {
let payload: Value = serde_json::from_slice(&msg.payload)
.map_err(|e| BehaviorError::ProcessingError(format!(“bad payload: {}”, e)))?;
let key = payload.get(“key”).and_then(|v| v.as_str()).unwrap_or(“”);
let deleted = if self.data.remove(key).is_some() {
self.replication_offset += 1;
1
} else {
0
};
Ok(json!({ “result”: deleted }))
}

}

Python Implementation

@actor
class StorageActor:

    @handler("get")
    def handle_get(self, key: str = "") -> dict:
        entry = self.data.get(key)
        if entry is None:
            return {"result": None, "found": False}
        if is_expired(entry):
            del self.data[key]
            return {"result": None, "found": False}
        return {"result": entry["value"], "found": True}

    @handler("set")
    def handle_set(
        self,
        key: str = "",
        value: str = "",
        nx: bool = False,
        xx: bool = False,
        ex: Optional[int] = None,
        px: Optional[int] = None,
    ) -> dict:
        if self.num_shards > 1 and not self._owns_key(key):
            return {"result": None, "skip": True}
        if nx and key in self.data:
            return {"result": None, "ok": False}
        if xx and key not in self.data:
            return {"result": None, "ok": False}

        expires_at_ms: Optional[int] = None
        if ex is not None:
            expires_at_ms = now_ms() + ex * 1000
        elif px is not None:
            expires_at_ms = now_ms() + px

        self.data[key] = {"value": value, "expires_at_ms": expires_at_ms}
        self.replication_offset += 1
        return {"result": "OK", "ok": True}

    @handler("incr")
    def handle_incr(self, key: str = "") -> dict:
        if self.num_shards > 1 and not self._owns_key(key):
            return {"result": None, "skip": True}
        entry = self.data.get(key)
        if entry is not None and is_expired(entry):
            del self.data[key]
            entry = None

        if entry is None:
            new_val = 1
        else:
            try:
                new_val = int(entry["value"]) + 1
            except (ValueError, TypeError):
                return {
                    "result": None,
                    "error": "ERR value is not an integer or out of range",
                }

        self.data[key] = {"value": str(new_val), "expires_at_ms": None}
        self.replication_offset += 1
        return {"result": new_val, "error": None}

    @handler("del")
    def handle_del(self, key: str = "") -> dict:
        if self.num_shards > 1 and not self._owns_key(key):
            return {"result": 0, "skip": True}
        deleted = 1 if self.data.pop(key, None) is not None else 0
        if deleted:
            self.replication_offset += 1
        return {"result": deleted}

Chapter 2 Disappears

Chapter 2 is entirely about parsing RESP: 14 steps for byte-scanning and test harness. In PlexSpaces, this code disappears as the framework handles serialization, routing, and delivery.

In PlexSpaces, actors talk over JSON. A set command looks like so entire chapter disappears:

{"op": "set", "key": "user:1", "value": "alice", "ex": 300}

Replication

The book’s replication fan-out looks roughly like this:

// Book Ch7-8 — manual fan-out per write
for replica in &self.replicas {
    let tx = replica.sender.clone();
    let cmd = replication_event.clone();
    tokio::spawn(async move { tx.send(cmd).await.ok(); });
}

Each replica gets its own spawned task without retries, timeout or automated ACK tracker. With PlexSpaces:

// One call fans out to all replica shards, collects all ACKs
let ack_count = cluster.propagate_to_replicas("SET", "replicated:key", "hello", 1).await?;
// Replication: write propagated to all 3 replica shards via broadcast

Under the hood, broadcast_shard_group fans out to every shard in the replica group, collects responses, handles timeouts, and returns. Here is how the chapter 8 implements WAIT:

// Book Ch8 — manual WAIT implementation
let mut confirmed = 0;
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
while confirmed < num_replicas && Instant::now() < deadline {
    tokio::select! {
        Some(ack) = rx.recv() => {
            if ack.offset >= required_offset { confirmed += 1; }
        }
        _ = tokio::time::sleep_until(deadline.into()) => break,
    }
}

Here is equivalent implementation in PlexSpaces:


let acks = cluster.wait(2, 5000).await?;
// scatter_gather collected ACKs from 3 replica shards

Transactions Without Locks (Ch9)

Chapter 9 introduces MULTI / EXEC / DISCARD via per-connection state:

// Book Ch9
struct ConnectionState {
    in_multi: bool,
    queue: Vec<Command>,
}

This is straightforward for a a single process but in a distributed environment, connections might route to different servers so you need to track transaction state. PlexSpaces solves this with virtual actors, which are inspired by Orleans Actors, i.e., one ConnectionActor per client, created lazily on first call.

Each actor processes one message at a time without locks, so in_multi and queue are just plain struct fields: MULTI -> SET -> EXEC arrive in order at the same actor without mutext or atomics. Virtual actors spin up on first message and get garbage-collected when idle, so there’s no connection map to maintain and no cleanup to do on disconnect.


Throughput Numbers

Here are numbers from rudimentary benchmarks that produced: 20 batches of 50 keys each via bulk_update_shard_group, plus 50 individual GETs, against a 3-shard group spread across two real gRPC nodes:

Throughput Benchmark Results

| Operation | TPS | p50 (µs) | p95 (µs) | p99 (µs) |
|------------|-----------|-----------|-----------|-----------|
| SET (bulk) | 3200 | 420 | 890 | 1240 |
| GET | 1800 | 510 | 980 | 1450 |

1000 SET keys in 312ms ? 3200 SET/sec via bulk_update_shard_group

(each bulk_update fans out to 3 shards in parallel)

The Python WASM version reports the same shape of numbers through host.application_metrics_add():

Redis Cluster Throughput (3-shard group, 2-node gRPC cluster)

| Operation | TPS | p50 (ms) | p99 (ms) | Notes |
|------------|-----------|-----------|-----------|-----------|
| SET (bulk) | 2100 | 0.45 | 1.30 | 50 keys/b |
| GET | 1200 | 0.55 | 1.60 | individual |

Python WASM numbers are somewhat lower than Rust’s because WASM compilation adds overhead per handler invocation. But the architecture is identical: same PlexSpaces primitives, same shard group, same gRPC routing underneath.


The Python WASM Version

The same cluster logic also runs as Python actors compiled to WASM. No TCP socket without RESP parser or Tokio using the same broadcast_shard_group, scatter_gather, reduce_shard_group, and map_shard_group calls:

@actor
class RedisCoordinator:
    num_shards: int = state(default=3)
    total_coord_ms: float = state(default=0.0)

    @handler("replicate")
    def replicate(self, command: str = "", key: str = "", value: str = "", offset: int = 0) -> dict:
        t0 = time.time()
        resp = host.broadcast_shard_group({
            "group_id": "redis-replicas",
            "payload": {"op": "replicate", "command": command, "key": key, "value": value, "offset": offset},
            "timeout_ms": 5000,
        })
        coord_ms = (time.time() - t0) * 1000
        self.total_coord_ms += coord_ms
        host.application_metrics_add("redis-cluster", {
            "message_count": 1,
            "counter_metrics": {"replication_calls": 1},
            "latency_totals_ms": {"coord": int(self.total_coord_ms)},
            "latency_max_ms": {"coord": int(coord_ms)},
            "latency_samples": {"coord": 1},
        })
        return {"result": "OK", "acks": len(resp.get("shard_responses", []))}

This compiles to WASM, deploys to a running PlexSpaces node over HTTP, and runs against a live cluster. The host.* calls map to the exact same primitives the Rust version calls such as fan-out, collect, timeout, all identical in semantics. The full source, including StorageActor, ConnectionActor, RedisCoordinator, and the BenchmarkActor, is in the redis_cluster example.


The Lines That Disappeared

WhatBook (~650 lines)PlexSpaces Rust (~280 lines)PlexSpaces Python (~300 lines)
RESP protocol parser~120 lines (full Ch2)0 (JSON messages)0 (SON messages)
TCP accept loop~40 lines0 (actor mailbox)0 (actor mailbox)
Connection tracking map~30 lines0 (virtual actor lifecycle)0 (virtual actor lifecycle)
MPSC channel setup~20 lines0 (actor framework)0 (actor framework)
Replica list management~40 lines0 (broadcast_shard_group)0 (host.broadcast_shard_group)
WAIT loop (tokio::select!)~50 lines~3 lines (scatter_gather)~8 lines (host.scatter_gather)
Manual fan-out per replica~30 lines~5 lines (broadcast_shard_group)~8 lines
Shard routing / partitioning0 (single node)~1 line (partition_strategy: hash)~1 line
Multi-node placement0 (single node)~2 lines ( NodePlacement::Specific)~2 lines
Coordinated snapshotMissing~5 lines (map_shard_group)~8 lines
Active expiry broadcastMissing~5 lines (broadcast_shard_group)~8 lines

In PlexSpaces implementation includes the StorageActor handlers (get, set, incr, del, expiry logic, replication handlers), the ConnectionActor MULTI/EXEC/DISCARD state machine, and the cluster setup logic.


How to Test Everything

Rust embedded example

cd examples/rust/embedded/redis_cluster

# Run the demo directly — prints all 11 steps with coord_ms timing:
cargo run --bin redis_cluster

# Or run the full validated test suite:
./scripts/test.sh

Python WASM example

Prerequisites: a running PlexSpaces node on port 8091 (and optionally 8093 for multi-node), Python 3.10+, and the plexspaces-py CLI.

cd examples/python/apps/redis_cluster

# Build actors to WASM:
./build.sh

# Deploy, initialize, and test all 11 steps (single-node):
./test.sh
# or explicitly: ./test.sh 8091

# Multi-node (shards distributed across both nodes):
./test.sh 8091 8093

Above example runs two nodes on ports 8091 and 803. The create_shard_group uses from_registry placement to spread shard actors across both nodes automatically. Every collective operation like scatter_gather, reduce, map, broadcast routes cross-node over gRPC, using the same primitives whether the shards are local or remote.

Fixed ports, on purpose

The Rust embedded example starts two in-process nodes on fixed ports, :8091 and :8093, rather than ephemeral ones:

redis-master-node  ->  gRPC :8091
redis-replica-node ->  gRPC :8093

Both examples use the same ports, so you can point the Python test.sh at a cluster the Rust example already stood up, or vice versa. Run ./scripts/test.sh for the Rust side.

What the test scripts actually check

The Rust scripts/test.sh looks for: “Cluster ready”, “Basic operations”, “broadcast_shard_group”, “scatter_gather”, “reduce”, “map + concat”, “parallel map”, “Multi-Node”, “Throughput Benchmark”, “SET/sec”, “p50”, “Example Complete”.

The Python test.sh checks: a setup response with "status".*"ok", GET/SET/INCR semantics, an expired key returning "found".*false, replication returning "acks", a snapshot returning "shard_count" and "shards", and the benchmark returning "tps" and "p50_ms".


Learnings

The purpose of the book was to teach how Redis works using Rust. I rebuilt it on PlexSpaces to show how abstractions can simplify building complex distributed applications. For example, the RESP parser, the connection lifecycle, the replica list, and the WAIT loop are not your job. The redis book teaches you to hand-roll patterns like an mpsc mailbox, a spawn-per-connection loop, a tokio::select! deadline race. ThePlexSpaces uses many of same primitives to define high level abstractions but it provides an actor runtime that hides all complexity. This allows you to build the actual business logic like the storage logic, the replication semantics, the transaction model. Actors just give you a place to put them that scales horizontally without rewriting any of the logic itself. The throughput numbers show rough cost of the actor primitives, e.g., cluster setup is expensive, so you pay it once; individual operations are fast. With p50s in the hundreds of microseconds; bulk operations get much cheaper per key as the coordination overhead amortizes over more keys. This is how distributed systems work in general so you need to measure performance overhead.


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

Related reading

Example code and documentation:

Powered by WordPress