Client SDK

A single Go package for everything DSend does: publish, consume, acknowledge, manage queues, and read broker metrics.

Overview

The SDK lives in github.com/Ali-Hasan-Khan/dsend/client. It exposes two clients that mirror the broker's two roles:

  • Producer — publishes messages and manages queues.
  • Consumer — subscribes to a queue, receives deliveries, and acknowledges them.

Each client holds a single persistent TCP connection to the broker and speaks the JSON wire protocol directly. No HTTP, no auto-reconnect, no magic — just synchronous calls and explicit context.Context handling.

Addresses. The broker listens on 127.0.0.1:8080 by default. Pass that as the addr argument to NewProducer and NewConsumer. The CLI hard-codes the same address.

Quickstart

1. Start the broker

Build and run the server. It binds 127.0.0.1:8080 and appends to ./data/wal.log.

terminal
$ go build -o dsend ./cmd/dsend
$ ./dsend server

2. Publish

main.go
package main

import (
	"context"
	"fmt"

	"github.com/Ali-Hasan-Khan/dsend/client"
)

func main() {
	p, err := client.NewProducer("localhost:8080")
	if err != nil {
		panic(err)
	}
	defer p.Close()

	if err := p.Publish(context.Background(), "orders", "order-1042 created"); err != nil {
		panic(err)
	}
	fmt.Println("published")
}

3. Consume and acknowledge

main.go
package main

import (
	"context"
	"fmt"

	"github.com/Ali-Hasan-Khan/dsend/client"
)

func main() {
	c, err := client.NewConsumer("localhost:8080")
	if err != nil {
		panic(err)
	}
	defer c.Close()

	if err := c.Subscribe("orders"); err != nil {
		panic(err)
	}

	for {
		msg, err := c.Receive(context.Background())
		if err != nil {
			panic(err)
		}

		// do real work here...
		fmt.Println("received:", msg.Payload)

		if err := c.Ack(msg.AckToken); err != nil {
			panic(err)
		}
	}
}
Don't skip the ack. DSend guarantees at-least-once delivery. Until you call Ack(msg.AckToken), the message stays in the broker's in-flight registry and will be redelivered if you take too long. See Delivery semantics.

Producer

A producer dials the broker once and keeps the connection open. All methods take a context.Context so you can time out or cancel cleanly.

Construct

NewProducer(addr string) (*Producer, error)

Opens a persistent TCP connection to the broker at addr.

Methods

MethodDescription
Publish(ctx, queueName, payload) Appends a message to the named queue. Publishes to the default queue when queueName is empty.
CreateQueue(ctx, name) Creates a named queue.
DeleteQueue(ctx, name) Deletes a named queue.
ListQueues(ctx) ([]string, error) Returns the names of all queues on the broker.
Metrics(ctx) (*BrokerMetrics, error) Returns broker-wide metrics plus a breakdown per queue.
QueueMetrics(ctx, queueName) (*QueueMetric, error) Returns metrics for a single queue.
Close() error Closes the underlying connection. Always defer this.

Publishing

Payloads are strings. Keep the payload small and treat it as opaque — the broker never inspects the contents. A message is written to the WAL before it is queued, so a crash between the two steps doesn't lose it.

publish.go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// Named queue. The queue is created implicitly on first publish.
err := p.Publish(ctx, "emails", `{"to":"ada@example.com","subject":"hi"}`)

// Default queue — no name required.
err = p.Publish(ctx, "", "hello, world")
Blocking on full queues. Each queue is a fixed-size ring buffer (capacity 20 by default). When the queue is full, Publish blocks until a consumer frees space or the context is cancelled.

Queue administration

admin.go
if err := p.CreateQueue(ctx, "orders"); err != nil {
	panic(err)
}

names, err := p.ListQueues(ctx)
if err != nil {
	panic(err)
}
fmt.Println(names) // ["default", "orders"]

if err := p.DeleteQueue(ctx, "orders"); err != nil {
	panic(err)
}

Consumer

A consumer is push-based: subscribe once, then loop on Receive. The broker schedules deliveries round-robin across all consumers subscribed to a queue.

Construct & subscribe

NewConsumer(addr string) (*Consumer, error)
Subscribe(queueName string) error

Subscribe registers this connection as a consumer session for the queue. One consumer connection can subscribe to a single queue at a time.

Receiving

Receive(ctx context.Context) (*ReceivedMessage, error)

Blocks until the broker pushes the next delivery. A ReceivedMessage carries:

FieldTypeMeaning
IDstringUnique message identifier.
PayloadstringOpaque message body.
AckTokenstringProof of delivery; pass it to Ack.
Receive timeouts. Receive sets a 10-second read deadline on the socket and returns an error if no delivery arrives in time. In a real consumer, retry Receive in a loop instead of exiting.

Acknowledging

Ack(token string) error

Tell the broker the message was processed successfully. The broker removes the ack token from its in-flight registry and never redelivers that message.

consumer.go
for {
	msg, err := c.Receive(ctx)
	if err != nil {
		// transient read deadline or network error; try again
		if ctx.Err() != nil {
			return // caller cancelled
		}
		continue
	}

	if err := handle(msg.Payload); err != nil {
		// skip ack: the broker will redeliver later
		continue
	}

	if err := c.Ack(msg.AckToken); err != nil {
		return
	}
}

Unsubscribing

Unsubscribe() error

