Cursus defines one Wire v2 contract. The in-repository Go SDK is changed and tested atomically with the broker. The separately released Java and Python SDKs also implement the required handshake, correlated frames, compression, structured errors, routing, administration, transactions, event sourcing, and framework helpers. This repository does not provide a legacy protocol mode; every SDK release must pass the shared byte-exact conformance fixture and live-broker tests.
flowchart LR
subgraph "Client SDKs"
GO[Go SDK<br/>sdk/]
JAVA[Java SDK<br/>cursus-java]
PY[Python SDK<br/>cursus-python]
end
subgraph "Cursus Broker"
B1[Broker 1]
B2[Broker 2]
B3[Broker 3]
end
GO --> B1
JAVA --> B2
PY --> B3
B1 <--> B2
B2 <--> B3
B1 <--> B3
Every supported SDK must implement the same wire protocol:
flowchart TB
subgraph "Wire Protocol"
FRAME["Wire v2: 32-byte CRS2 header + payload"]
BATCH["Batch: CBV2 schema + records"]
CMD["Command: header ID + CRQ2 fields"]
end
subgraph "Shared"
ENCODE[Canonical record encoder]
DECODE[Canonical batch decoder]
COMPRESS[gzip / snappy / lz4]
end
GO[Go SDK] --> FRAME
JAVA[Java SDK] --> FRAME
PY[Python SDK] --> FRAME
FRAME --> BATCH
FRAME --> CMD
BATCH --> ENCODE
BATCH --> DECODE
FRAME --> COMPRESS
| Feature | Go SDK | Java SDK | Python SDK |
|---|---|---|---|
| Producer (sync) | ✅ | ✅ | ✅ |
| Producer (async) | — | — | ✅ AsyncProducer |
| Consumer (polling) | ✅ | ✅ | ✅ |
| Consumer (streaming) | ✅ | ✅ | ✅ |
| Consumer Groups | ✅ | ✅ | ✅ |
| EventStore | ✅ | ✅ | ✅ |
| Compression (gzip) | ✅ | ✅ | ✅ |
| Compression (snappy) | ✅ | ✅ | ✅ (extras) |
| Compression (lz4) | ✅ | ✅ | ✅ (extras) |
| TLS | ✅ | ✅ | ✅ |
| FindCoordinator | ✅ | ✅ | ✅ |
| Partition Leader Routing | ✅ | ✅ | ✅ |
Broker-owned offset resume / auto_offset_reset |
✅ | ✅ | ✅ |
Explicit read_committed / read_uncommitted |
✅ | ✅ | ✅ |
| Required Wire v2 handshake | ✅ | ✅ | ✅ |
| Typed structured broker errors | ✅ | ✅ | ✅ |
| High-level broker transactions | ✅ | ✅ | ✅ |
| Admin client | ✅ | ✅ | ✅ |
| Event framework and saga helpers | ✅ | ✅ | ✅ |
| Client metrics | ✅ | ✅ | ✅ |
| Connection authentication | ✅ | ✅ | ✅ |
| Framework Integration | — | Spring Boot | FastAPI |
| Iterator Pattern | — | — | ✅ for/async for |
Producer.Flush() error waits for queued batches and returns a drain timeout or the first permanent delivery failure. Producer.Close() error also reports that failure, including when shutdown times out. Check both return values: accepting a message into the local buffer is not proof of broker delivery. Permanent delivery errors remain visible for the lifetime of the producer.
For acknowledged batches, success requires an OK acknowledgement matching the batch’s producer ID, epoch, and complete sequence range. Read timeouts, incomplete responses, and invalid acknowledgements discard the partition connection before retrying the unchanged batch. Acks="0" does not wait for broker acknowledgement and cannot establish delivery.
sequenceDiagram
participant SDK as Any SDK
participant ANY as Any Broker
participant COORD as Coordinator
participant LEADER as Partition Leader
SDK->>ANY: FIND_COORDINATOR group=G
ANY-->>SDK: OK host=H port=P
SDK->>COORD: JOIN_GROUP topic=T group=G member=M
COORD-->>SDK: OK member=M-1234 generation=N assignments=[0,1,2]
SDK->>COORD: SYNC_GROUP topic=T group=G member=M-1234 generation=N
COORD-->>SDK: OK member=M-1234 generation=N assignments=[0,1,2]
SDK->>ANY: METADATA topic=T
ANY-->>SDK: OK leaders=L0,L1,L2
loop Per Partition
SDK->>LEADER: CONSUME topic=T partition=P offset=O
LEADER-->>SDK: batch(messages)
end
loop Heartbeat
SDK->>COORD: HEARTBEAT topic=T group=G member=M-1234 generation=N
COORD-->>SDK: OK member=M-1234 generation=N
end
Every Go SDK connection performs the required Wire v2 binary handshake before authentication or application requests. Compression is negotiated during this handshake and the temporary deadline is cleared before the request lifecycle begins:
cfg := sdk.NewDefaultConsumerConfig()
cfg.CompressionType = "lz4"
cfg.HandshakeTimeoutMS = 5000
The handshake runs for every newly opened or reconnected TCP connection. HandshakeTimeoutMS bounds it; zero uses 5000 ms and negative values fail configuration validation. A version or compression mismatch closes the connection before use. The SDK has no application-level feature negotiation or legacy protocol mode.
Go, Java, and Python encode the same canonical conformance vectors for negotiation, requests, batches, stream controls, errors, and compression. The fixture protects byte-level compatibility; it does not replace each SDK’s required live-broker producer, consumer, administration, transaction, and EventStore tests.
Broker failures returned by application requests are available as *sdk.BrokerError:
var brokerErr *sdk.BrokerError
if errors.As(err, &brokerErr) {
if brokerErr.Retryable {
// Apply bounded backoff or redirect handling before retrying.
}
}
BrokerError exposes Code, Class, Retryable, Fields, and the raw response. Wire v2 requires explicit class and retryable fields; unstructured error text is rejected. Typed errors match ErrTopicNotFound, ErrInvalidPartition, and ErrNotLeader through errors.Is.
Producer.CreateTopic is the minimal convenience API. Disabled idempotence and unspecified policy values are omitted so repeated provisioning does not clear an existing definition. Use CreateTopicWithOptions for an explicit non-zero producer provisioning contract:
err := producer.CreateTopicWithOptions("player-state", sdk.TopicOptions{
Partitions: 12,
CleanupPolicy: sdk.TopicCleanupCompact,
RetentionHours: 168,
Partitioner: "hash_key",
})
For administrative create/update flows, use AdminClient. TopicDefinitionPatch uses pointers so callers can intentionally send zero, false, or an empty ACL while leaving nil fields unchanged:
cfg := sdk.NewDefaultAdminConfig()
cfg.BrokerAddrs = []string{"broker-1:9000", "broker-2:9000", "broker-3:9000"}
admin, err := sdk.NewAdminClient(cfg)
if err != nil {
return err
}
retentionHours := 0
emptyReaders := []string{}
definition, err := admin.UpdateTopic("player-state", sdk.TopicDefinitionPatch{
RetentionHours: &retentionHours,
ReadACL: &emptyReaders,
})
deleted, err := admin.DeleteTopic("retired-player-state", sdk.DeleteTopicOptions{
IfExists: true,
})
truncated, err := admin.TruncateTopic("test-player-state", sdk.TruncateTopicOptions{
ExpectedRevision: definition.Revision,
})
AdminClient validates values locally, returns the broker’s complete TopicDefinition for create/update, a DeleteTopicResult for delete, and a TruncateTopicResult for reset. Attempts are bounded by MaxRetries + 1 and rotate across configured brokers for retryable routing/availability and pre-submission transport failures. The SDK canonicalizes compact,delete to delete,compact, enforces the portable topic-name contract, and rejects unsafe command values, zero expected revisions, negative retention, or unknown policy enums before opening a broker connection. Compact policies require a non-event-sourcing application topic; the broker enforces the additional distributed safety gates. EventStore.CreateTopic explicitly declares cleanup_policy=delete.
DeleteTopic and TruncateTopic exist only on the admin client; producer and consumer clients do not expose destructive lifecycle methods. IfExists makes an explicit deletion retry idempotent; without it, a missing topic returns topic_not_found. The admin client retries a dropped connection after command submission only for IfExists=true; non-idempotent delete and truncate stop with an unknown-outcome error. A truncate replay cannot erase a second generation because ExpectedRevision is mandatory, but a committed first attempt would return a conflict rather than the original result, so the SDK does not hide that ambiguity. CleanupPending means the logical lifecycle committed while broker-local storage cleanup is still converging. Kubernetes reconcilers should call delete only for an explicit absent/tombstone resource that crossed a separate approval boundary, and should never emulate truncation with delete-and-create.
The Go SDK exposes both the existing low-level transaction commands and a
session-preserving TransactionalProducer:
cfg := sdk.NewDefaultConsumerConfig()
cfg.BrokerAddrs = []string{"broker-1:9000", "broker-2:9000"}
client, err := sdk.NewConsumerClient(cfg)
if err != nil {
return err
}
producer, err := client.NewTransactionalProducer("game-server-events")
if err != nil {
return err
}
if err := producer.Begin(); err != nil {
return err
}
if err := producer.Publish("events", -1, sdk.Message{
SeqNum: 1,
Key: "match-42",
Payload: payload,
}); err != nil {
_ = producer.Abort()
return err
}
if err := producer.SendOffsets(
"events",
groupID,
memberID,
generation,
map[int]uint64{partition: lastProcessedOffset + 1},
); err != nil {
_ = producer.Abort()
return err
}
return producer.Commit()
The broker allocates and fences the producer ID and epoch. The high-level
producer retains that session across reconnects and serializes lifecycle calls.
A successful Commit or Abort consumes the current epoch; the next Begin
automatically reinitializes the session and obtains a higher epoch. Retrying an
uncertain finalization continues to use the same epoch, preserving idempotency. Once the broker reports state=prepare_commit, retry Commit; do not switch that epoch to abort.
For multi-topic input, register with RegisterGroupSubscription, join with JoinGroupSubscription, and stage all offsets with SendTopicOffsets. Returned sdk.Message values include Topic and Partition so handlers can preserve source identity. Exactly-once processing covers Cursus offsets and Cursus output records; external effects are not included.
NOT_COORDINATOR responses update a per-transaction coordinator cache and are
retried with a bounded delay. Fencing, authorization, validation, and ambiguous
network failures are returned to the caller as errors rather than retried. The
broker is authoritative for final transaction state; Describe returns the
typed state, message count, and offset count.
One transaction may publish to multiple output partitions, but staged consumer
offsets must share one (topic, group, member, generation) scope. Duplicate
partition entries merge monotonically and the scope commits through one fenced
bulk offset update. Use separate transactions for separate consumer scopes.
Commit writes idempotent output records, appends partition transaction markers,
applies the bulk source offset update, then persists the final coordinator
decision. read_committed requires both marker and decision, so output stays
hidden during a recoverable crash window before finalization.
The Go consumer fetches the broker-owned committed nextOffset after
assignment and rejoin. Applications that commit after processing should commit
lastProcessedOffset + 1. Reconnects roll back the local fetch position to the
last broker-committed offset, providing at-least-once processing.
AutoOffsetResetEarliest and AutoOffsetResetLatest are sent as
autoOffsetReset=earliest|latest in polling and streaming commands.
AutoOffsetResetError leaves reset selection disabled and surfaces an
out-of-range condition.
After each metadata refresh the consumer records whether the authoritative
cleanup_policy includes compact. Forward jumps on such a topic are valid
logical holes and increment
cursus_consumer_compacted_offsets_skipped_total{topic,group}; they do not
increment cursus_consumer_offset_gap_total. The same jump on a non-compacted
topic remains an unexpected offset gap. Missing or invalid cleanup policy
metadata clears the compaction classification instead of weakening gap
detection.
ConsumerConfig.ReadIsolation selects sdk.ReadCommitted or
sdk.ReadUncommitted; the default is ReadCommitted. The SDK sends the value
explicitly on both CONSUME and STREAM. Committed reads expose
non-transactional records and transaction records only after both the matching
partition marker and final coordinator decision are durable. They skip aborted
transactions and stop at the earliest unresolved transaction. Uncommitted reads
return the raw committed partition log, including transaction control records.
Publisher and consumer configs accept connection credentials:
cfg.Principal = "game-server"
cfg.AuthToken = os.Getenv("CURSUS_AUTH_TOKEN")
When both fields are set, every new or reconnected SDK connection performs
AUTH principal=<principal> token=<token> after protocol negotiation and
before application commands. The two fields must be configured together.
Authentication and authorization failures are returned as typed
*sdk.BrokerError values. TLS remains required when credentials cross an
untrusted network.