KubeMQ
ConnectorsAMQP 1.0Concepts

Events Store

Durable, replayable pub/sub over AMQP 1.0 — resume a subscription after a disconnect and replay history on the KubeMQ Events Store pattern.

Events Store is durable, replayable pub/sub over the AMQP 1.0 connector. Attach a link to a node whose address begins with events-store/<channel> and the connector binds it to the KubeMQ Events Store pattern. Unlike plain Events (fire-hose, no replay), an Events Store subscriber can resume where it left off after a disconnect and can replay history from a chosen start position.

Overview

The events-store/ prefix selects the Events Store pattern (longest-prefix match, evaluated before events/). A producer attaches a sender to events-store/<ch>; each TRANSFER becomes a KubeMQ SendEventsStore with Store=true, so the message is persisted. A consumer subscribes durably by attaching a receiver with terminus expiry-policy = never, a stable container-id, and a stable link Name — on reconnect with the same identity, the subscription resumes.

OperationAMQP actionKubeMQ mapping
ProduceAttach a sender to events-store/<ch>, TRANSFERSendEventsStore (Store=true) — persisted
Subscribe durablyReceiver with expiry-policy = never + stable container-id + stable link nameDurable, resumable subscription
Set replay startReceiver link property x-opt-kubemq-startStart cursor (first / last / sequence / time …)

Durable identity

A durable subscription is identified by the pair (container-id, link name). To resume, reconnect with the same container-id AND the same link name — change either and you get a different durable subscription that starts fresh. The connector derives a stable id from both:

durableID = sanitize40(containerID) + "_" + sanitize40(linkName) + "_" + fnv1a32hex(containerID + "|" + linkName)

A stable container-id is mandatory. Because the container-id is half the durable identity, a durable subscriber that lets its container-id drift across reconnects (for example, a randomly generated one) will never resume — it creates a brand-new subscription each time. Pin a stable container-id.

Start positions

A durable receiver's start position is set with the link property x-opt-kubemq-start. It applies only to events-store (it is ignored on plain Events and on RPC, which have no replay). The grammar:

x-opt-kubemq-start valueMeaning
"" or new-onlyonly messages published after the subscription starts (the default)
firstreplay from the beginning of stored history
laststart from the most recent stored message
sequence:<n>start at sequence number <n> (1-based, non-negative)
time:<RFC3339|unix-seconds>start at a wall-clock time
time-delta:<seconds>start <seconds> ago from now

Notes:

  • Time granularity is seconds; the store keeps nanoseconds. You send time: as RFC3339 or whole seconds and the connector converts to the store's nanosecond resolution. time-delta: is whole seconds, used verbatim.
  • No "last N by count". There is no way to ask for "the last N messages" — bound a replay with sequence:, time:, or time-delta:.
  • Malformed values are rejected at attach. sequence:abc, time:not-a-time, or an unknown token returns DETACH(amqp:invalid-field) naming the offending token.

For filtered replay — replaying only the subset of stored messages matching a selector — combine a start position with a selector filter on the receiver; see Address mapping.

How it works

A durable subscriber attaches with a stable identity and a non-expiring source. On a clean disconnect the connector preserves the cursor; re-attaching with the same (container-id, link name) resumes and delivers every event published while the subscriber was away.

Persisted events are replayed to a durable subscriber from its chosen start position; the cursor survives disconnects so the subscription resumes exactly where it left off.

Events-store stalled credit loses the buffered window. A consume link fronts the subscription with a deliver-first ring buffer (≈1024 by default) that is auto-acked in the store before you take delivery. If the buffer fills while your credit stays at 0, the link detaches with amqp:resource-limit-exceeded ("credit stalled") and the entire buffered, already-acked window is lost — a durable re-attach resumes after it. Replenish credit aggressively so the buffer never fills at zero credit. The only signal is the metric kubemq_amqp10_events_store_dropped_stalled_total.

Durable subscribe and resume

Each example publishes 3 events to a live durable subscriber, disconnects, publishes 5 more while the subscriber is away, then re-attaches with the same durable identity and receives exactly the 5 missed events — no loss, no re-delivery of the already-consumed first 3. Every client reads the broker endpoint from KUBEMQ_AMQP_URL (default amqp://localhost:5672).

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	amqp "github.com/Azure/go-amqp"
)

