Go SDK
Module github.com/odurgut/clusdr/sdk, package clusdr. Applications call the daemon on this host. Shared model: SDKs.
go get github.com/odurgut/clusdr/sdkSame version train as the daemon (first release: v0.1.0). Wire types live in github.com/odurgut/clusdr/api.
A running daemon is required (guide: first member).
Connect
import (
"context"
"time"
"github.com/odurgut/clusdr/sdk"
)
c, err := clusdr.Local()
if err != nil {
// daemon down, TLS, or Health not ready within 10s
}
defer c.Close()Local dials CLUSDR_GRPC_ADDR or 127.0.0.1:7947, then waits on the Health RPC (10s). That wait is not configurable from a public option.
c, err := clusdr.Dial("127.0.0.1:8947", clusdr.WithDataDir("./data-b"))Dial is for tests and operators. Apps use Local.
| Option | Meaning |
|---|---|
WithInsecure() | Plaintext. Same as CLUSDR_TLS=disabled |
WithDataDir(dir) | Directory with ca.crt / node.crt / node.key |
WithHolder(id) | Lock/lease identity. Empty → sdk-<hex> for this connection |
WithRequestTimeout(d) | Used when the caller context has no deadline (default 10s) |
If the caller context already has a deadline, that deadline wins.
Cluster
Members(ctx) ([]Member, error)
Leader(ctx) (Member, error)
Watch(ctx) (<-chan Event, error)
Publish(ctx, topic string, payload []byte) error
Lock(ctx, name string, ttl time.Duration) (*Lock, error)
TryLock(ctx, name string, ttl time.Duration) (lk *Lock, ok bool, err error)
Unlock(ctx, name string) error
Lease(ctx, name string, ttl time.Duration) (*Lease, error)
Renew(ctx, name string) error
Revoke(ctx, name string) error
Close() errorEvery RPC takes context.Context. Unary calls retry Unavailable, Aborted, and ResourceExhausted with backoff 50ms → 2s until the context deadline.
ttl <= 0 sends ttl_ms = 0; the daemon uses its default (15s).
Close unlocks locks and revokes leases this connection still holds, then closes the gRPC conn.
Membership
members, err := c.Members(ctx)
leader, err := c.Leader(ctx)Member:
| Field | Meaning |
|---|---|
ID | Stable node id |
Address | Advertised Runtime API |
Status | alive, leaving, or dead |
Leader | True if this id is the current Raft leader |
Role | voter or observer. Empty from the wire becomes voter |
Leader() builds a member from GetLeader (Status is alive, Role is voter). No leader → gRPC Unavailable, wrapped as clusdr: leader: ….
Watch
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch, err := c.Watch(ctx)
for ev := range ch {
switch ev.Type {
case "member.join", "member.left", "leader.changed":
// cluster
case "custom.deployment":
// payload is []byte
}
}Watch returns immediately. A goroutine reads the stream. Cancel ctx to stop; the channel is then closed.
The request is last_seq only. There is no topic or type filter in this SDK. You see the full bus (snapshot, watch.sync, live events). CLI --topic is a different client.
On disconnect the loop reconnects with the last seq it saw, backoff 50ms → 2s. Server may send the snapshot again and watch.gap. Custom events are not replayed.
Channel buffer is 64. A slow receiver blocks the read loop (it does not drop on the client). The daemon bus still drops slow subscribers.
Event: Type, Source, Payload ([]byte), Timestamp, Seq.
Publish
err := c.Publish(ctx, "deployment", []byte(`{"sha":"abc"}`))Payload is raw bytes. Max 64 KiB (daemon). Topic: 1–128 characters, A–Z a–z 0–9 . _ -.
Not on the Raft log. Accepted=false → clusdr: publish rejected: …. Duplicate event_id is accepted with message = duplicate at the wire; this helper only fails when accepted is false.
Locks
A lock is an exclusive name on the Raft log. Store lk.Token with any write that must be fenced. A stale holder cannot unlock a newer grant.
lk, err := c.Lock(ctx, "scheduler", 15*time.Second)
if err != nil { /* deadline, observer, name, table full */ }
defer c.Unlock(ctx, "scheduler")
// later
_ = lk.Name
_ = lk.Holder
_ = lk.Token
_ = lk.Deadline()Lock blocks until acquired or ctx is done. Calling Lock again for a name this connection already holds returns the existing *Lock and does not re-RPC.
lk, ok, err := c.TryLock(ctx, "scheduler", 15*time.Second)
if err != nil { /* transport */ }
if !ok {
// someone else holds it — not an error
// lk may still describe the current holder
}Unlock of a name this client does not hold → clusdr: lock "…" is not held by this client.
Background renew starts after acquire, interval about TTL/3 (minimum 50ms). FailedPrecondition or Canceled on renew stops the loop. Close and Unlock stop renew and release.
Observer daemon: Lock / TryLock / Unlock / Renew → FailedPrecondition (observer cannot mutate locks). List is a CLI/RPC concern; this SDK has no ListLocks.
Name rules and table size: locks, limits.
Leases
A lease is a named TTL grant. Grant never blocks. Many names can be held at once.
life, stop := context.WithCancel(ctx)
lease, err := c.Lease(life, "worker-1", 15*time.Second)
if err != nil { /* already owned, name, table */ }
// cancel life → renew stops; grant expires at Deadline. Not a revoke.
stop()
err = c.Renew(ctx, "worker-1")
err = c.Revoke(ctx, "worker-1")Lease’s first argument is both the grant RPC context (until the RPC returns) and the lifetime of background renew. Cancelling it after a successful grant stops renew only.
Close revokes remaining leases. That is not the same as cancelling the lease context.
Renew / Revoke of a name this client does not hold → error.
Observers can grant leases. presence.<nodeID> is the daemon’s own liveness lease — not an application grant.
Lease: Name, Owner, Token, Deadline().
TLS
On unless CLUSDR_TLS=disabled or WithInsecure().
Lookup order for PEMs: WithDataDir, else CLUSDR_DATA_DIR, else ~/.clusdr.
If that directory has no usable certs, the client falls back to bootstrap TLS (TLS 1.2+, skip hostname, no client cert). That is enough to reach a daemon that still accepts join-style TLS; a cluster that requires a node cert will reject you.
When PEMs load: client cert + cluster CA. Peer identity is the CA, not the dial hostname (InsecureSkipVerify + VerifyPeerCertificate against the CA).
Errors
Returned errors are wrapped (clusdr: members: …, clusdr: lock "name": …). Unwrap to the gRPC status when you need the code.
| Situation | What you see |
|---|---|
| Daemon down / Health timeout | clusdr: daemon not ready at … or clusdr: dial … |
Empty Dial("") | clusdr: empty dial address |
| Transient RPC | retried until deadline |
| No leader | Unavailable on Leader |
| Observer + lock | FailedPrecondition |
| Unlock / revoke name you do not hold | SDK error, no RPC |
Not in this package
- Watch topic / type filters
ListLocks/ListLeases- Join, promote, config
- A public
WithReadyTimeout
Wire shapes: gRPC API.