Removes this session from the queue's round-robin rotation and stops deliveries. Closing the connection also unregisters the session.

Managing queues

A queue is an isolated, fixed-capacity in-memory ring buffer with its own consumers, in-flight messages, DLQ, and metrics. The default queue exists implicitly; publishing to any other name creates the queue on first use.

  • Implicit creation — publishing to orders creates it if it doesn't exist.
  • Explicit creationCreateQueue if you want to manage it up front.
  • Capacity — 20 messages per queue by default (see configuration).
  • DeletionDeleteQueue removes the queue from the broker.
cli
$ ./dsend queue create orders
Queue created successfully

$ ./dsend queue list
Queues: [default orders]

$ ./dsend queue delete orders
Queue deleted successfully

Metrics

Every counter maps to a concrete delivery event. Query broker-wide metrics or scope them to one queue. The CLI exposes the same data: ./dsend metrics or ./dsend metrics --queue orders.

Types

internal/model/metrics.go
type Metric struct {
	ProducedCount        int
	AckedCount           int
	DlqCount             int
	InflightCount        int
	RedeliveredCount     int
	ConsumerSessionCount int
	QueueDepth           int
}

type QueueMetric struct {
	Name string
	Metric
}

type BrokerMetrics struct {
	Queues []QueueMetric
	Total  Metric
}

Fields

FieldMeaning
ProducedCountMessages published to the queue.
AckedCountMessages acknowledged by consumers.
QueueDepthMessages currently waiting in the ring buffer.
InflightCountMessages delivered but not yet acknowledged.
RedeliveredCountMessages requeued after an ack timeout.
DlqCountMessages dead-lettered after exhausting retries.
ConsumerSessionCountConnected consumers subscribed to the queue.
metrics.go
m, err := p.Metrics(ctx)
if err != nil {
	panic(err)
}

for _, q := range m.Queues {
	fmt.Printf("%s: depth=%d in-flight=%d acked=%d\n",
		q.Name, q.QueueDepth, q.InflightCount, q.AckedCount)
}

q, err := p.QueueMetrics(ctx, "orders")
if err != nil {
	panic(err)
}
fmt.Println(q.DlqCount)

CLI reference

The dsend binary wraps the SDK for scripting and quick tests.

CommandDescription
dsend server Starts the broker on 127.0.0.1:8080 with the WAL at ./data/wal.log.
dsend publish [--queue <name>] "<message>" Publishes a message. Defaults to the default queue.
dsend subscribe [--queue <name>] Subscribes, receives messages, and acks each one. Ctrl-C to stop.
dsend metrics [--queue <name>] Prints broker metrics. Scopes to one queue with --queue.
dsend queue create <name> Creates a named queue.
dsend queue delete <name> Deletes a named queue.
dsend queue list Lists all queue names.
The CLI targets localhost:8080 and the broker must be running first. There's no persistence across dsend server restarts beyond what the WAL recovers.

Wire protocol

Clients and broker exchange length-delimited JSON over TCP. Each message is a request with a type field; the broker replies with a response carrying success and, on failure, error.

Request types

TypeSent byPurpose
publishProducerAppend a message to a queue.
subscribeConsumerRegister a consumer session for a queue.
ackConsumerAcknowledge a delivery by token.
unsubscribeConsumerLeave the queue's round-robin rotation.
metricsProducerFetch broker-wide or per-queue metrics.
create_queueProducerCreate a named queue.
delete_queueProducerDelete a named queue.
list_queuesProducerList all queue names.

Example

client → broker
{"type":"publish","queue":"orders","message":{"payload":"order-1042 created"}}
broker → client
{"success":true,"error":""}
broker → consumer (delivery)
{
  "success": true,
  "message": {
    "id": "a1b2c3d4-...",
    "payload": "order-1042 created",
    "timestamp": "2026-07-31T12:00:00Z",
    "retry": 0
  },
  "ack_token": "9f8e7d6c-..."
}

Delivery semantics

Push with round-robin

The broker pushes deliveries to subscribed consumers in round-robin order. Add more consumers to a queue and load is spread across them automatically. Each consumer receives one message at a time.

At-least-once

When a message is delivered, the broker moves it from the ring buffer into an in-flight registry keyed by ack token. It stays there until one of three things happens:

  1. Ack — the consumer processed it; it's removed permanently.
  2. Ack timeout — if the consumer doesn't ack within AckTimeout, the message is requeued and redelivered.
  3. Retry limit — after MaxRetries redeliveries, the message is moved to the queue's DLQ.
Ordering. A message can be delivered more than once (at-least-once, not exactly-once). Make your consumers idempotent when duplicate processing matters.

Default configuration

SettingDefaultEffect
AckTimeout100 sHow long a delivery may sit unacked before redelivery.
RedeliveryInterval5 sHow often expired in-flight messages are swept.
MaxRetries3Redeliveries before a message is dead-lettered.
QueueSize20Capacity of each queue's ring buffer.

Configuration is set in engine.DefaultConfig() at broker startup (cmd/dsend/server.go).

Dead-letter queue

Each queue has its own DLQ. Messages that exhaust their retries land there instead of being dropped. The DlqCount metric tracks how many have.

Persistence & recovery

The broker appends every state change — publish, ack, requeue, dead-letter — to a write-ahead log. On restart it replays the WAL and rebuilds queues, in-flight state, and session ownership.