const channel = "amqp10.examples.durable"

// The durable identity = (containerID, linkName). Both MUST be stable to resume.
const (
	containerID = "amqp10-examples-durable-container"
	linkName    = "durable-sub"
)
const standingCredit = 100

func amqpURL() string {
	if v := os.Getenv("KUBEMQ_AMQP_URL"); v != "" {
		return v
	}
	return "amqp://localhost:5672"
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
	defer cancel()
	addr := "events-store/" + channel // events-store/ prefix → KubeMQ Events Store

	// Producer — a plain connection (no stable id needed) that publishes throughout.
	prodConn, _ := amqp.Dial(ctx, amqpURL(), nil)
	defer func() { _ = prodConn.Close() }()
	prodSess, _ := prodConn.NewSession(ctx, nil)
	sender, _ := prodSess.NewSender(ctx, addr, nil)

	// 1. DURABLE SUBSCRIBE (first attach): stable container-id + link name +
	//    non-expiring source + start=new-only.
	durRcv, durConn := attachDurable(ctx, "first attach")
	publish(ctx, sender, 0, 3)
	first := drain(ctx, durRcv, 3, 30*time.Second)
	fmt.Printf("first attach received %d events: %v\n", len(first), first)

	// 2. DISCONNECT — the connector preserves the durable cursor.
	_ = durConn.Close()
	time.Sleep(time.Second)

	// 3. PUBLISH 5 more while away.
	publish(ctx, sender, 3, 8)

	// 4. RE-ATTACH with the SAME identity → resumes and delivers the 5 missed events.
	durRcv2, durConn2 := attachDurable(ctx, "re-attach")
	defer func() { _ = durConn2.Close() }()
	resumed := drain(ctx, durRcv2, 5, 30*time.Second)
	fmt.Printf("re-attach resumed and received the %d events published while away: %v\n", len(resumed), resumed)
}

func attachDurable(ctx context.Context, phase string) (*amqp.Receiver, *amqp.Conn) {
	conn, err := amqp.Dial(ctx, amqpURL(), &amqp.ConnOptions{ContainerID: containerID})
	if err != nil {
		log.Fatalf("[%s] dial durable: %v", phase, err)
	}
	session, _ := conn.NewSession(ctx, nil)
	rcv, err := session.NewReceiver(ctx, "events-store/"+channel, &amqp.ReceiverOptions{
		Credit:             standingCredit,
		SourceExpiryPolicy: amqp.ExpiryPolicyNever,                           // durable signal
		Name:               linkName,                                         // stable link name
		Properties:         map[string]any{"x-opt-kubemq-start": "new-only"}, // start cursor
	})
	if err != nil {
		log.Fatalf("[%s] attach durable receiver: %v", phase, err)
	}
	time.Sleep(750 * time.Millisecond) // let the subscription pump go live
	return rcv, conn
}

func publish(ctx context.Context, sender *amqp.Sender, lo, hi int) {
	for i := lo; i < hi; i++ {
		if err := sender.Send(ctx, amqp.NewMessage([]byte(fmt.Sprintf("es-%03d", i))), nil); err != nil {
			log.Fatalf("publish: %v", err)
		}
	}
}

func drain(ctx context.Context, rcv *amqp.Receiver, max int, window time.Duration) []string {
	out := make([]string, 0, max)
	deadline := time.Now().Add(window)
	for len(out) < max && time.Now().Before(deadline) {
		rcvCtx, cancel := context.WithTimeout(ctx, time.Until(deadline))
		msg, err := rcv.Receive(rcvCtx, nil)
		cancel()
		if err != nil {
			break
		}
		_ = rcv.AcceptMessage(ctx, msg)
		out = append(out, string(msg.GetData()))
	}
	return out
}
import os
import time

from proton import Message, Terminus, symbol
from proton.reactor import Container, ReceiverOption
from proton.utils import BlockingConnection

CHANNEL = "amqp10.examples.durable"
# The durable identity = (container-id, link-name). Both MUST be stable to resume.
CONTAINER_ID = "amqp10-examples-durable-container"
LINK_NAME = "durable-sub"
START_PROP = "x-opt-kubemq-start"
STANDING_CREDIT = 100


