dcontext-dactor 0.7.1

Automatic dcontext propagation through dactor actor messages
Documentation

dcontext-dactor

Automatic dcontext propagation through dactor actor messages.

Crates.io Docs.rs

When actors send messages to each other — locally or across the network — distributed context (request IDs, tenant info, feature flags, etc.) needs to travel with those messages. dcontext-dactor makes this transparent by providing inbound and outbound interceptors that handle serialization, deserialization, and scope restoration automatically.

Quick Start

[dependencies]

dcontext = "0.3"

dcontext-dactor = "0.3"

dactor = "0.3.1"

use dcontext_dactor::{ContextInboundInterceptor, ContextOutboundInterceptor};

// Register interceptors with your dactor runtime
runtime.add_outbound_interceptor(Box::new(ContextOutboundInterceptor::default()));
runtime.add_inbound_interceptor(Box::new(ContextInboundInterceptor::default()));

// That's it! Context now flows automatically between actors.
// Inside any actor handler:
let rid: RequestId = dcontext::get_context("request_id");

How It Works

Two-Stage Pipeline

The crate uses a two-stage pipeline that separates header normalization from context restoration:

Sender                              Receiver
──────                              ────────
OutboundInterceptor                 InboundInterceptor
  ├─ local target?                    Stage 1: on_receive()
  │   └─ attach ContextSnapshot         ├─ wire header? deserialize → snapshot
  └─ remote target?                     └─ normalize to ContextSnapshotHeader
      └─ serialize → ContextHeader    Stage 2: wrap_handler()
         (includes scope chain)          ├─ enter named scope "remote:<actor_name>"
                                         └─ wrap future with dcontext::with_context()

Outbound — The ContextOutboundInterceptor captures the current context and attaches it to the message headers. For local targets, it uses a zero-copy snapshot (preserving local-only values). For remote targets, it serializes to wire bytes.

Inbound — The ContextInboundInterceptor works in two stages:

  1. on_receive normalizes incoming headers (deserializes wire bytes into a snapshot)
  2. wrap_handler wraps the handler future with dcontext::with_context(), so context is automatically available inside the handler — no manual restoration needed.

Local vs Remote Propagation

Scenario Header Type Serialization Local-only values
Same process ContextSnapshotHeader None ✅ Preserved
Cross-network ContextHeader Bincode bytes ❌ Excluded

Error Handling

Both interceptors accept an ErrorPolicy:

use dcontext_dactor::{ContextOutboundInterceptor, ContextInboundInterceptor, ErrorPolicy};

// Log warnings and continue (default) — messages are delivered even if
// context serialization/deserialization fails
let outbound = ContextOutboundInterceptor::default();
let inbound = ContextInboundInterceptor::default();

// Reject — message is dropped if context cannot be propagated
let outbound = ContextOutboundInterceptor::new(ErrorPolicy::Reject);
let inbound = ContextInboundInterceptor::new(ErrorPolicy::Reject);

Wire Transport Registration

If your actors communicate over the network, register the context header deserializer with dactor's HeaderRegistry:

use dcontext_dactor::register_context_headers;

let mut header_registry = dactor::HeaderRegistry::new();
register_context_headers(&mut header_registry);
// Pass header_registry to your dactor transport configuration

Manually Extracting Context

If you need the propagated context snapshot for spawning sub-tasks or other manual use, use extract_context:

use dcontext_dactor::extract_context;

async fn my_handler(ctx: &ActorContext, msg: MyMessage) {
    // Get the propagated snapshot (if any)
    if let Some(snapshot) = extract_context(ctx) {
        // Use it to propagate context to a spawned task
        dcontext::spawn_with_context_async(snapshot, async {
            // sub-task has the same context
        }).await;
    }
}

Full Example

See samples/src/bin/dactor_propagation.rs for a complete working example.

Scope Chain Integration

The inbound interceptor's wrap_handler automatically creates a named scope remote:<actor_name> for each inbound message. This makes distributed call boundaries visible in the scope chain:

// Caller (e.g., API gateway) sets up context and sends a message:
let _guard = dcontext::enter_named_scope("api-gateway");
actor_ref.send(MyMessage { ... }).await;

// Inside the receiving actor handler ("OrderActor"):
let chain = dcontext::scope_chain();
// chain == ["api-gateway", "remote:OrderActor"]

This gives you a full distributed call path without manual instrumentation. The scope chain propagates through serialization (wire format v2), so the chain accumulates as requests traverse multiple services and actors.

API Reference

Type Purpose
ContextOutboundInterceptor Captures context on message send
ContextInboundInterceptor Restores context on message receive (two-stage)
ContextHeader Serialized wire header ("dcontext.wire")
ContextSnapshotHeader Local snapshot header ("dcontext.snapshot")
ErrorPolicy LogAndContinue (default) or Reject
extract_context() Manually extract propagated context
register_context_headers() Register wire deserializer with HeaderRegistry

Related

License

MIT