def amqp_url() -> str:
    return os.environ.get("KUBEMQ_AMQP_URL", "amqp://localhost:5672")


class DurableSource(ReceiverOption):
    """Make the source durable + non-expiring and stamp x-opt-kubemq-start."""

    def __init__(self, start: str) -> None:
        self.start = start

    def apply(self, receiver) -> None:
        receiver.source.durability = Terminus.DELIVERIES
        receiver.source.expiry_policy = Terminus.EXPIRE_NEVER  # durable signal
        receiver.properties = {symbol(START_PROP): self.start}  # start cursor (link prop)


def attach_durable(phase: str):
    container = Container()
    container.container_id = CONTAINER_ID  # half the durable identity
    conn = BlockingConnection(amqp_url(), container=container)
    receiver = conn.create_receiver(
        "events-store/" + CHANNEL,
        credit=STANDING_CREDIT,
        name=LINK_NAME,  # the other half of the identity
        options=DurableSource("new-only"),
    )
    time.sleep(0.75)  # let the subscription pump go live
    return conn, receiver


def publish(sender, lo: int, hi: int) -> None:
    for i in range(lo, hi):
        sender.send(Message(body=f"es-{i:03d}"))


def drain(receiver, want: int, window: float) -> list[str]:
    out: list[str] = []
    deadline = time.monotonic() + window
    while len(out) < want and time.monotonic() < deadline:
        try:
            msg = receiver.receive(timeout=max(0.0, deadline - time.monotonic()))
        except Exception:
            break
        if receiver.fetcher.unsettled:
            receiver.accept()
        out.append(str(msg.body))
    return out


def main() -> None:
    addr = "events-store/" + CHANNEL  # events-store/ prefix → KubeMQ Events Store
    prod_conn = BlockingConnection(amqp_url())
    sender = prod_conn.create_sender(addr)

    # 1. DURABLE SUBSCRIBE (first attach).
    dur_conn, dur_rcv = attach_durable("first attach")
    publish(sender, 0, 3)
    first = drain(dur_rcv, 3, 30.0)
    print(f"first attach received {len(first)} events: {first}")

    # 2. DISCONNECT — the connector preserves the durable cursor.
    dur_conn.close()
    time.sleep(1.0)

    # 3. PUBLISH 5 more while away.
    publish(sender, 3, 8)

    # 4. RE-ATTACH with the SAME identity → resumes and delivers the 5 missed events.
    dur_conn2, dur_rcv2 = attach_durable("re-attach")
    resumed = drain(dur_rcv2, 5, 30.0)
    print(f"re-attach resumed and received the {len(resumed)} events published while away: {resumed}")

    dur_conn2.close()
    sender.close()
    prod_conn.close()


if __name__ == "__main__":
    main()
import java.util.HashSet;
import java.util.Set;

import javax.jms.Connection;
import javax.jms.Message;
import javax.jms.MessageConsumer;
import javax.jms.MessageProducer;
import javax.jms.Session;
import javax.jms.Topic;

import org.apache.qpid.jms.JmsConnectionFactory;

public final class Main {
    private static final String CHANNEL = "amqp10.examples.durable";
    // The durable identity = (JMS clientID, subscription name). Both MUST be stable.
    private static final String CLIENT_ID = "amqp10-examples-durable-container";
    private static final String SUB_NAME = "durable-sub";

    public static void main(String[] args) throws Exception {
        String url = System.getenv().getOrDefault("KUBEMQ_AMQP_URL", "amqp://localhost:5672");
        String address = "events-store/" + CHANNEL; // events-store/ → KubeMQ Events Store
        JmsConnectionFactory factory = new JmsConnectionFactory(url);

        // Producer — a separate connection (no stable clientID needed).
        try (Connection prodConn = factory.createConnection();
                Session prodSession = prodConn.createSession(false, Session.AUTO_ACKNOWLEDGE)) {
            prodConn.start();
            Topic topic = prodSession.createTopic(address);
            try (MessageProducer producer = prodSession.createProducer(topic)) {

                // 1. DURABLE SUBSCRIBE (first attach): clientID + durable consumer.
                try (Connection durConn = factory.createConnection()) {
                    durConn.setClientID(CLIENT_ID);
                    durConn.start();
                    try (Session durSession = durConn.createSession(false, Session.CLIENT_ACKNOWLEDGE)) {
                        Topic durTopic = durSession.createTopic(address);
                        MessageConsumer durable = durSession.createDurableConsumer(durTopic, SUB_NAME);
                        Thread.sleep(750);
                        publish(prodSession, producer, 0, 3);
                        Set<String> first = drain(durable, 3, 30_000);
                        System.out.printf("first attach received %d events: %s%n", first.size(), first);
                        durable.close(); // detach but KEEP the durable
                    }
                }
                Thread.sleep(1_000);

                // 3. PUBLISH 5 more while away.
                publish(prodSession, producer, 3, 8);

                // 4. RE-ATTACH with the SAME identity → resumes the subscription.
                try (Connection durConn2 = factory.createConnection()) {
                    durConn2.setClientID(CLIENT_ID);
                    durConn2.start();
                    try (Session durSession2 = durConn2.createSession(false, Session.CLIENT_ACKNOWLEDGE)) {
                        Topic durTopic2 = durSession2.createTopic(address);
                        MessageConsumer durable2 = durSession2.createDurableConsumer(durTopic2, SUB_NAME);
                        Set<String> resumed = drain(durable2, 5, 30_000);
                        System.out.printf(
                                "re-attach resumed and received the %d events published while away: %s%n",
                                resumed.size(), resumed);
                        durable2.close();
                        durSession2.unsubscribe(SUB_NAME); // clean teardown of the durable
                    }
                }
            }
        }
    }

    private static void publish(Session session, MessageProducer producer, int lo, int hi) throws Exception {
        for (int i = lo; i < hi; i++) {
            producer.send(session.createTextMessage(String.format("es-%03d", i)));
        }
    }

    private static Set<String> drain(MessageConsumer consumer, int max, long timeoutMillis) throws Exception {
        Set<String> out = new HashSet<>();
        long deadline = System.currentTimeMillis() + timeoutMillis;
        while (out.size() < max) {
            long remaining = deadline - System.currentTimeMillis();
            if (remaining <= 0) break;
            Message msg = consumer.receive(remaining);
            if (msg == null) break;
            msg.acknowledge();
            out.add(msg.getBody(String.class));
        }
        return out;
    }
}
using System.Text;
using Amqp;
using Amqp.Framing;
using Amqp.Types;

const string channel = "amqp10.examples.durable";
// The durable identity = (containerID, linkName). Both MUST be stable to resume.
const string containerId = "amqp10-examples-durable-container";
const string linkName = "durable-sub";
const int standingCredit = 100;
var expiryNever = new Symbol("never"); // terminus-expiry-policy = never (durable signal)

static string AmqpUrl() =>
    Environment.GetEnvironmentVariable("KUBEMQ_AMQP_URL") is { Length: > 0 } v
        ? v
        : "amqp://localhost:5672";

var addr = "events-store/" + channel; // events-store/ prefix → KubeMQ Events Store

// Producer — a plain connection (no stable id needed) that publishes throughout.
var prodConnection = await Connection.Factory.CreateAsync(new Address(AmqpUrl()));
var prodSession = new Session(prodConnection);
var sender = new SenderLink(prodSession, "durable-producer", addr);

void Publish(int lo, int hi)
{
    for (var i = lo; i < hi; i++)
    {
        var message = new Message { BodySection = new Data { Binary = Encoding.UTF8.GetBytes($"es-{i:D3}") } };
        sender.Send(message, TimeSpan.FromSeconds(15));
    }
}

(ReceiverLink Receiver, Connection Connection) AttachDurable(string phase)
{
    var factory = new ConnectionFactory();
    factory.AMQP.ContainerId = containerId; // half the durable identity
    var connection = factory.CreateAsync(new Address(AmqpUrl())).GetAwaiter().GetResult();
    var session = new Session(connection);
    var attach = new Attach
    {
        Source = new Source { Address = addr, ExpiryPolicy = expiryNever },
        Target = new Target(),
        LinkName = linkName, // the other half of the durable identity
        Properties = new Fields { { new Symbol("x-opt-kubemq-start"), "new-only" } },
    };
    var receiver = new ReceiverLink(session, linkName, attach, null);
    receiver.SetCredit(standingCredit, autoRestore: true);
    Thread.Sleep(750); // let the subscription pump go live
    return (receiver, connection);
}

List<string> Drain(ReceiverLink receiver, int max, TimeSpan window)
{
    var outp = new List<string>(max);
    var deadline = DateTime.UtcNow + window;
    while (outp.Count < max && DateTime.UtcNow < deadline)
    {
        var message = receiver.Receive(TimeSpan.FromSeconds(2));
        if (message is null) continue;
        receiver.Accept(message);
        outp.Add(BodyString(message));
    }
    return outp;
}

try
{
    // 1. DURABLE SUBSCRIBE (first attach).
    var (durRcv, durConn) = AttachDurable("first attach");
    Publish(0, 3);
    var first = Drain(durRcv, 3, TimeSpan.FromSeconds(30));
    Console.WriteLine($"first attach received {first.Count} events: [{string.Join(" ", first)}]");

    // 2. DISCONNECT — the connector preserves the durable cursor.
    await durConn.CloseAsync();
    await Task.Delay(1000);

    // 3. PUBLISH 5 more while away.
    Publish(3, 8);

    // 4. RE-ATTACH with the SAME identity → resumes the subscription.
    var (durRcv2, durConn2) = AttachDurable("re-attach");
    try
    {
        var resumed = Drain(durRcv2, 5, TimeSpan.FromSeconds(30));
        Console.WriteLine($"re-attach resumed and received the {resumed.Count} events published while away: [{string.Join(" ", resumed)}]");
        await durRcv2.CloseAsync();
    }
    finally
    {
        await durConn2.CloseAsync();
    }
}
finally
{
    await sender.CloseAsync();
    await prodSession.CloseAsync();
    await prodConnection.CloseAsync();
}

static string BodyString(Message message) => message.BodySection switch
{
    Data d => Encoding.UTF8.GetString(d.Binary),
    AmqpValue { Value: byte[] bytes } => Encoding.UTF8.GetString(bytes),
    AmqpValue { Value: string str } => str,
    AmqpValue v => v.Value?.ToString() ?? string.Empty,
    _ => string.Empty,
};
import {
  Connection,
  ReceiverEvents,
  type ConnectionOptions,
  type EventContext,
  type Receiver,
} from "rhea-promise";

const channel = "amqp10.examples.durable";
// The durable identity = (containerID, linkName). Both MUST be stable to resume.
const containerId = "amqp10-examples-durable-container";
const linkName = "durable-sub";
const standingCredit = 100;

function brokerEndpoint(): { host: string; port: number } {
  const url = new URL(process.env["KUBEMQ_AMQP_URL"] ?? "amqp://localhost:5672");
  return { host: url.hostname, port: url.port ? Number(url.port) : 5672 };
}

function durableOptions(): ConnectionOptions {
  const { host, port } = brokerEndpoint();
  return { host, port, container_id: containerId, reconnect: false }; // STABLE id
}

function bodyToString(body: unknown): string {
  return Buffer.isBuffer(body) ? body.toString("utf8") : String(body);
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

interface DurableAttach {
  connection: Connection;
  receiver: Receiver;
}

async function attachDurable(): Promise<DurableAttach> {
  const connection = new Connection(durableOptions());
  await connection.open();
  const receiver = await connection.createReceiver({
    name: linkName, // half the durable identity
    source: {
      address: `events-store/${channel}`,
      expiry_policy: "never", // durable signal
    },
    properties: { "x-opt-kubemq-start": "new-only" }, // start cursor
    credit_window: 0,
    autoaccept: false,
    autosettle: false,
  });
  await sleep(750); // let the subscription pump go live
  return { connection, receiver };
}

function drain(receiver: Receiver, max: number, windowMs: number): Promise<string[]> {
  return new Promise((resolve, reject) => {
    const out: string[] = [];
    const timer = setTimeout(() => {
      receiver.removeListener(ReceiverEvents.message, handler);
      resolve(out);
    }, windowMs);
    const handler = (ctx: EventContext): void => {
      try {
        ctx.delivery?.accept(); // accept advances the durable cursor
        out.push(bodyToString(ctx.message?.body));
        if (out.length >= max) {
          clearTimeout(timer);
          receiver.removeListener(ReceiverEvents.message, handler);
          resolve(out);
          return;
        }
        receiver.addCredit(1);
      } catch (err) {
        clearTimeout(timer);
        receiver.removeListener(ReceiverEvents.message, handler);
        reject(err instanceof Error ? err : new Error(String(err)));
      }
    };
    receiver.on(ReceiverEvents.message, handler);
    receiver.addCredit(standingCredit);
  });
}

async function main(): Promise<void> {
  const { host, port } = brokerEndpoint();
  const address = `events-store/${channel}`; // events-store/ → KubeMQ Events Store

  // Producer — a plain connection (no stable id needed) that publishes throughout.
  const prodConnection = new Connection({
    host, port, container_id: `kubemq-amqp10-js-durable-prod-${process.pid}`, reconnect: false,
  });
  await prodConnection.open();
  const sender = await prodConnection.createAwaitableSender({ target: { address } });
  const publish = async (lo: number, hi: number): Promise<void> => {
    for (let i = lo; i < hi; i++) {
      await sender.send({ body: `es-${String(i).padStart(3, "0")}` }, { timeoutInSeconds: 15 });
    }
  };

  try {
    // 1. DURABLE SUBSCRIBE (first attach).
    const first = await attachDurable();
    await publish(0, 3);
    const firstBodies = await drain(first.receiver, 3, 30_000);
    console.log(`first attach received ${firstBodies.length} events: [${firstBodies.join(" ")}]`);

    // 2. DISCONNECT — the connector preserves the durable cursor.
    await first.connection.close();
    await sleep(1_000);

    // 3. PUBLISH 5 more while away.
    await publish(3, 8);

    // 4. RE-ATTACH with the SAME identity → resumes the subscription.
    const second = await attachDurable();
    try {
      const resumed = await drain(second.receiver, 5, 30_000);
      console.log(`re-attach resumed and received the ${resumed.length} events published while away: [${resumed.join(" ")}]`);
    } finally {
      await second.connection.close();
    }
  } finally {
    await sender.close();
    await prodConnection.close();
  }
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
use std::collections::HashSet;
use std::time::Duration;

use fe2o3_amqp::connection::ConnectionHandle;
use fe2o3_amqp::link::receiver::CreditMode;
use fe2o3_amqp::session::SessionHandle;
use fe2o3_amqp::{Connection, Receiver, Sender, Session};
use fe2o3_amqp_types::definitions::SenderSettleMode;
use fe2o3_amqp_types::messaging::{Body, Source, TerminusExpiryPolicy};
use fe2o3_amqp_types::primitives::{OrderedMap, Symbol, Value};

const CHANNEL: &str = "amqp10.examples.durable";
// The durable identity = (container-id, link name). Both MUST be stable to resume.
const CONTAINER_ID: &str = "amqp10-examples-durable-container";
const LINK_NAME: &str = "durable-sub";
const STANDING_CREDIT: u32 = 100;
const START_PROP: &str = "x-opt-kubemq-start";

fn amqp_url() -> String {
    std::env::var("KUBEMQ_AMQP_URL").unwrap_or_else(|_| "amqp://localhost:5672".to_string())
}

fn body_string(body: &Body<Value>) -> String {
    let bytes = match body {
        Body::Data(batch) => batch.iter().flat_map(|d| d.0.to_vec()).collect(),
        Body::Value(v) => match &v.0 {
            Value::Binary(b) => b.to_vec(),
            Value::String(s) => s.clone().into_bytes(),
            other => format!("{other:?}").into_bytes(),
        },
        _ => Vec::new(),
    };
    String::from_utf8_lossy(&bytes).into_owned()
}

fn durable_source(addr: &str) -> Source {
    Source::builder()
        .address(addr)
        .expiry_policy(TerminusExpiryPolicy::Never) // durable signal
        .build()
}

fn start_props(start: &str) -> OrderedMap<Symbol, Value> {
    let mut props = OrderedMap::new();
    props.insert(Symbol::from(START_PROP), Value::String(start.to_string()));
    props
}

#[allow(clippy::type_complexity)]
async fn attach_durable(
    url: &str,
    addr: &str,
) -> Result<(Receiver, SessionHandle<()>, ConnectionHandle<()>), Box<dyn std::error::Error>> {
    let mut connection = Connection::builder()
        .container_id(CONTAINER_ID) // half the durable identity
        .open(url)
        .await?;
    let mut session = Session::begin(&mut connection).await?;
    let receiver = Receiver::builder()
        .name(LINK_NAME) // the other half of the identity
        .source(durable_source(addr))
        .properties(start_props("new-only")) // start cursor
        .credit_mode(CreditMode::Auto(STANDING_CREDIT))
        .attach(&mut session)
        .await?;
    tokio::time::sleep(Duration::from_millis(750)).await; // let the pump go live
    Ok((receiver, session, connection))
}

async fn publish(sender: &mut Sender, lo: usize, hi: usize) -> Result<(), Box<dyn std::error::Error>> {
    for i in lo..hi {
        let outcome = sender.send(format!("es-{i:03}")).await?;
        if !outcome.is_accepted() {
            return Err(format!("publish es-{i:03}: unexpected outcome {outcome:?}").into());
        }
    }
    Ok(())
}

async fn drain(receiver: &mut Receiver, max: usize, window: Duration) -> Result<Vec<String>, Box<dyn std::error::Error>> {
    let mut out = Vec::with_capacity(max);
    let deadline = tokio::time::Instant::now() + window;
    while out.len() < max {
        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
        if remaining.is_zero() {
            break;
        }
        match tokio::time::timeout(remaining, receiver.recv::<Body<Value>>()).await {
            Ok(Ok(delivery)) => {
                receiver.accept(&delivery).await?;
                out.push(body_string(&delivery.message().body));
            }
            _ => break,
        }
    }
    Ok(out)
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let url = amqp_url();
    let addr = format!("events-store/{CHANNEL}"); // events-store/ → KubeMQ Events Store

    // Producer — a plain connection (no stable id needed) that publishes throughout.
    let mut prod_conn = Connection::open("amqp10-examples-durable-producer", url.as_str()).await?;
    let mut prod_sess = Session::begin(&mut prod_conn).await?;
    let mut sender = Sender::builder()
        .name("durable-replay-producer")
        .target(addr.as_str())
        .sender_settle_mode(SenderSettleMode::Unsettled)
        .attach(&mut prod_sess)
        .await?;

    // 1. DURABLE SUBSCRIBE (first attach).
    let (mut dur_rcv, mut dur_sess, mut dur_conn) = attach_durable(url.as_str(), addr.as_str()).await?;
    publish(&mut sender, 0, 3).await?;
    let first = drain(&mut dur_rcv, 3, Duration::from_secs(30)).await?;
    println!("first attach received {} events: {first:?}", first.len());

    // 2. DISCONNECT — the connector preserves the durable cursor.
    dur_rcv.close().await?;
    dur_sess.end().await?;
    dur_conn.close().await?;
    tokio::time::sleep(Duration::from_secs(1)).await;

    // 3. PUBLISH 5 more while away.
    publish(&mut sender, 3, 8).await?;

    // 4. RE-ATTACH with the SAME identity → resumes the subscription.
    let (mut dur_rcv2, mut dur_sess2, mut dur_conn2) = attach_durable(url.as_str(), addr.as_str()).await?;
    let resumed = drain(&mut dur_rcv2, 5, Duration::from_secs(30)).await?;
    let resumed_set: HashSet<String> = resumed.iter().cloned().collect();
    println!("re-attach resumed and received the {} events published while away: {resumed:?}", resumed_set.len());

    dur_rcv2.close().await?;
    dur_sess2.end().await?;
    dur_conn2.close().await?;
    sender.close().await?;
    prod_sess.end().await?;
    prod_conn.close().await?;
    Ok(())
}

Durable subscriptions are node-local. A durable identity may have at most one live attach per node; a second live attach of the same identity returns DETACH(amqp:not-allowed, "durable subscription in use"). In a cluster the durable cursor lives on the node that owned the original attach, so a durable subscriber must reconnect to the same node to resume — use load-balancer session affinity or a sticky connection. (RPC replies travel the broker reply path and are cluster-safe; durable subscriptions are not.)

Was this page helpful?

On this page