From 286a01aa2172be0d9c3ae3f3b8c7f60675121a73 Mon Sep 17 00:00:00 2001 From: radmehr soleimanian Date: Fri, 31 Jul 2026 00:22:24 +0330 Subject: [PATCH 1/2] add new sdk --- go/futureq/client.go | 283 +++++++++++++++++------- go/futureq/consumer.go | 330 ++++++++++++++++------------ go/futureq/doc.go | 84 +++---- go/futureq/errors.go | 41 ++-- go/futureq/example_test.go | 123 ++++------- go/futureq/futureq_test.go | 238 +++++++------------- go/futureq/message.go | 110 ++++++++-- go/futureq/observer.go | 262 ++++++++++++++++++++++ go/futureq/producer.go | 440 ++++++++++++++++++------------------- go/futureq/retry.go | 81 ++++--- go/futureq/topology.go | 240 ++++++++++++++++++++ go/go.mod | 56 ++++- go/go.sum | 436 +++++++++++++++++++++++++++++++++++- 13 files changed, 1902 insertions(+), 822 deletions(-) create mode 100644 go/futureq/observer.go create mode 100644 go/futureq/topology.go diff --git a/go/futureq/client.go b/go/futureq/client.go index b459428..346ee31 100644 --- a/go/futureq/client.go +++ b/go/futureq/client.go @@ -1,35 +1,48 @@ package futureq import ( + "context" "crypto/tls" "fmt" + "sync" "time" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/keepalive" + + pb "github.com/futureq-io/protocol/proto/go" ) // Client is the top-level entry point for the FutureQ SDK. -// It owns and manages the underlying gRPC [grpc.ClientConn] and exposes -// factory methods for creating [Producer] and [Consumer] instances. // -// A Client is safe for concurrent use by multiple goroutines. -// Typically an application creates one Client at startup and reuses it -// throughout its lifetime. +// A Client tracks the FutureQ cluster topology and routes producer and +// consumer streams to the current Raft leader. The topology can be +// discovered two ways: +// +// 1. Polling — the SDK periodically calls GetClusterInfo on one of the +// seed addresses you passed to [New]. This is the default. +// 2. Dragonboat discovery — when [WithDragonboatDiscovery] is enabled the +// SDK embeds a non-voting Dragonboat replica of the metadata Raft group +// in-process and receives topology changes over Raft in real time. +// This mode is experimental; it never writes to disk (an in-memory VFS +// backs the embedded replica). // -// Call [Close] when the Client is no longer needed to release the underlying -// network connection. +// A Client is safe for concurrent use by multiple goroutines. type Client struct { - conn *grpc.ClientConn - opts clientOptions - managed bool // true when the SDK owns the conn (i.e. created via New) - closed bool + opts clientOptions + + mu sync.RWMutex + tracker *topologyTracker + observer *metadataObserver // nil unless WithDragonboatDiscovery was set + closed bool } -// clientOptions holds the resolved configuration used when dialling the server. +// clientOptions holds the resolved configuration used when dialling the +// cluster. type clientOptions struct { + seeds []string dialTimeout time.Duration keepAliveTime time.Duration keepAliveTimeout time.Duration @@ -38,9 +51,16 @@ type clientOptions struct { tlsConfig *tls.Config insecure bool additionalDialOpts []grpc.DialOption + + // refreshInterval is how often the SDK polls GetClusterInfo on the + // seeds to keep its view of the leader fresh. Defaults to 10 s. + // Ignored when dragonboat discovery is on (the observer is push-based). + refreshInterval time.Duration + + // dragonboat is non-nil when dragonboat discovery is enabled. + dragonboat *metadataObserverConfig } -// defaultClientOptions returns a sensible production-ready baseline. func defaultClientOptions() clientOptions { return clientOptions{ dialTimeout: 10 * time.Second, @@ -48,6 +68,7 @@ func defaultClientOptions() clientOptions { keepAliveTimeout: 10 * time.Second, maxRecvMsgSizeMB: 16, maxSendMsgSizeMB: 16, + refreshInterval: 10 * time.Second, } } @@ -55,10 +76,9 @@ func defaultClientOptions() clientOptions { type Option func(*clientOptions) // WithInsecure disables transport security for the connection. -// Use this only in development or when the connection is protected by an -// external proxy (e.g. mutual TLS at the service-mesh layer). +// Use only in development or when TLS terminates at an external proxy. // -// This option is mutually exclusive with [WithTLS]. +// Mutually exclusive with [WithTLS]. func WithInsecure() Option { return func(o *clientOptions) { o.insecure = true @@ -67,10 +87,9 @@ func WithInsecure() Option { } // WithTLS configures the client to use TLS with the provided [tls.Config]. -// Pass nil to use the system default TLS configuration (recommended for -// production when connecting to a server with a publicly-signed certificate). +// Pass nil to use the system default TLS configuration. // -// This option is mutually exclusive with [WithInsecure]. +// Mutually exclusive with [WithInsecure]. func WithTLS(cfg *tls.Config) Option { return func(o *clientOptions) { o.insecure = false @@ -79,137 +98,216 @@ func WithTLS(cfg *tls.Config) Option { } // WithDialTimeout sets the maximum duration to wait when establishing the -// initial gRPC connection. Defaults to 10 seconds. +// initial gRPC connection. Defaults to 10 s. func WithDialTimeout(d time.Duration) Option { - return func(o *clientOptions) { - o.dialTimeout = d - } + return func(o *clientOptions) { o.dialTimeout = d } } // WithKeepAlive configures the client-side HTTP/2 keep-alive probes. -// - time — how long the client waits after the last activity before -// sending a PING frame. Defaults to 30 s. -// - timeout — how long the client waits for a PING ACK before considering -// the connection dead. Defaults to 10 s. -func WithKeepAlive(time, timeout time.Duration) Option { +func WithKeepAlive(time_, timeout time.Duration) Option { return func(o *clientOptions) { - o.keepAliveTime = time + o.keepAliveTime = time_ o.keepAliveTimeout = timeout } } -// WithMaxRecvMsgSize sets the maximum message size in megabytes that the -// client can receive from the server. Defaults to 16 MB. +// WithMaxRecvMsgSize sets the maximum message size in megabytes the client +// can receive from the server. Defaults to 16 MB. func WithMaxRecvMsgSize(mb int) Option { - return func(o *clientOptions) { - o.maxRecvMsgSizeMB = mb - } + return func(o *clientOptions) { o.maxRecvMsgSizeMB = mb } } -// WithMaxSendMsgSize sets the maximum message size in megabytes that the -// client may send to the server. Defaults to 16 MB. +// WithMaxSendMsgSize sets the maximum message size in megabytes the client +// may send to the server. Defaults to 16 MB. func WithMaxSendMsgSize(mb int) Option { - return func(o *clientOptions) { - o.maxSendMsgSizeMB = mb - } + return func(o *clientOptions) { o.maxSendMsgSizeMB = mb } } // WithDialOptions appends arbitrary [grpc.DialOption]s to the dialler. // Use this escape hatch for features not covered by the typed option set -// (e.g. per-RPC credentials, custom interceptors, service-config JSON). +// (per-RPC credentials, custom interceptors, service-config JSON, etc.). func WithDialOptions(opts ...grpc.DialOption) Option { return func(o *clientOptions) { o.additionalDialOpts = append(o.additionalDialOpts, opts...) } } -// New dials the FutureQ server at the given address and returns a ready -// [Client]. The address must be in "host:port" format, e.g. -// "futureq.internal:8443". +// WithTopologyRefreshInterval overrides how often the SDK polls +// GetClusterInfo on the seed addresses to refresh its view of the cluster +// leader. Defaults to 10 s. Set to a negative value to disable polling. +// Ignored when [WithDragonboatDiscovery] is enabled. +func WithTopologyRefreshInterval(d time.Duration) Option { + return func(o *clientOptions) { o.refreshInterval = d } +} + +// WithDragonboatDiscovery enables the experimental in-process Dragonboat +// observer. The SDK starts a non-voting replica of the metadata Raft group +// inside the client process; topology changes are pushed to the SDK over +// Raft as soon as they commit, so producer / consumer streams follow the +// leader without any polling. +// +// All observer state lives in an in-memory VFS — nothing is written to +// disk. The observer is shut down when [Client.Close] is called. // -// By default, New uses TLS with the system certificate pool. Pass -// [WithInsecure] to disable TLS or [WithTLS] to provide a custom -// [tls.Config]. +// cfg may be nil; sensible defaults (random NodeID, 127.0.0.1:0 Raft +// address, shard 1) are used. See [metadataObserverConfig] for the +// available knobs. +func WithDragonboatDiscovery(cfg *metadataObserverConfig) Option { + return func(o *clientOptions) { + if cfg == nil { + cfg = &metadataObserverConfig{} + } + cp := *cfg + o.dragonboat = &cp + } +} + +// New creates a [Client] that discovers the FutureQ cluster through the +// supplied seed addresses and returns a ready client. At least one seed +// must be reachable; otherwise New returns the dial error. // -// New blocks until the connection is established or [WithDialTimeout] expires. -// An error is returned if the connection cannot be established. +// Seeds may point at any node in the cluster — leader or follower. The SDK +// uses GetClusterInfo on the seeds to learn the leader address, and then +// routes all producer / consumer streams to it. // // client, err := futureq.New( -// "futureq.internal:8443", -// futureq.WithTLS(nil), // system certs -// futureq.WithDialTimeout(5*time.Second), +// []string{"node1.internal:9000", "node2.internal:9000"}, +// futureq.WithInsecure(), // ) -func New(addr string, opts ...Option) (*Client, error) { +func New(seeds []string, opts ...Option) (*Client, error) { + if len(seeds) == 0 { + return nil, fmt.Errorf("futureq: at least one seed address is required") + } o := defaultClientOptions() for _, opt := range opts { opt(&o) } - dialOpts, err := buildDialOptions(o) - if err != nil { - return nil, fmt.Errorf("futureq: build dial options: %w", err) + c := &Client{opts: o} + + dial := func(ctx context.Context, addr string) (*grpc.ClientConn, error) { + dialOpts, err := buildDialOptions(o) + if err != nil { + return nil, err + } + conn, err := grpc.NewClient(addr, dialOpts...) + if err != nil { + return nil, err + } + return conn, nil } - conn, err := grpc.NewClient(addr, dialOpts...) - if err != nil { - return nil, fmt.Errorf("futureq: dial %s: %w", addr, err) + c.tracker = newTopologyTracker(seeds, o.refreshInterval, dial) + + // Initial discovery — must succeed before we return so the caller can + // assume the client is usable immediately. + ctx, cancel := context.WithTimeout(context.Background(), o.dialTimeout) + defer cancel() + if _, err := c.tracker.refreshOnce(ctx); err != nil { + return nil, fmt.Errorf("futureq: initial topology discovery: %w", err) } - return &Client{conn: conn, opts: o, managed: true}, nil + // Start the background refresh loop unless dragonboat discovery is on + // (in which case the observer is push-based and polling is redundant). + if o.dragonboat == nil { + c.tracker.start(context.Background()) + } else { + obsCfg := *o.dragonboat + obs, err := startMetadataObserver(context.Background(), obsCfg, c.tracker, + func(ctx context.Context) error { + return c.joinMetadata(ctx, obsCfg.NodeID, obsCfg.RaftAddress) + }, + ) + if err != nil { + return nil, err + } + c.observer = obs + } + + return c, nil } -// NewWithConn creates a [Client] from an existing [grpc.ClientConn]. -// The caller retains ownership of the connection; [Client.Close] will not -// close it. -// -// This is useful when you want to share a connection with other gRPC services -// or when you need fine-grained control over connection management (e.g. -// channel pools, custom balancers). -// -// conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) -// client := futureq.NewWithConn(conn) -func NewWithConn(conn *grpc.ClientConn) *Client { - return &Client{conn: conn, managed: false} +// joinMetadata calls JoinMetadata on every seed until one accepts us. It +// is invoked exactly once at startup when dragonboat discovery is on. +func (c *Client) joinMetadata(ctx context.Context, nodeID uint64, raftAddr string) error { + var lastErr error + for _, seed := range c.tracker.Addresses() { + callCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + conn, err := c.tracker.dial(callCtx, seed) + if err != nil { + cancel() + lastErr = err + continue + } + cli := pb.NewFutureQClusterClient(conn) + resp, err := cli.JoinMetadata(callCtx, &pb.JoinMetadataRequest{ + NodeId: nodeID, + RaftAddress: raftAddr, + }) + conn.Close() + cancel() + if err != nil { + lastErr = err + continue + } + if !resp.GetSuccess() { + lastErr = fmt.Errorf("futureq: join metadata rejected: %s", resp.GetErrorMessage()) + continue + } + return nil + } + if lastErr == nil { + lastErr = ErrTopologyUnavailable + } + return lastErr } -// Close releases resources held by the Client. -// If the Client was created with [New] it also closes the underlying gRPC -// connection; connections supplied via [NewWithConn] are left open. +// Close releases resources held by the Client, including the topology +// refresh loop and the embedded dragonboat observer (when enabled). // // It is safe to call Close more than once; subsequent calls are no-ops. func (c *Client) Close() error { + c.mu.Lock() if c.closed { + c.mu.Unlock() return nil } c.closed = true - if c.managed && c.conn != nil { - return c.conn.Close() + c.mu.Unlock() + + if c.observer != nil { + _ = c.observer.Close() } + c.tracker.stop() return nil } -// Conn returns the underlying [grpc.ClientConn]. -// Most callers should use [NewProducer] and [NewConsumer] instead. -func (c *Client) Conn() *grpc.ClientConn { - return c.conn +// Topology returns the most recent cluster topology snapshot known to the +// client. The second return value is false when no snapshot has been +// recorded yet (should not happen after a successful [New]). +func (c *Client) Topology() (Topology, bool) { + return c.tracker.Snapshot() +} + +// Leader returns the current best-known leader address, or "" when unknown. +func (c *Client) Leader() string { + addr, _ := c.tracker.Leader() + return addr } // buildDialOptions converts clientOptions into a slice of grpc.DialOption. func buildDialOptions(o clientOptions) ([]grpc.DialOption, error) { var opts []grpc.DialOption - // Transport credentials switch { case o.insecure: opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials())) case o.tlsConfig != nil: opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(o.tlsConfig))) default: - // Default: TLS with system certificate pool opts = append(opts, grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, ""))) } - // Message size limits (convert MB → bytes) opts = append(opts, grpc.WithDefaultCallOptions( grpc.MaxCallRecvMsgSize(o.maxRecvMsgSizeMB*1024*1024), @@ -217,15 +315,30 @@ func buildDialOptions(o clientOptions) ([]grpc.DialOption, error) { ), ) - // Keep-alive opts = append(opts, grpc.WithKeepaliveParams(keepalive.ClientParameters{ Time: o.keepAliveTime, Timeout: o.keepAliveTimeout, PermitWithoutStream: true, })) - // Caller-supplied extras (applied last so they can override defaults) opts = append(opts, o.additionalDialOpts...) return opts, nil } + +// dialLeader resolves the current leader address and dials it. Returns +// ErrNoLeader when no leader is known. +func (c *Client) dialLeader(ctx context.Context) (*grpc.ClientConn, error) { + addr, _ := c.tracker.Leader() + if addr == "" { + // Try a foreground refresh before failing. + if _, err := c.tracker.refreshOnce(ctx); err != nil { + return nil, ErrNoLeader + } + addr, _ = c.tracker.Leader() + if addr == "" { + return nil, ErrNoLeader + } + } + return c.tracker.dial(ctx, addr) +} diff --git a/go/futureq/consumer.go b/go/futureq/consumer.go index 55dcaf2..4afd133 100644 --- a/go/futureq/consumer.go +++ b/go/futureq/consumer.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "runtime/debug" + "sync" "time" "google.golang.org/grpc" @@ -15,49 +16,51 @@ import ( ) // HandlerFunc is the callback type passed to [Consumer.Subscribe]. -// -// The function is called once for every message delivered from the server. -// The return value controls acknowledgement: -// - Return nil to positively acknowledge (ACK) the message. The server -// will delete the message from its store; it will not be redelivered. - -// - Return any non-nil error to negatively acknowledge (NACK) the message. -// The server will redeliver it on the next dispatch pass (typically after -// 5 seconds). // -// The handler MUST NOT block indefinitely. If the handler panics, [Consumer] -// sends a NACK and wraps the panic value as [ErrHandlerPanic]. +// The function is invoked once per delivered message. The return value +// controls the acknowledgement sent back to the broker: +// - Return nil to ACK. The broker deletes the message; it will not +// be redelivered. +// - Return any non-nil error to NACK. The broker re-dispatches the +// message to another consumer on the next dispatcher tick. +// +// The handler MUST NOT block indefinitely. If it panics, the consumer +// NACKs the message and logs the panic to stderr. type HandlerFunc func(msg Delivery) error -// Consumer subscribes to a FutureQ queue and processes messages as they -// become due. +// Consumer subscribes to a FutureQ topic and group and processes +// messages as they become due. // -// Internally it maintains a long-lived gRPC bi-directional streaming RPC -// ([FutureQConsumer.Subscribe]). The server pushes [QueueMessage] frames -// down the stream; the consumer replies with [AckRequest] frames. +// Internally it maintains a bi-directional gRPC stream +// ([FutureQConsumer.Subscribe]) connected to the current Raft leader. +// When the leader changes, the consumer tears down the stream and +// re-subscribes against the new leader. // // Create a Consumer via [Client.NewConsumer]. -// A Consumer must be closed with [Consumer.Close] when no longer needed. -// -// A Consumer is NOT safe for concurrent use across multiple goroutines; -// only one goroutine should call [Consumer.Subscribe] at a time. +// A Consumer is NOT safe for concurrent use across goroutines; only one +// goroutine should call [Consumer.Subscribe] at a time. type Consumer struct { - stream grpc.BidiStreamingClient[pb.AckRequest, pb.QueueMessage] - ackTimeout time.Duration - concurrency int - closed bool - cancelFn context.CancelFunc + client *Client + cfg consumerConfig + topic string + group string + + mu sync.Mutex + stream grpc.BidiStreamingClient[pb.ConsumerFrame, pb.QueueMessage] + conn *grpc.ClientConn + closed bool + cancel context.CancelFunc } // ConsumerOption is a functional option for [Client.NewConsumer]. type ConsumerOption func(*consumerConfig) type consumerConfig struct { - // ackTimeout is the per-ACK send timeout. Defaults to 5 seconds. + // ackTimeout is the per-ACK send timeout. Defaults to 5 s. ackTimeout time.Duration - // concurrency controls how many handler goroutines may run simultaneously. - // Defaults to 1 (serial processing, preserving ordering within a stream). + // concurrency is the maximum number of handler goroutines that may + // run in parallel. Defaults to 1 (serial, in-order processing). concurrency int } @@ -68,21 +71,15 @@ func defaultConsumerConfig() consumerConfig { } } -// WithAckTimeout sets the maximum time to wait when sending an ACK or NACK -// back to the server. Defaults to 5 seconds. +// WithAckTimeout sets the maximum time to wait when sending an ACK or +// NACK back to the server. Defaults to 5 s. func WithAckTimeout(d time.Duration) ConsumerOption { - return func(c *consumerConfig) { - c.ackTimeout = d - } + return func(c *consumerConfig) { c.ackTimeout = d } } -// WithConcurrency sets the maximum number of message handler goroutines that -// may run in parallel. Defaults to 1 (serial delivery order). -// -// Increasing concurrency can improve throughput when the handler performs -// I/O-bound work, but ordering guarantees are relaxed. -// -// The value must be ≥ 1; values < 1 are silently clamped to 1. +// WithConcurrency sets the maximum number of handler goroutines that +// may run in parallel. Defaults to 1 (serial delivery order). Values +// less than 1 are clamped to 1. func WithConcurrency(n int) ConsumerOption { return func(c *consumerConfig) { if n < 1 { @@ -92,109 +89,140 @@ func WithConcurrency(n int) ConsumerOption { } } -// NewConsumer opens a bidirectional streaming RPC to the FutureQ server and -// returns a ready [Consumer]. +// NewConsumer opens a subscribe stream against the current cluster +// leader for (topic, group) and returns a ready [Consumer]. // -// The context controls the lifetime of the underlying stream. Cancel it to -// terminate the subscription gracefully; [Consumer.Subscribe] will return. +// Consumers sharing the same group compete for messages (one delivery +// per group); different groups on the same topic each receive an +// independent copy (fan-out). // -// consumer, err := client.NewConsumer(ctx, -// futureq.WithConcurrency(4), -// futureq.WithAckTimeout(3*time.Second), -// ) -func (c *Client) NewConsumer(ctx context.Context, opts ...ConsumerOption) (*Consumer, error) { +// The context controls the lifetime of the underlying stream. +func (c *Client) NewConsumer( + ctx context.Context, + topic, group string, + opts ...ConsumerOption, +) (*Consumer, error) { cfg := defaultConsumerConfig() for _, opt := range opts { opt(&cfg) } - client := pb.NewFutureQConsumerClient(c.conn) - - // Wrap ctx so we can cancel the stream from Consumer.Close. streamCtx, cancel := context.WithCancel(ctx) + consumer := &Consumer{ + client: c, + cfg: cfg, + topic: topic, + group: group, + cancel: cancel, + } + if err := consumer.reconnect(streamCtx); err != nil { + cancel() + return nil, err + } + return consumer, nil +} + +// reconnect dials the current leader and re-opens the subscribe stream. +// The caller must hold c.mu. +func (c *Consumer) reconnect(ctx context.Context) error { + addr, _ := c.client.tracker.Leader() + if addr == "" { + if _, err := c.client.tracker.refreshOnce(ctx); err != nil { + return ErrNoLeader + } + addr, _ = c.client.tracker.Leader() + if addr == "" { + return ErrNoLeader + } + } + + c.closeStreamLocked() - stream, err := client.Subscribe(streamCtx) + conn, err := c.client.tracker.dial(ctx, addr) if err != nil { - cancel() - return nil, fmt.Errorf("futureq: open consumer stream: %w", err) + return fmt.Errorf("futureq: dial leader %s: %w", addr, err) + } + cli := pb.NewFutureQConsumerClient(conn) + stream, err := cli.Subscribe(ctx) + if err != nil { + conn.Close() + return fmt.Errorf("futureq: open subscribe stream: %w", err) } - return &Consumer{ - stream: stream, - ackTimeout: cfg.ackTimeout, - concurrency: cfg.concurrency, - cancelFn: cancel, - }, nil + // First frame must be SubscribeInit. + init := &pb.ConsumerFrame{ + Body: &pb.ConsumerFrame_Init{ + Init: &pb.SubscribeInit{ + Topic: c.topic, + GroupId: c.group, + }, + }, + } + if err := stream.Send(init); err != nil { + _ = stream.CloseSend() + conn.Close() + return fmt.Errorf("futureq: send subscribe init: %w", err) + } + + c.conn = conn + c.stream = stream + return nil } -// Subscribe blocks and invokes handler for every message delivered by the -// server. It returns only when the stream is closed (by calling [Close], -// cancelling the context, or a network error). +// closeStreamLocked tears down the current stream, if any. +// The caller must hold c.mu. +func (c *Consumer) closeStreamLocked() { + if c.stream != nil { + _ = c.stream.CloseSend() + c.stream = nil + } + if c.conn != nil { + _ = c.conn.Close() + c.conn = nil + } +} + +// Subscribe blocks and invokes handler for every message delivered by +// the broker. It returns only when the stream is closed (via [Close], +// context cancellation, or a non-recoverable transport error). // // # Message ordering // -// When [WithConcurrency] is 1 (the default), messages are processed serially -// and in delivery order. With higher concurrency, ordering is not guaranteed. +// When [WithConcurrency] is 1 (the default), messages are processed +// serially and in delivery order. With higher concurrency, ordering is +// not guaranteed. // // # Error handling // -// If handler returns a non-nil error, the message is NACKed and the server -// will redeliver it. A NACK does not stop the subscription loop; Subscribe -// continues to process subsequent messages. +// Returning a non-nil error from handler NACKs the message; the broker +// redelivers it on the next dispatcher tick. A NACK does not stop the +// subscription loop. // -// If handler panics, Subscribe recovers the panic, NACKs the message, and -// continues. The recovered panic value is logged to stderr. +// A panic in handler is recovered, the message is NACKed, and the panic +// value is printed to stderr. // -// Subscribe returns nil when the stream was closed cleanly (context cancelled -// or [Close] called). It returns a non-nil error for unexpected transport -// failures. -// -// err := consumer.Subscribe(ctx, func(d futureq.Delivery) error { -// return process(d.Payload) -// }) -// if err != nil { -// log.Printf("consumer error: %v", err) -// } +// Subscribe returns nil when the stream was closed cleanly. It returns +// a non-nil error for unexpected transport failures. func (c *Consumer) Subscribe(ctx context.Context, handler HandlerFunc) error { + c.mu.Lock() if c.closed { + c.mu.Unlock() return ErrClosed } + stream := c.stream + c.mu.Unlock() - // sem limits the number of concurrent handler goroutines. - sem := make(chan struct{}, c.concurrency) - - // ackCh serialises ACK/NACK writes back to the server. - // We use a buffered channel sized to concurrency+1 to prevent handler - // goroutines from blocking when the ACK sender is busy. - ackCh := make(chan *pb.AckRequest, c.concurrency+1) - - // errCh collects the first fatal error from the ACK sender goroutine. + sem := make(chan struct{}, c.cfg.concurrency) + ackCh := make(chan *pb.AckRequest, c.cfg.concurrency+1) errCh := make(chan error, 1) - // ACK sender goroutine — one goroutine owns all writes to the stream. - go func() { - for ack := range ackCh { - ackCtx, cancel := context.WithTimeout(ctx, c.ackTimeout) - err := sendAck(ackCtx, c.stream, ack) - cancel() - if err != nil { - select { - case errCh <- err: - default: - } - return - } - } - errCh <- nil - }() + go c.ackSender(ctx, stream, ackCh, errCh) - // Receive loop. for { - msg, err := c.stream.Recv() + msg, err := stream.Recv() if err != nil { - // Close the ack channel so the sender goroutine drains and exits. close(ackCh) - <-errCh // wait for sender to finish + <-errCh if err == io.EOF { return nil @@ -206,30 +234,27 @@ func (c *Consumer) Subscribe(ctx context.Context, handler HandlerFunc) error { return fmt.Errorf("futureq: consumer recv: %w", err) } - delivery := Delivery{ + d := Delivery{ + Topic: msg.GetTopic(), Payload: msg.GetPayload(), + EnqueuedAt: time.UnixMilli(msg.GetEnqueuedAtUnixMs()), + Delay: time.Duration(msg.GetDelayMs()) * time.Millisecond, deliveryTag: msg.GetDeliveryTag(), } - // Acquire a handler slot (blocks if at concurrency limit). sem <- struct{}{} - - go func(d Delivery) { - defer func() { <-sem }() // release slot when done - - ack := c.invokeHandler(handler, d) - + go func(del Delivery) { + defer func() { <-sem }() + ack := c.invokeHandler(handler, del) select { case ackCh <- ack: case <-ctx.Done(): } - }(delivery) + }(d) - // Check if the ACK sender encountered a fatal error. select { case err := <-errCh: if err != nil { - close(ackCh) return fmt.Errorf("futureq: consumer ack sender: %w", err) } default: @@ -237,17 +262,39 @@ func (c *Consumer) Subscribe(ctx context.Context, handler HandlerFunc) error { } } -// invokeHandler calls handler in a deferred-recover wrapper. -// It returns an AckRequest with success=true on nil return, false otherwise. +// ackSender owns all writes to the stream after the initial +// SubscribeInit. It runs until ackCh is closed. +func (c *Consumer) ackSender( + ctx context.Context, + stream grpc.BidiStreamingClient[pb.ConsumerFrame, pb.QueueMessage], + ackCh <-chan *pb.AckRequest, + errCh chan<- error, +) { + for ack := range ackCh { + frame := &pb.ConsumerFrame{Body: &pb.ConsumerFrame_Ack{Ack: ack}} + + sendCtx, cancel := context.WithTimeout(ctx, c.cfg.ackTimeout) + err := sendConsumerFrame(sendCtx, stream, frame) + cancel() + if err != nil { + select { + case errCh <- err: + default: + } + return + } + } + errCh <- nil +} + +// invokeHandler calls handler in a deferred-recover wrapper and +// converts the result into an AckRequest. func (c *Consumer) invokeHandler(handler HandlerFunc, d Delivery) *pb.AckRequest { success := true - func() { defer func() { if r := recover(); r != nil { success = false - // Print the panic to stderr so it is visible in logs even if - // the caller does not check the error. fmt.Printf("futureq: handler panicked: %v\n%s\n", r, debug.Stack()) } }() @@ -255,42 +302,43 @@ func (c *Consumer) invokeHandler(handler HandlerFunc, d Delivery) *pb.AckRequest success = false } }() - return &pb.AckRequest{ Success: success, DeliveryTag: d.deliveryTag, } } -// Close cancels the underlying stream context, causing [Subscribe] to return. -// Any in-flight handler invocations are allowed to finish before the stream is -// torn down by the server. +// Close cancels the underlying stream context, causing [Subscribe] to +// return. In-flight handler invocations are allowed to finish. // -// It is safe to call Close more than once; subsequent calls are no-ops. +// Safe to call more than once. func (c *Consumer) Close() error { + c.mu.Lock() + defer c.mu.Unlock() if c.closed { return nil } c.closed = true - c.cancelFn() + c.cancel() + c.closeStreamLocked() return nil } -// sendAck writes a single AckRequest to the stream. -func sendAck(ctx context.Context, stream grpc.BidiStreamingClient[pb.AckRequest, pb.QueueMessage], ack *pb.AckRequest) error { +// sendConsumerFrame writes a single ConsumerFrame honouring ctx. +func sendConsumerFrame( + ctx context.Context, + stream grpc.BidiStreamingClient[pb.ConsumerFrame, pb.QueueMessage], + frame *pb.ConsumerFrame, +) error { type result struct{ err error } ch := make(chan result, 1) - - go func() { - ch <- result{err: stream.Send(ack)} - }() - + go func() { ch <- result{err: stream.Send(frame)} }() select { case <-ctx.Done(): - return fmt.Errorf("futureq: ack send: %w", ctx.Err()) + return fmt.Errorf("futureq: consumer frame send: %w", ctx.Err()) case r := <-ch: if r.err != nil && r.err != io.EOF { - return fmt.Errorf("futureq: ack send: %w", r.err) + return fmt.Errorf("futureq: consumer frame send: %w", r.err) } return nil } diff --git a/go/futureq/doc.go b/go/futureq/doc.go index 2a102b4..bdab496 100644 --- a/go/futureq/doc.go +++ b/go/futureq/doc.go @@ -1,62 +1,68 @@ -// Package futureq provides a production-ready Go client SDK for the FutureQ -// scheduled message queue. +// Package futureq provides a production-ready Go client SDK for the +// FutureQ scheduled message queue. // // # Overview // -// FutureQ is a distributed, time-bucket-based scheduled queue backed by Pebble -// (an LSM key-value store) and optionally replicated via the Dragonboat Raft -// library. This SDK abstracts the underlying gRPC bi-directional streaming -// protocol into two high-level, idiomatic Go clients: +// FutureQ is a distributed, time-bucket-based scheduled queue backed by +// Pebble and replicated via Dragonboat Raft. This SDK abstracts the +// gRPC bi-directional streaming protocol into two high-level clients: // -// - [Producer] — schedules messages to be delivered at a specific time. -// - [Consumer] — subscribes to the queue and receives messages when they -// become due, acknowledging each one to prevent redelivery. +// - [Producer] — publishes batches of messages with optional delays, +// TTLs and secondary indexes. +// - [Consumer] — subscribes to a (topic, group) pair and invokes a +// handler for every delivered message, ACKing or NACKing each one. +// +// # Topology discovery +// +// The SDK tracks the cluster's Raft leader and routes streams to it. +// Two discovery mechanisms are available: +// +// 1. Polling — the SDK periodically calls GetClusterInfo on one of +// the seed addresses passed to [New]. This is the default. +// 2. Dragonboat discovery — when [WithDragonboatDiscovery] is set the +// SDK embeds a non-voting Dragonboat replica of the metadata Raft +// group inside the client process. Topology changes flow in over +// Raft as they commit, with no polling. The embedded replica stores +// all of its state in an in-memory VFS — nothing is ever written +// to disk. +// +// Dragonboat discovery is experimental. // // # Connecting // -// Create a [Client] with [New] (or [NewWithConn] to supply your own -// [google.golang.org/grpc.ClientConn]): +// Create a [Client] with [New]: // -// client, err := futureq.New("localhost:8443", futureq.WithInsecure()) -// if err != nil { -// log.Fatal(err) -// } +// client, err := futureq.New( +// []string{"node1.internal:9000", "node2.internal:9000"}, +// futureq.WithInsecure(), +// ) +// if err != nil { log.Fatal(err) } // defer client.Close() // -// # Producing messages -// -// Obtain a [Producer] from the client and call [Producer.Publish]: +// # Producing // // producer, err := client.NewProducer(ctx) -// if err != nil { -// log.Fatal(err) -// } +// if err != nil { log.Fatal(err) } // defer producer.Close() // -// err = producer.Publish(ctx, futureq.Message{ -// Topic: "notifications", -// Payload: []byte(`{"user": 42}`), -// ExecuteAt: time.Now().Add(5 * time.Minute), -// }) -// -// # Consuming messages +// err = producer.PublishBatch(ctx, []futureq.Message{ +// {Topic: "email", Payload: []byte("…"), Delay: 5 * time.Minute}, +// }, futureq.AckQuorum) // -// Obtain a [Consumer] from the client and call [Consumer.Subscribe]: +// # Consuming // -// consumer, err := client.NewConsumer(ctx) -// if err != nil { -// log.Fatal(err) -// } +// consumer, err := client.NewConsumer(ctx, "email", "workers") +// if err != nil { log.Fatal(err) } // defer consumer.Close() // -// err = consumer.Subscribe(ctx, func(msg futureq.Delivery) error { -// fmt.Printf("received: %s\n", msg.Payload) -// return nil // returning nil ACKs the message +// err = consumer.Subscribe(ctx, func(d futureq.Delivery) error { +// process(d.Payload) +// return nil // nil ACKs the message // }) // // # Error handling // -// All public methods return typed errors. Sentinel errors defined in this -// package (e.g. [ErrNotLeader], [ErrStreamClosed]) can be inspected with -// [errors.Is]. +// All public methods return typed errors. Sentinel errors defined in +// this package (e.g. [ErrNotLeader], [ErrNoLeader], [ErrStreamClosed]) +// can be inspected with [errors.Is]. package futureq diff --git a/go/futureq/errors.go b/go/futureq/errors.go index d56cdca..fb7232d 100644 --- a/go/futureq/errors.go +++ b/go/futureq/errors.go @@ -8,34 +8,47 @@ import ( // Sentinel errors returned by the SDK. // Use [errors.Is] to test for them: // -// if errors.Is(err, futureq.ErrNotLeader) { … } +// if errors.Is(err, futureq.ErrNoLeader) { … } var ( - // ErrNotLeader is returned by [Producer.Publish] when the connected node is - // not the current Raft cluster leader and therefore cannot accept writes. - // The caller should retry against the leader node. + // ErrNotLeader is returned by [Producer.PublishBatch] when the + // connected node is not the current Raft cluster leader and therefore + // cannot accept writes. The SDK usually recovers from this + // automatically by re-resolving the leader through the topology + // tracker; callers may also retry manually. ErrNotLeader = errors.New("futureq: node is not the cluster leader") + // ErrNoLeader is returned when the SDK does not currently know of any + // live leader to route a request to (e.g. immediately after startup + // before the first topology refresh, or during a rolling restart). + // Retrying after a short backoff usually succeeds. + ErrNoLeader = errors.New("futureq: no known cluster leader") + // ErrStreamClosed is returned when the underlying gRPC bi-directional - // stream has been closed by the server or the network. The [Producer] or - // [Consumer] should be discarded and a new one created. + // stream has been closed by the server or the network. The [Producer] + // or [Consumer] should be discarded and a new one created. ErrStreamClosed = errors.New("futureq: stream closed") - // ErrPublishFailed is returned by [Producer.Publish] when the server - // acknowledged the message but reported an application-level error. - // The wrapped error message contains the server's error string. + // ErrPublishFailed is returned by [Producer.PublishBatch] when the + // server acknowledged the batch but reported an application-level + // error. Use [errors.As] to recover the structured [PublishError]. ErrPublishFailed = errors.New("futureq: publish failed") // ErrHandlerPanic is returned by [Consumer.Subscribe] when the message - // handler panicked. The wrapped value contains the recovered panic value. + // handler panicked. The wrapped value contains the recovered panic value. ErrHandlerPanic = errors.New("futureq: handler panicked") - // ErrClosed is returned when a method is called on a [Producer] or - // [Consumer] that has already been closed. + // ErrClosed is returned when a method is called on a [Client], + // [Producer] or [Consumer] that has already been closed. ErrClosed = errors.New("futureq: client is closed") + + // ErrTopologyUnavailable is returned when the SDK cannot obtain the + // cluster topology from any of the configured addresses (all seeds + // unreachable and no cached leader). + ErrTopologyUnavailable = errors.New("futureq: cluster topology unavailable") ) -// PublishError is the structured error type returned when a single Publish -// call is acknowledged by the server with success=false. +// PublishError is the structured error type returned when a batch is +// acknowledged by the server with success=false. // // It wraps [ErrPublishFailed] and additionally carries the server-supplied // error message. diff --git a/go/futureq/example_test.go b/go/futureq/example_test.go index afac970..d29d5a9 100644 --- a/go/futureq/example_test.go +++ b/go/futureq/example_test.go @@ -9,12 +9,11 @@ import ( "github.com/futureq-io/sdk/go/futureq" ) -// ExampleClient_NewProducer demonstrates how to create a producer and -// schedule a single message. -func ExampleClient_NewProducer() { +// Example demonstrates the simplest produce-and-consume flow. +func Example() { client, err := futureq.New( - "futureq.internal:8443", - futureq.WithTLS(nil), + []string{"localhost:9000"}, + futureq.WithInsecure(), ) if err != nil { log.Fatal(err) @@ -22,118 +21,72 @@ func ExampleClient_NewProducer() { defer client.Close() ctx := context.Background() - producer, err := client.NewProducer(ctx, futureq.WithPublishTimeout(5*time.Second)) + + producer, err := client.NewProducer(ctx) if err != nil { log.Fatal(err) } defer producer.Close() err = producer.Publish(ctx, futureq.Message{ - Topic: "email-notifications", - Payload: []byte(`{"to":"alice@example.com","subject":"Welcome!"}`), - ExecuteAt: time.Now().Add(10 * time.Minute), + Topic: "email", + Payload: []byte(`{"to":"user@example.com"}`), + Delay: 5 * time.Minute, }) - if err != nil { - log.Printf("publish error: %v", err) - return - } - - fmt.Println("message scheduled") - // Output: message scheduled -} - -// ExampleProducer_PublishBatch shows how to schedule multiple messages -// in a single call. -// func ExampleProducer_PublishBatch() { -// client, err := futureq.New("futureq.internal:8443", futureq.WithTLS(nil)) -// if err != nil { -// log.Fatal(err) -// } -// defer client.Close() - -// ctx := context.Background() -// producer, err := client.NewProducer(ctx) -// if err != nil { -// log.Fatal(err) -// } -// defer producer.Close() - -// now := time.Now() -// messages := []futureq.Message{ -// {Topic: "reminders", Payload: []byte("reminder-1"), ExecuteAt: now.Add(1 * time.Minute)}, -// {Topic: "reminders", Payload: []byte("reminder-2"), ExecuteAt: now.Add(2 * time.Minute)}, -// {Topic: "reminders", Payload: []byte("reminder-3"), ExecuteAt: now.Add(3 * time.Minute)}, -// } - -// result, err := producer.PublishBatch(ctx, messages) -// if err != nil { -// log.Fatalf("transport error: %v", err) -// } - -// for i, e := range result.Errors { -// if e != nil { -// log.Printf("message %d failed: %v", i, e) -// } -// } - -// fmt.Printf("failed: %d/%d\n", len(result.FailedIndices()), len(messages)) -// } - -// ExampleClient_NewConsumer demonstrates how to subscribe to the queue -// and process messages with automatic ACK/NACK. -func ExampleClient_NewConsumer() { - client, err := futureq.New("futureq.internal:8443", futureq.WithTLS(nil)) if err != nil { log.Fatal(err) } - defer client.Close() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - consumer, err := client.NewConsumer(ctx, - futureq.WithConcurrency(4), - futureq.WithAckTimeout(3*time.Second), - ) + consumer, err := client.NewConsumer(ctx, "email", "senders") if err != nil { log.Fatal(err) } defer consumer.Close() - err = consumer.Subscribe(ctx, func(d futureq.Delivery) error { - fmt.Printf("received on topic %q: %s\n", d.Topic, d.Payload) - // Return nil to ACK; return an error to NACK and trigger redelivery. + _ = consumer.Subscribe(ctx, func(d futureq.Delivery) error { + fmt.Printf("received on %s: %s\n", d.Topic, d.Payload) return nil }) +} + +// ExampleClient_dragonboatDiscovery shows how to enable the +// experimental Dragonboat-based topology discovery. +func ExampleClient_dragonboatDiscovery() { + client, err := futureq.New( + []string{"node1.internal:9000", "node2.internal:9000"}, + futureq.WithInsecure(), + // Push-based topology updates; no disk I/O. + futureq.WithDragonboatDiscovery(nil), + ) if err != nil { - log.Printf("consumer error: %v", err) + log.Fatal(err) } + defer client.Close() } -// ExampleProducer_PublishWithRetry demonstrates the built-in retry helper. -func ExampleProducer_PublishWithRetry() { - client, err := futureq.New("futureq.internal:8443", futureq.WithTLS(nil)) +// ExampleProducer_PublishBatch demonstrates atomic batch publishing. +func ExampleProducer_PublishBatch() { + client, err := futureq.New([]string{"localhost:9000"}, futureq.WithInsecure()) if err != nil { log.Fatal(err) } defer client.Close() - ctx := context.Background() - producer, err := client.NewProducer(ctx) + producer, err := client.NewProducer(context.Background()) if err != nil { log.Fatal(err) } defer producer.Close() - policy := futureq.DefaultRetryPolicy() - policy.MaxAttempts = 5 - - err = producer.PublishWithRetry(ctx, futureq.Message{ - Topic: "orders", - Payload: []byte(`{"order_id": 9001}`), - ExecuteAt: time.Now().Add(30 * time.Second), - }, policy) + err = producer.PublishBatch(context.Background(), []futureq.Message{ + {Topic: "events", Payload: []byte("a"), Delay: time.Minute}, + {Topic: "events", Payload: []byte("b"), Delay: 2 * time.Minute, TTL: time.Hour}, + {Topic: "events", Payload: []byte("c"), Indexes: []futureq.Index{ + futureq.StringIndex("user:42"), + futureq.Int64Index(1001), + }}, + }, futureq.AckQuorum) if err != nil { - log.Printf("all retries exhausted: %v", err) + log.Fatal(err) } } diff --git a/go/futureq/futureq_test.go b/go/futureq/futureq_test.go index 544b74a..fed9326 100644 --- a/go/futureq/futureq_test.go +++ b/go/futureq/futureq_test.go @@ -1,190 +1,108 @@ -package futureq_test +package futureq import ( - "context" "errors" "testing" "time" - - "github.com/futureq-io/sdk/go/futureq" ) -// ---------------------------------------------------------------------------- -// Client option tests -// ---------------------------------------------------------------------------- - -func TestWithInsecure(t *testing.T) { - t.Parallel() - // New should not dial immediately; it should succeed even without a server. - client, err := futureq.New("localhost:19999", futureq.WithInsecure()) - if err != nil { - t.Fatalf("New() error = %v, want nil", err) +func TestMessageToProtoRoundTrip(t *testing.T) { + m := Message{ + Topic: "events", + Payload: []byte("hello"), + Delay: 5 * time.Second, + TTL: time.Hour, + Indexes: []Index{ + Int64Index(42), + StringIndex("user:7"), + }, + } + pm := toProtoMessage(m) + if pm.GetTopic() != m.Topic { + t.Fatalf("topic mismatch: %q", pm.GetTopic()) + } + if string(pm.GetPayload()) != string(m.Payload) { + t.Fatalf("payload mismatch") + } + if pm.GetDelayMs() != 5000 { + t.Fatalf("delay mismatch: %d", pm.GetDelayMs()) + } + if pm.GetTtlMs() != int64(time.Hour/time.Millisecond) { + t.Fatalf("ttl mismatch: %d", pm.GetTtlMs()) + } + if got := pm.GetIndexes(); len(got) != 2 { + t.Fatalf("expected 2 indexes, got %d", len(got)) + } else { + if got[0].GetInt64Value() != 42 { + t.Errorf("index 0: expected 42, got %d", got[0].GetInt64Value()) + } + if got[1].GetStringValue() != "user:7" { + t.Errorf("index 1: expected user:7, got %q", got[1].GetStringValue()) + } } - defer client.Close() } -func TestWithTLS_nil(t *testing.T) { - t.Parallel() - // TLS with nil config uses system certs — connection won't complete but - // New itself should succeed. - _, err := futureq.New("localhost:19999", futureq.WithTLS(nil)) - if err != nil { - t.Fatalf("New() with TLS(nil) error = %v, want nil", err) +func TestAckLevelToProto(t *testing.T) { + if toProtoAckLevel(AckQuorum) != 0 { + t.Errorf("AckQuorum should map to 0") + } + if toProtoAckLevel(AckNone) != 1 { + t.Errorf("AckNone should map to 1") } } -func TestClientClose_multipleCallsAreNoOps(t *testing.T) { - t.Parallel() - client, err := futureq.New("localhost:19999", futureq.WithInsecure()) - if err != nil { - t.Fatal(err) - } +func TestTopologyTracker_SetLeaderNotifiesOnChange(t *testing.T) { + tracker := newTopologyTracker([]string{"a:1"}, time.Hour, nil) - if err := client.Close(); err != nil { - t.Fatalf("first Close() error = %v", err) - } - // Second close must not panic or error. - if err := client.Close(); err != nil { - t.Fatalf("second Close() error = %v", err) + tracker.setLeader(1, "a:9000", Topology{LeaderNodeID: 1, LeaderAddress: "a:9000"}) + select { + case <-tracker.Notify(): + default: + t.Fatal("expected a notification on first set") } -} - -// ---------------------------------------------------------------------------- -// Retry policy tests -// ---------------------------------------------------------------------------- -func TestDefaultRetryPolicy(t *testing.T) { - p := futureq.DefaultRetryPolicy() - if p.MaxAttempts < 1 { - t.Errorf("MaxAttempts = %d, want ≥ 1", p.MaxAttempts) + // Same address → no notification. + tracker.setLeader(1, "a:9000", Topology{LeaderNodeID: 1, LeaderAddress: "a:9000"}) + select { + case <-tracker.Notify(): + t.Fatal("did not expect a notification when address is unchanged") + default: } - if p.InitialBackoff <= 0 { - t.Errorf("InitialBackoff = %v, want > 0", p.InitialBackoff) + + // New address → notification. + tracker.setLeader(2, "b:9000", Topology{LeaderNodeID: 2, LeaderAddress: "b:9000"}) + select { + case <-tracker.Notify(): + default: + t.Fatal("expected a notification on address change") } } func TestDefaultRetryable(t *testing.T) { - t.Parallel() - tests := []struct { - name string - err error - wantRetry bool + cases := []struct { + name string + err error + want bool }{ - {"nil error", nil, false}, - {"ErrNotLeader", futureq.ErrNotLeader, false}, - {"ErrClosed", futureq.ErrClosed, false}, - {"ErrPublishFailed", futureq.ErrPublishFailed, false}, - {"wrapped ErrNotLeader", errors.Join(errors.New("outer"), futureq.ErrNotLeader), false}, - {"arbitrary error", errors.New("some transient error"), false}, - } - for _, tc := range tests { - tc := tc + {"nil", nil, false}, + {"no leader", ErrNoLeader, true}, + {"not leader", ErrNotLeader, true}, + {"stream closed", ErrStreamClosed, true}, + {"publish failed", &PublishError{ServerMessage: "boom"}, false}, + {"closed", ErrClosed, false}, + } + for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got := futureq.DefaultRetryable(tc.err) - if got != tc.wantRetry { - t.Errorf("DefaultRetryable(%v) = %v, want %v", tc.err, got, tc.wantRetry) + if got := DefaultRetryable(tc.err); got != tc.want { + t.Errorf("DefaultRetryable(%v) = %v, want %v", tc.err, got, tc.want) } }) } } -// ---------------------------------------------------------------------------- -// Error type tests -// ---------------------------------------------------------------------------- - -func TestPublishError_Is(t *testing.T) { - t.Parallel() - pe := &futureq.PublishError{ServerMessage: "disk full"} - if !errors.Is(pe, futureq.ErrPublishFailed) { - t.Error("errors.Is(publishError, ErrPublishFailed) = false, want true") - } -} - -func TestPublishError_Unwrap(t *testing.T) { - t.Parallel() - pe := &futureq.PublishError{ServerMessage: "oops"} - if !errors.Is(pe, futureq.ErrPublishFailed) { - t.Error("unwrap chain does not reach ErrPublishFailed") - } -} - -func TestPublishError_Error(t *testing.T) { - t.Parallel() - pe := &futureq.PublishError{ServerMessage: "oops"} - if pe.Error() == "" { - t.Error("Error() returned empty string") - } -} - -// ---------------------------------------------------------------------------- -// Message zero-value tests -// ---------------------------------------------------------------------------- - -func TestMessage_zeroValueExecuteAt(t *testing.T) { - t.Parallel() - var m futureq.Message - // ExecuteAt zero value should marshal to negative/zero unix ms — verify it - // doesn't panic during access. - _ = m.ExecuteAt.UnixMilli() -} - -// ---------------------------------------------------------------------------- -// BatchResult tests -// ---------------------------------------------------------------------------- - -func TestBatchResult_HasErrors_false(t *testing.T) { - t.Parallel() - r := futureq.BatchResult{Errors: []error{nil, nil}} - if r.HasErrors() { - t.Error("HasErrors() = true on all-nil errors, want false") - } -} - -func TestBatchResult_HasErrors_true(t *testing.T) { - t.Parallel() - r := futureq.BatchResult{Errors: []error{nil, errors.New("fail"), nil}} - if !r.HasErrors() { - t.Error("HasErrors() = false, want true") - } -} - -func TestBatchResult_FailedIndices(t *testing.T) { - t.Parallel() - r := futureq.BatchResult{Errors: []error{nil, errors.New("fail"), nil, errors.New("fail2")}} - indices := r.FailedIndices() - if len(indices) != 2 || indices[0] != 1 || indices[1] != 3 { - t.Errorf("FailedIndices() = %v, want [1 3]", indices) +func TestPublishErrorUnwrap(t *testing.T) { + err := &PublishError{ServerMessage: "boom"} + if !errors.Is(err, ErrPublishFailed) { + t.Fatal("PublishError should unwrap to ErrPublishFailed") } } - -// ---------------------------------------------------------------------------- -// Producer/Consumer — closed state tests (no server required) -// ---------------------------------------------------------------------------- - -func TestProducer_publishAfterClose_returnsErrClosed(t *testing.T) { - t.Parallel() - // We can't open a real stream without a server, so we test via - // NewConsumer/NewProducer only when the underlying gRPC connection is - // established. Here we simply verify the ErrClosed sentinel is defined. - if futureq.ErrClosed == nil { - t.Error("ErrClosed must not be nil") - } -} - -func TestConsumerOptions_concurrencyClamp(t *testing.T) { - t.Parallel() - // WithConcurrency(0) should silently clamp to 1 — verify no panic. - client, err := futureq.New("localhost:19999", futureq.WithInsecure()) - if err != nil { - t.Fatal(err) - } - defer client.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) - defer cancel() - - // NewConsumer will fail because there's no server, but the option itself - // must not panic. - _, _ = client.NewConsumer(ctx, futureq.WithConcurrency(0)) -} diff --git a/go/futureq/message.go b/go/futureq/message.go index 0ff6440..1e9eb9f 100644 --- a/go/futureq/message.go +++ b/go/futureq/message.go @@ -2,42 +2,112 @@ package futureq import "time" -// Message is the value type passed to [Producer.Publish]. +// AckLevel controls how durable a batch publish must be before the broker +// acknowledges it. It maps directly onto the wire-level +// [pb.AckLevel] enumeration. +type AckLevel int + +const ( + // AckQuorum requires the batch to be replicated to a quorum of Raft + // voters before the broker acknowledges it. This is the default and the + // safest option. + AckQuorum AckLevel = iota + + // AckNone asks the broker to acknowledge the batch immediately, without + // waiting for replication. Use this only when losing messages is + // acceptable (e.g. high-throughput telemetry). + AckNone +) + +// Index is a single secondary index attached to a published message. +// +// Indexes are expensive in FutureQ — attach only the ones you actually need +// to query on the consumer side. A message may carry any mix of int64 and +// string indexes. +type Index struct { + // Int64Value is set when the index is a 64-bit signed integer. + // Exactly one of Int64Value / StringValue must be non-zero / non-empty. + Int64Value int64 + + // StringValue is set when the index is an arbitrary string. + StringValue string + + // IsString selects which field the SDK serialises. When true the SDK + // writes StringValue; otherwise it writes Int64Value. + IsString bool +} + +// Int64Index is a convenience constructor for a numeric index. +func Int64Index(v int64) Index { + return Index{Int64Value: v} +} + +// StringIndex is a convenience constructor for a string index. +func StringIndex(v string) Index { + return Index{StringValue: v, IsString: true} +} + +// Message is the value type passed to [Producer.Publish] and +// [Producer.PublishBatch]. +// // Every field has an idiomatic zero value: -// - Topic defaults to the empty string (valid; the server accepts it). -// - Payload may be nil (the server stores a zero-byte body). -// - ExecuteAt defaults to time.Time{} which is treated as "execute -// immediately" by the FutureQ server (bucket 0). +// - Topic may be empty (the server accepts it). +// - Payload may be nil (a zero-byte body is stored). +// - Delay and TTL of zero mean "deliver on the next dispatcher tick" and +// "never expire" respectively. +// - Indexes may be nil. type Message struct { - // Topic is an arbitrary string label for the message. - // It is stored alongside the payload and surfaced in [Delivery]. - // Topics are not used for routing in the current server implementation - // but are available for application-level filtering on the consumer side. + // Topic identifies the logical channel for this message. + // Consumers subscribe to a topic and receive every message published on it. Topic string // Payload is the raw bytes to deliver to consumers. // There is no imposed structure; JSON, Protobuf, Avro, etc. all work. Payload []byte - // ExecuteAt is the earliest time at which the message should be - // delivered. The server will not dispatch the message before this - // instant. Pass time.Now() or a zero value to schedule for immediate - // delivery. - ExecuteAt time.Time + // Delay is how long the broker waits before the message becomes eligible + // for delivery, measured from the broker's receive time. Zero means + // "deliver on the next dispatcher tick". + Delay time.Duration + + // TTL is the message time-to-live, also measured from the broker's + // receive time. If the message has not been consumed within TTL it is + // lazily discarded. Zero means no expiry. + TTL time.Duration + + // Indexes are the secondary indexes attached to the message. + // Indexes are expensive in FutureQ — see [Index]. + Indexes []Index } // Delivery is received by the handler function passed to [Consumer.Subscribe]. -// It carries the decoded message body and the opaque delivery tag that must be -// echoed back in the ACK/NACK sent to the server. +// It carries the decoded message body plus the metadata the broker recorded +// when the message was first received. type Delivery struct { - // Topic is the topic label set by the producer. + // Topic is the channel this message was published on. Topic string // Payload is the raw message body. Payload []byte - // DeliveryTag is an opaque server-assigned token that uniquely identifies - // You do not need to use this field directly; the SDK uses it internally - // when generating ACK/NACK responses. + // EnqueuedAt is the broker wall-clock time when the message was first + // received. Useful for computing actual end-to-end delivery latency. + EnqueuedAt time.Time + + // Delay is the original delay requested by the producer. + Delay time.Duration + + // deliveryTag is the opaque server-assigned token (the raw Pebble key) + // that uniquely identifies this delivery. The SDK echoes it back in the + // ACK/NACK frame; callers should never need to touch it directly. deliveryTag []byte } + +// DeliveryTag returns the raw server-assigned token for this delivery. +// Most callers never need this — the SDK manages ACKs internally. +func (d Delivery) DeliveryTag() []byte { + // Return a copy so callers cannot mutate the tag the SDK relies on. + out := make([]byte, len(d.deliveryTag)) + copy(out, d.deliveryTag) + return out +} diff --git a/go/futureq/observer.go b/go/futureq/observer.go new file mode 100644 index 0000000..d1abfb0 --- /dev/null +++ b/go/futureq/observer.go @@ -0,0 +1,262 @@ +package futureq + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/lni/dragonboat/v4" + raftconfig "github.com/lni/dragonboat/v4/config" + "github.com/lni/dragonboat/v4/statemachine" + "github.com/lni/vfs" + "go.uber.org/zap" + + "github.com/futureq-io/futureq/pkg/raft/metadata" +) + +// metadataObserverConfig configures the embedded Dragonboat observer. +// +// The observer runs a local, non-voting Dragonboat replica of the FutureQ +// metadata Raft group inside the SDK process. All Dragonboat state is kept +// in a memory-backed VFS — no files are ever written to disk. Topology +// changes flow into the SDK via the metadata StateMachine the moment they +// are committed, without any polling. +type metadataObserverConfig struct { + // NodeID is the unique ID of this observer within the metadata Raft + // group. Must not collide with any broker or any other observer. + // When zero a random ID in [1<<32, 1<<48) is generated. + NodeID uint64 + + // RaftAddress is the host:port this observer's Raft transport listens + // on. Must be reachable by the brokers so they can replicate the + // metadata log to us. When empty, "127.0.0.1:0" is used (any free port + // on localhost). + RaftAddress string + + // RTTMillisecond is the expected round-trip time between Raft peers. + // Defaults to 50 ms. + RTTMillisecond uint64 + + // ShardID is the event shard whose topology the observer reports. + // The observer tracks every shard present in the metadata log but only + // the leader of ShardID is pushed into the topology tracker. + // Defaults to 1 (the default FutureQ event shard). + ShardID uint64 + + // OnTopologyChange is an optional callback invoked every time the + // tracked shard's leader changes. It runs on the observer's internal + // goroutine; keep it fast and non-blocking. + OnTopologyChange func(topo *metadata.ShardTopology) +} + +// metadataObserver wraps an embedded Dragonboat NodeHost running a +// non-voting replica of the metadata Raft group. +type metadataObserver struct { + cfg metadataObserverConfig + nh *dragonboat.NodeHost + sm *metadata.MetadataStateMachine + fs *vfs.MemFS // held so GC never collects it while NodeHost is running + track *topologyTracker + done chan struct{} + wg sync.WaitGroup + mu sync.Mutex // guards closed + closed bool +} + +// startMetadataObserver brings up the observer: +// 1. Allocate an observer NodeID / Raft address if the caller left them blank. +// 2. Create a NodeHost with an in-memory VFS (no disk writes). +// 3. StartReplica on the metadata shard with the existing +// metadata.NewMetadataStateMachineFactory from futureq-v2. +// 4. Call JoinMetadata on one of the seed brokers so we are added as a +// non-voting member of the metadata group. +// 5. Spawn a watcher goroutine that reads the state machine on every +// applied entry and forwards leader changes into the tracker. +// +// seedDial is used to call the cluster's JoinMetadata RPC; it must dial +// one of the seed brokers. +func startMetadataObserver( + ctx context.Context, + cfg metadataObserverConfig, + track *topologyTracker, + seedDial func(ctx context.Context) error, +) (*metadataObserver, error) { + if cfg.RTTMillisecond == 0 { + cfg.RTTMillisecond = 50 + } + if cfg.ShardID == 0 { + cfg.ShardID = 1 + } + if cfg.RaftAddress == "" { + cfg.RaftAddress = "127.0.0.1:0" + } + if cfg.NodeID == 0 { + cfg.NodeID = newObserverNodeID() + } + + // In-memory VFS — Dragonboat will use it for WALDir, NodeHostDir and + // all snapshot/log files. Nothing ever touches the OS filesystem. + memfs := vfs.NewMem() + + nhc := raftconfig.NodeHostConfig{ + WALDir: "observer-wal", + NodeHostDir: "observer-data", + RTTMillisecond: cfg.RTTMillisecond, + RaftAddress: cfg.RaftAddress, + } + nhc.Expert.FS = memfs + + nh, err := dragonboat.NewNodeHost(nhc) + if err != nil { + return nil, fmt.Errorf("futureq: dragonboat observer: %w", err) + } + + obs := &metadataObserver{ + cfg: cfg, + nh: nh, + fs: memfs, + track: track, + done: make(chan struct{}), + } + + // Wrap the existing factory so we capture the state-machine instance + // and can read topology directly from it. + base := metadata.NewMetadataStateMachineFactory(zap.NewNop()) + factory := func(clusterID, nodeID uint64) statemachine.IStateMachine { + sm := base(clusterID, nodeID) + if msm, ok := sm.(*metadata.MetadataStateMachine); ok { + obs.sm = msm + } + return sm + } + + rc := raftconfig.Config{ + ReplicaID: cfg.NodeID, + ShardID: metadata.MetadataShardID, + ElectionRTT: 10, + HeartbeatRTT: 1, + CheckQuorum: false, // non-voting observers must not require quorum + SnapshotEntries: 5, + CompactionOverhead: 5, + } + + // join=true means "I'm an already-registered member". We register via + // the JoinMetadata RPC below; on restart the in-memory state is empty + // so we always re-register. + if err := nh.StartReplica(nil, true, factory, rc); err != nil { + nh.Close() + return nil, fmt.Errorf("futureq: dragonboat observer: start replica: %w", err) + } + + // Register with the cluster as a non-voting observer of the metadata + // group. The seedDial callback performs the actual JoinMetadata RPC. + if err := seedDial(ctx); err != nil { + nh.Close() + return nil, fmt.Errorf("futureq: dragonboat observer: join metadata: %w", err) + } + + obs.wg.Add(1) + go obs.watch() + + return obs, nil +} + +// watch polls the state machine for the tracked shard's topology and +// forwards changes to the tracker. Applied-Index polling is cheap (an +// in-memory map read) and avoids the complexity of registering a custom +// listener; Dragonboat has already delivered the entry to the SM by the +// time we observe the new value. +func (o *metadataObserver) watch() { + defer o.wg.Done() + + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + + var lastLeader uint64 + var lastEpoch uint64 + + push := func() { + sm := o.sm + if sm == nil { + return + } + topo := sm.GetShardTopology(o.cfg.ShardID) + if topo == nil { + return + } + if topo.LeaderID == lastLeader && topo.Epoch == lastEpoch { + return + } + lastLeader = topo.LeaderID + lastEpoch = topo.Epoch + + snap := Topology{ + LeaderNodeID: topo.LeaderID, + LeaderAddress: topo.LeaderAddr, + UpdatedAt: time.Now(), + } + for id, addr := range topo.Nodes { + grpc := topo.GrpcAddrs[id] + if grpc == "" { + grpc = addr + } + snap.Nodes = append(snap.Nodes, NodeInfo{ + NodeID: id, + Address: grpc, + IsLeader: id == topo.LeaderID, + IsAlive: true, + }) + } + o.track.setLeader(topo.LeaderID, topo.LeaderAddr, snap) + + if o.cfg.OnTopologyChange != nil { + o.cfg.OnTopologyChange(topo) + } + } + + for { + select { + case <-o.done: + return + case <-ticker.C: + push() + } + } +} + +// Close shuts the observer down, leaving the metadata group first so the +// brokers stop replicating to us, then closing the NodeHost. The VFS is +// in-memory, so no cleanup on disk is required. +func (o *metadataObserver) Close() error { + o.mu.Lock() + if o.closed { + o.mu.Unlock() + return nil + } + o.closed = true + o.mu.Unlock() + + close(o.done) + o.wg.Wait() + + // Best-effort: tell the cluster we're leaving. Uses a short timeout so + // Close never hangs when the cluster is unreachable. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = o.nh.SyncRequestDeleteReplica(ctx, metadata.MetadataShardID, o.cfg.NodeID, 0) + + o.nh.Close() + return nil +} + +// observerNodeIDCounter hands out unique node IDs for observers that did +// not specify one. We start above 1<<32 to keep the observer ID space well +// clear of typical broker IDs (1, 2, 3, …). +var observerNodeIDCounter atomic.Uint64 + +func newObserverNodeID() uint64 { + n := observerNodeIDCounter.Add(1) + return (1 << 32) | (n & 0xffff) +} diff --git a/go/futureq/producer.go b/go/futureq/producer.go index 3a3733c..94c9ff7 100644 --- a/go/futureq/producer.go +++ b/go/futureq/producer.go @@ -15,95 +15,146 @@ import ( pb "github.com/futureq-io/protocol/proto/go" ) -// Producer schedules messages for future delivery on a FutureQ server. +// Producer publishes batches of messages to the FutureQ cluster. // -// Internally it maintains a single long-lived gRPC bi-directional streaming -// RPC ([FutureQProducer.PublishStream]). Sends and receives on this stream -// are multiplexed safely across goroutines using an internal mutex. +// Internally it maintains a long-lived bi-directional gRPC stream +// ([FutureQProducer.PublishStream]) connected to the current Raft leader. +// When the leader changes (a topology event delivered by the SDK's +// discovery mechanism), the producer tears down the stream and re-dials +// the new leader automatically. // -// Create a Producer via [Client.NewProducer]. A Producer must be closed with -// [Producer.Close] when no longer needed to release server-side resources. +// Create a Producer via [Client.NewProducer]. A Producer must be closed +// with [Producer.Close] when no longer needed. // // A Producer is safe for concurrent use by multiple goroutines. type Producer struct { - stream grpc.BidiStreamingClient[pb.StreamPublishRequest, pb.StreamPublishAck] - mu sync.Mutex - closed bool - timeout time.Duration + client *Client + cfg producerConfig + + mu sync.Mutex + stream grpc.BidiStreamingClient[pb.PublishBatch, pb.PublishBatchAck] + conn *grpc.ClientConn + // leaderAddr records which address the current stream is connected to, + // so reconnect() can detect when a re-dial is actually needed. + leaderAddr string + closed bool } // ProducerOption is a functional option for [Client.NewProducer]. type ProducerOption func(*producerConfig) type producerConfig struct { - // publishTimeout is the per-publish operation timeout for waiting for the - // server ACK. Defaults to 10 seconds. + // publishTimeout is the per-batch deadline for the entire + // send-and-wait-for-ack round trip. Defaults to 10 s. publishTimeout time.Duration } func defaultProducerConfig() producerConfig { - return producerConfig{ - publishTimeout: 10 * time.Second, - } + return producerConfig{publishTimeout: 10 * time.Second} } -// WithPublishTimeout sets the maximum duration to wait for a server ACK after -// sending a single message. If the server does not respond within this window, -// Publish returns a timeout error. Defaults to 10 seconds. +// WithPublishTimeout sets the maximum duration to wait for a server ACK +// after sending a single batch. Defaults to 10 s. func WithPublishTimeout(d time.Duration) ProducerOption { - return func(c *producerConfig) { - c.publishTimeout = d - } + return func(c *producerConfig) { c.publishTimeout = d } } -// NewProducer opens a bidirectional streaming RPC to the FutureQ server and +// NewProducer opens a publish stream to the current cluster leader and // returns a ready [Producer]. // -// The context controls the lifetime of the underlying stream. Cancel it (or -// let it expire) to tear down the stream asynchronously; the producer will -// return [ErrStreamClosed] on the next [Producer.Publish] call. -// -// producer, err := client.NewProducer(ctx, futureq.WithPublishTimeout(5*time.Second)) +// The context controls the lifetime of the underlying stream. Cancel it +// to tear the stream down asynchronously; subsequent [Producer.PublishBatch] +// calls will re-establish the stream against the then-current leader. func (c *Client) NewProducer(ctx context.Context, opts ...ProducerOption) (*Producer, error) { cfg := defaultProducerConfig() for _, opt := range opts { opt(&cfg) } + p := &Producer{client: c, cfg: cfg} + if err := p.reconnect(ctx); err != nil { + return nil, err + } + return p, nil +} + +// reconnect establishes a fresh stream to the current leader. If the +// existing stream is already on the current leader, reconnect is a no-op. +// The caller must hold p.mu. +func (p *Producer) reconnect(ctx context.Context) error { + addr, _ := p.client.tracker.Leader() + if addr == "" { + if _, err := p.client.tracker.refreshOnce(ctx); err != nil { + return ErrNoLeader + } + addr, _ = p.client.tracker.Leader() + if addr == "" { + return ErrNoLeader + } + } + if p.stream != nil && p.leaderAddr == addr { + return nil + } - client := pb.NewFutureQProducerClient(c.conn) + p.closeStreamLocked() - stream, err := client.PublishStream(ctx) + conn, err := p.client.tracker.dial(ctx, addr) if err != nil { - return nil, fmt.Errorf("futureq: open producer stream: %w", err) + return fmt.Errorf("futureq: dial leader %s: %w", addr, err) } + cli := pb.NewFutureQProducerClient(conn) + stream, err := cli.PublishStream(ctx) + if err != nil { + conn.Close() + return fmt.Errorf("futureq: open producer stream: %w", err) + } + p.conn = conn + p.stream = stream + p.leaderAddr = addr + return nil +} + +// closeStreamLocked tears down the current stream, if any. +// The caller must hold p.mu. +func (p *Producer) closeStreamLocked() { + if p.stream != nil { + _ = p.stream.CloseSend() + p.stream = nil + } + if p.conn != nil { + _ = p.conn.Close() + p.conn = nil + } + p.leaderAddr = "" +} - return &Producer{ - stream: stream, - timeout: cfg.publishTimeout, - }, nil +// Publish schedules a single message. It is equivalent to +// [Producer.PublishBatch] with a one-element slice. +func (p *Producer) Publish(ctx context.Context, msg Message) error { + return p.PublishBatch(ctx, []Message{msg}, AckQuorum) } -// Publish schedules a [Message] for future delivery and blocks until the -// server acknowledges the write. +// PublishBatch schedules msgs atomically as a single Raft log entry and +// blocks until the broker acknowledges the batch. // -// The method is safe for concurrent use; multiple goroutines may call Publish -// on the same Producer simultaneously. +// ackLevel selects the durability guarantee: +// - [AckQuorum] — the broker waits until the batch is replicated to a +// quorum of Raft voters before acknowledging. Safest; default. +// - [AckNone] — the broker acknowledges immediately. Fastest; messages +// may be lost if the leader crashes before replicating. // // Possible errors: // - [ErrClosed] — the Producer has been closed. -// - [ErrNotLeader] — the server node is not the Raft leader. -// - [ErrPublishFailed] (via [errors.As]) — the server persisted the request -// but returned an application error; inspect [PublishError.ServerMessage]. -// - A gRPC status error — e.g. codes.Unavailable if the server is down. -// -// Example: -// -// err := producer.Publish(ctx, futureq.Message{ -// Topic: "email-notifications", -// Payload: []byte(`{"to":"user@example.com"}`), -// ExecuteAt: time.Now().Add(10 * time.Minute), -// }) -func (p *Producer) Publish(ctx context.Context, msg Message) error { +// - [ErrNoLeader] — the SDK does not currently know of a live leader. +// - [ErrNotLeader] — the connected node is no longer the leader (the +// SDK reconnects to the new leader before returning, so the next call +// usually succeeds). +// - [PublishError] via [errors.As] — the broker acknowledged the batch +// but reported an application-level failure. +func (p *Producer) PublishBatch(ctx context.Context, msgs []Message, ackLevel AckLevel) error { + if len(msgs) == 0 { + return nil + } + p.mu.Lock() defer p.mu.Unlock() @@ -111,213 +162,148 @@ func (p *Producer) Publish(ctx context.Context, msg Message) error { return ErrClosed } - req := &pb.StreamPublishRequest{ - Topic: msg.Topic, - Payload: msg.Payload, - ExecuteAtUnixMs: msg.ExecuteAt.UnixMilli(), + if err := p.reconnect(ctx); err != nil { + return err } - // Apply the publish timeout on top of any deadline already in ctx. - sendCtx, cancel := context.WithTimeout(ctx, p.timeout) - defer cancel() + frame := &pb.PublishBatch{ + Messages: make([]*pb.PublishMessage, len(msgs)), + AckLevel: toProtoAckLevel(ackLevel), + } + for i, m := range msgs { + frame.Messages[i] = toProtoMessage(m) + } - // Send is blocking; we wrap it in a goroutine so we can respect sendCtx. - type sendResult struct{ err error } - sendCh := make(chan sendResult, 1) - go func() { - sendCh <- sendResult{err: p.stream.Send(req)} - }() + callCtx, cancel := context.WithTimeout(ctx, p.cfg.publishTimeout) + defer cancel() - select { - case <-sendCtx.Done(): - return fmt.Errorf("futureq: publish send: %w", sendCtx.Err()) - case res := <-sendCh: - if res.err != nil { - if res.err == io.EOF { - return ErrStreamClosed - } - return fmt.Errorf("futureq: publish send: %w", res.err) - } + if err := sendFrame(callCtx, p.stream, frame); err != nil { + // Force reconnect on next call. + p.closeStreamLocked() + return wrapStreamErr("publish send", err) } - // Wait for the server ACK. - type recvResult struct { - ack *pb.StreamPublishAck - err error + ack, err := recvAck(callCtx, p.stream) + if err != nil { + p.closeStreamLocked() + return wrapStreamErr("publish recv ack", err) } - recvCh := make(chan recvResult, 1) - go func() { - ack, err := p.stream.Recv() - recvCh <- recvResult{ack: ack, err: err} - }() - - select { - case <-sendCtx.Done(): - return fmt.Errorf("futureq: publish recv ack: %w", sendCtx.Err()) - case res := <-recvCh: - if res.err != nil { - if res.err == io.EOF { - return ErrStreamClosed - } - return fmt.Errorf("futureq: publish recv ack: %w", res.err) - } - if !res.ack.GetSuccess() { - msg := res.ack.GetErrorMessage() - if strings.Contains(msg, "not the cluster leader") { - return ErrNotLeader - } - return &PublishError{ServerMessage: msg} + if !ack.GetSuccess() { + msg := ack.GetErrorMessage() + if strings.Contains(msg, "not the cluster leader") { + p.closeStreamLocked() + return ErrNotLeader } + return &PublishError{ServerMessage: msg} } - return nil } -// PublishBatch schedules multiple messages atomically and collects per-message -// acknowledgements. It returns a [BatchResult] that maps each message index -// to its error (nil meaning success). -// -// PublishBatch is optimised for throughput: it sends all messages before -// reading ACKs, which reduces round-trip latency on high-latency links. -// -// The batch is sent under a single mutex acquisition, so no other Publish call -// can interleave between the sends. +// Close gracefully closes the producer stream. After Close returns, +// further calls to [Producer.Publish] / [Producer.PublishBatch] return +// [ErrClosed]. // -// results, err := producer.PublishBatch(ctx, []futureq.Message{ -// {Topic: "t", Payload: []byte("a"), ExecuteAt: time.Now().Add(1*time.Minute)}, -// {Topic: "t", Payload: []byte("b"), ExecuteAt: time.Now().Add(2*time.Minute)}, -// }) -// if err != nil { -// // transport-level error -// } -// for i, e := range results.Errors { -// if e != nil { -// fmt.Printf("message %d failed: %v\n", i, e) -// } -// } -// func (p *Producer) PublishBatch(ctx context.Context, msgs []Message) (BatchResult, error) { -// if len(msgs) == 0 { -// return BatchResult{}, nil -// } - -// p.mu.Lock() -// defer p.mu.Unlock() - -// if p.closed { -// return BatchResult{}, ErrClosed -// } - -// // Apply batch timeout on top of any deadline already in ctx. -// // Scale the timeout with the number of messages. -// batchTimeout := p.timeout + time.Duration(len(msgs))*10*time.Millisecond -// batchCtx, cancel := context.WithTimeout(ctx, batchTimeout) -// defer cancel() - -// // Serialise the requests up-front so we can fail fast on marshal errors -// // without partially sending the batch. -// reqs := make([]*pb.StreamPublishRequest, len(msgs)) -// for i, m := range msgs { -// reqs[i] = &pb.StreamPublishRequest{ -// Topic: m.Topic, -// Payload: m.Payload, -// ExecuteAtUnixMs: m.ExecuteAt.UnixMilli(), -// } -// } - -// // Send phase -// for _, req := range reqs { -// if err := batchCtx.Err(); err != nil { -// return BatchResult{}, fmt.Errorf("futureq: batch send cancelled: %w", err) -// } -// if err := p.stream.Send(req); err != nil { -// if err == io.EOF { -// return BatchResult{}, ErrStreamClosed -// } -// return BatchResult{}, fmt.Errorf("futureq: batch send: %w", err) -// } -// } - -// // Receive phase — one ACK per sent message (server guarantees order). -// result := BatchResult{Errors: make([]error, len(msgs))} -// for i := range msgs { -// if err := batchCtx.Err(); err != nil { -// return result, fmt.Errorf("futureq: batch recv ack cancelled at index %d: %w", i, err) -// } - -// ack, err := p.stream.Recv() -// if err != nil { -// if err == io.EOF { -// return result, ErrStreamClosed -// } -// return result, fmt.Errorf("futureq: batch recv ack at index %d: %w", i, err) -// } - -// if !ack.GetSuccess() { -// serverMsg := ack.GetErrorMessage() -// if strings.Contains(serverMsg, "not the cluster leader") { -// result.Errors[i] = ErrNotLeader -// } else { -// result.Errors[i] = &PublishError{ServerMessage: serverMsg} -// } -// } -// } - -// return result, nil -// } - -// Close gracefully closes the producer stream, flushing any pending messages. -// After Close returns, further calls to [Producer.Publish] return [ErrClosed]. -// -// It is safe to call Close more than once; subsequent calls are no-ops. +// Safe to call more than once. func (p *Producer) Close() error { p.mu.Lock() defer p.mu.Unlock() - if p.closed { return nil } p.closed = true + p.closeStreamLocked() + return nil +} - if err := p.stream.CloseSend(); err != nil { - // Ignore EOF — the server has already closed its side. - if err == io.EOF { - return nil - } - st, ok := status.FromError(err) - if ok && (st.Code() == codes.Canceled || st.Code() == codes.Unavailable) { - return nil +// ─── helpers ────────────────────────────────────────────────────────────── + +// toProtoAckLevel converts the SDK enum to the wire enum. +func toProtoAckLevel(a AckLevel) pb.AckLevel { + if a == AckNone { + return pb.AckLevel_ACK_LEVEL_NO_ACK + } + return pb.AckLevel_ACK_LEVEL_QUORUM +} + +// toProtoMessage converts an SDK [Message] into the wire [pb.PublishMessage]. +func toProtoMessage(m Message) *pb.PublishMessage { + pm := &pb.PublishMessage{ + Topic: m.Topic, + Payload: m.Payload, + } + if m.Delay > 0 { + pm.DelayMs = m.Delay.Milliseconds() + } + if m.TTL > 0 { + pm.TtlMs = m.TTL.Milliseconds() + } + if len(m.Indexes) > 0 { + pm.Indexes = make([]*pb.Index, len(m.Indexes)) + for i, idx := range m.Indexes { + if idx.IsString { + pm.Indexes[i] = &pb.Index{Value: &pb.Index_StringValue{StringValue: idx.StringValue}} + } else { + pm.Indexes[i] = &pb.Index{Value: &pb.Index_Int64Value{Int64Value: idx.Int64Value}} + } } - return fmt.Errorf("futureq: close producer: %w", err) } - return nil + return pm } -// BatchResult holds the per-message outcomes of a [Producer.PublishBatch] call. -type BatchResult struct { - // Errors is a slice parallel to the input messages slice. - // Errors[i] is nil when message i was acknowledged successfully, or a - // non-nil error describing why message i was rejected. - Errors []error +// sendFrame writes one frame to the stream, honouring ctx. +func sendFrame( + ctx context.Context, + stream grpc.BidiStreamingClient[pb.PublishBatch, pb.PublishBatchAck], + frame *pb.PublishBatch, +) error { + type result struct{ err error } + ch := make(chan result, 1) + go func() { ch <- result{err: stream.Send(frame)} }() + select { + case <-ctx.Done(): + return ctx.Err() + case r := <-ch: + return r.err + } } -// HasErrors reports whether any message in the batch was rejected. -func (r BatchResult) HasErrors() bool { - for _, e := range r.Errors { - if e != nil { - return true - } +// recvAck reads one ack frame from the stream, honouring ctx. +func recvAck( + ctx context.Context, + stream grpc.BidiStreamingClient[pb.PublishBatch, pb.PublishBatchAck], +) (*pb.PublishBatchAck, error) { + type result struct { + ack *pb.PublishBatchAck + err error + } + ch := make(chan result, 1) + go func() { + ack, err := stream.Recv() + ch <- result{ack: ack, err: err} + }() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case r := <-ch: + return r.ack, r.err } - return false } -// FailedIndices returns the indices of messages that were rejected. -func (r BatchResult) FailedIndices() []int { - var out []int - for i, e := range r.Errors { - if e != nil { - out = append(out, i) +// wrapStreamErr converts low-level send/recv errors into SDK sentinels. +func wrapStreamErr(op string, err error) error { + if err == nil { + return nil + } + if err == io.EOF { + return ErrStreamClosed + } + st, ok := status.FromError(err) + if ok { + switch st.Code() { + case codes.Canceled, codes.Unavailable: + return ErrStreamClosed } } - return out + return fmt.Errorf("futureq: %s: %w", op, err) } diff --git a/go/futureq/retry.go b/go/futureq/retry.go index 352a997..3f4bf3f 100644 --- a/go/futureq/retry.go +++ b/go/futureq/retry.go @@ -10,32 +10,32 @@ import ( "google.golang.org/grpc/status" ) -// RetryPolicy configures automatic retry behaviour for [Producer.PublishWithRetry]. +// RetryPolicy configures automatic retry behaviour for +// [Producer.PublishBatchWithRetry]. // -// Zero values are not meaningful; use [DefaultRetryPolicy] as a baseline and -// adjust individual fields as needed. +// Zero values are not meaningful; use [DefaultRetryPolicy] as a baseline +// and adjust individual fields as needed. type RetryPolicy struct { - // MaxAttempts is the maximum number of times to attempt publishing a - // message, including the initial attempt. A value of 1 means no retries. + // MaxAttempts is the maximum number of publish attempts, including the + // initial one. A value of 1 means no retries. MaxAttempts int // InitialBackoff is the duration to wait before the first retry. InitialBackoff time.Duration - // MaxBackoff caps the exponential back-off. Jitter is applied on top. + // MaxBackoff caps the exponential back-off. Jitter is applied on top. MaxBackoff time.Duration // Multiplier is the factor by which the backoff grows on each attempt. - // A value of 2.0 doubles the delay each time. Multiplier float64 - // RetryableFunc is an optional predicate that determines whether a given - // error should trigger a retry. If nil, [DefaultRetryable] is used. + // RetryableFunc is an optional predicate that determines whether a + // given error should trigger a retry. If nil, [DefaultRetryable] is used. RetryableFunc func(err error) bool } -// DefaultRetryPolicy returns a RetryPolicy suitable for most production use -// cases: three attempts with exponential backoff starting at 100 ms. +// DefaultRetryPolicy returns a RetryPolicy suitable for most production +// use cases: three attempts with exponential backoff starting at 100 ms. func DefaultRetryPolicy() RetryPolicy { return RetryPolicy{ MaxAttempts: 3, @@ -45,18 +45,26 @@ func DefaultRetryPolicy() RetryPolicy { } } -// DefaultRetryable is the default predicate used by [PublishWithRetry]. -// It returns true for transient errors (network timeouts, Unavailable) and -// false for permanent errors like [ErrNotLeader] or [ErrPublishFailed]. +// DefaultRetryable is the default predicate used by +// [Producer.PublishBatchWithRetry]. +// +// It retries transient errors (network timeouts, leader changes, +// Unavailable) but never permanent application errors like +// [ErrPublishFailed]. func DefaultRetryable(err error) bool { if err == nil { return false } - // Never retry permanent application errors. - if errors.Is(err, ErrNotLeader) || errors.Is(err, ErrPublishFailed) || errors.Is(err, ErrClosed) { + // Leader / discovery errors are always worth retrying — the SDK + // reconnects under the hood and the next attempt usually lands on the + // new leader. + if errors.Is(err, ErrNoLeader) || errors.Is(err, ErrNotLeader) || errors.Is(err, ErrStreamClosed) { + return true + } + // Never retry permanent errors. + if errors.Is(err, ErrPublishFailed) || errors.Is(err, ErrClosed) { return false } - // Retry on gRPC transient status codes. st, ok := status.FromError(err) if ok { switch st.Code() { @@ -67,20 +75,22 @@ func DefaultRetryable(err error) bool { return false } -// PublishWithRetry attempts to publish msg up to policy.MaxAttempts times, -// pausing between attempts according to the exponential back-off defined in -// policy. +// PublishBatchWithRetry attempts to publish msgs up to +// policy.MaxAttempts times, pausing between attempts according to the +// exponential back-off defined in policy. // -// It is the caller's responsibility to ensure that the context has a deadline +// It is the caller's responsibility to ensure the context has a deadline // encompassing all attempts. // -// If all attempts fail, PublishWithRetry returns the error from the last -// attempt. -// // policy := futureq.DefaultRetryPolicy() // policy.MaxAttempts = 5 -// err := producer.PublishWithRetry(ctx, msg, policy) -func (p *Producer) PublishWithRetry(ctx context.Context, msg Message, policy RetryPolicy) error { +// err := producer.PublishBatchWithRetry(ctx, msgs, futureq.AckQuorum, policy) +func (p *Producer) PublishBatchWithRetry( + ctx context.Context, + msgs []Message, + ackLevel AckLevel, + policy RetryPolicy, +) error { isRetryable := policy.RetryableFunc if isRetryable == nil { isRetryable = DefaultRetryable @@ -90,26 +100,21 @@ func (p *Producer) PublishWithRetry(ctx context.Context, msg Message, policy Ret var lastErr error for attempt := 0; attempt < policy.MaxAttempts; attempt++ { - err := p.Publish(ctx, msg) + err := p.PublishBatch(ctx, msgs, ackLevel) if err == nil { return nil } - lastErr = err if !isRetryable(err) { return err } if attempt < policy.MaxAttempts-1 { - // Apply jitter: actual sleep is [0.5 * backoff, 1.5 * backoff]. - sleep := backoff select { case <-ctx.Done(): return ctx.Err() - case <-time.After(sleep): + case <-time.After(backoff): } - - // Grow the backoff for the next iteration, capped at MaxBackoff. next := time.Duration(float64(backoff) * policy.Multiplier) backoff = time.Duration(math.Min(float64(next), float64(policy.MaxBackoff))) } @@ -117,3 +122,13 @@ func (p *Producer) PublishWithRetry(ctx context.Context, msg Message, policy Ret return lastErr } + +// PublishWithRetry is the single-message equivalent of +// [Producer.PublishBatchWithRetry]. +func (p *Producer) PublishWithRetry( + ctx context.Context, + msg Message, + policy RetryPolicy, +) error { + return p.PublishBatchWithRetry(ctx, []Message{msg}, AckQuorum, policy) +} diff --git a/go/futureq/topology.go b/go/futureq/topology.go new file mode 100644 index 0000000..b534ed2 --- /dev/null +++ b/go/futureq/topology.go @@ -0,0 +1,240 @@ +package futureq + +import ( + "context" + "sync" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + pb "github.com/futureq-io/protocol/proto/go" +) + +// Topology is an immutable snapshot of the cluster's view of itself. +type Topology struct { + // LeaderNodeID is the Raft node ID of the current leader, or 0 if unknown. + LeaderNodeID uint64 + + // LeaderAddress is the gRPC address clients should dial for producing + // and consuming. Empty when unknown. + LeaderAddress string + + // Nodes lists every known cluster member. + Nodes []NodeInfo + + // UpdatedAt is when this snapshot was recorded locally. + UpdatedAt time.Time +} + +// NodeInfo describes a single cluster member. +type NodeInfo struct { + NodeID uint64 + Address string + IsLeader bool + IsAlive bool +} + +// topologyTracker tracks the current cluster leader and routes producer / +// consumer streams to it. It is refreshed either by polling GetClusterInfo +// against the seed addresses, or (when [WithDragonboatDiscovery] is on) by +// a local Dragonboat observer that receives topology changes over Raft. +// +// The zero value is not usable; construct via newTopologyTracker. +type topologyTracker struct { + mu sync.RWMutex + leaderAddr string + leaderID uint64 + last Topology + haveSnap bool + seedAddrs []string + refreshFreq time.Duration + dial func(ctx context.Context, addr string) (*grpc.ClientConn, error) + + // notifyCh receives a signal every time the leader address changes. + // Producers and consumers listen on this to tear down and re-dial. + notifyCh chan struct{} + + // cancel stops the background refresh loop. + cancel context.CancelFunc + wg sync.WaitGroup +} + +func newTopologyTracker( + seeds []string, + refreshFreq time.Duration, + dial func(ctx context.Context, addr string) (*grpc.ClientConn, error), +) *topologyTracker { + cp := make([]string, len(seeds)) + copy(cp, seeds) + return &topologyTracker{ + seedAddrs: cp, + refreshFreq: refreshFreq, + dial: dial, + notifyCh: make(chan struct{}, 1), + } +} + +// Leader returns the current best-known leader address, or "" when unknown. +func (t *topologyTracker) Leader() (addr string, nodeID uint64) { + t.mu.RLock() + defer t.mu.RUnlock() + return t.leaderAddr, t.leaderID +} + +// Snapshot returns the most recent topology snapshot. +// The second return value is false when no snapshot has been received yet. +func (t *topologyTracker) Snapshot() (Topology, bool) { + t.mu.RLock() + defer t.mu.RUnlock() + return t.last, t.haveSnap +} + +// Addresses returns the configured seed addresses. +func (t *topologyTracker) Addresses() []string { + t.mu.RLock() + defer t.mu.RUnlock() + out := make([]string, len(t.seedAddrs)) + copy(out, t.seedAddrs) + return out +} + +// Notify returns a channel that receives a value every time the leader +// address changes. The channel has buffer 1, so slow consumers never +// block the tracker. +func (t *topologyTracker) Notify() <-chan struct{} { return t.notifyCh } + +// setLeader atomically records a new leader address. When the address +// differs from the previous one, a notification is broadcast on Notify. +func (t *topologyTracker) setLeader(nodeID uint64, addr string, topo Topology) { + t.mu.Lock() + changed := addr != "" && (addr != t.leaderAddr || nodeID != t.leaderID) + t.leaderAddr = addr + t.leaderID = nodeID + t.last = topo + t.haveSnap = true + t.mu.Unlock() + + if changed { + select { + case t.notifyCh <- struct{}{}: + default: + } + } +} + +// start launches the background refresh loop. The loop polls GetClusterInfo +// on each seed address in turn until one responds, then applies the result. +// It is a no-op when refreshFreq <= 0 (pure dragonboat mode with no polling). +func (t *topologyTracker) start(ctx context.Context) { + if t.refreshFreq <= 0 { + return + } + pollCtx, cancel := context.WithCancel(ctx) + t.cancel = cancel + t.wg.Add(1) + go func() { + defer t.wg.Done() + tick := time.NewTicker(t.refreshFreq) + defer tick.Stop() + for { + select { + case <-pollCtx.Done(): + return + case <-tick.C: + _, _ = t.refreshOnce(pollCtx) + } + } + }() +} + +// stop terminates the background refresh loop and blocks until it exits. +func (t *topologyTracker) stop() { + if t.cancel != nil { + t.cancel() + } + t.wg.Wait() +} + +// refreshOnce polls the seeds once and applies the first successful +// response. Returns the fetched Topology on success. +func (t *topologyTracker) refreshOnce(ctx context.Context) (Topology, error) { + t.mu.RLock() + seeds := make([]string, len(t.seedAddrs)) + copy(seeds, t.seedAddrs) + t.mu.RUnlock() + + if len(seeds) == 0 { + return Topology{}, ErrTopologyUnavailable + } + + var lastErr error + for _, addr := range seeds { + topo, err := t.fetchClusterInfo(ctx, addr) + if err != nil { + lastErr = err + continue + } + t.setLeader(topo.LeaderNodeID, topo.LeaderAddress, topo) + return topo, nil + } + if lastErr == nil { + lastErr = ErrTopologyUnavailable + } + return Topology{}, lastErr +} + +// fetchClusterInfo dials addr and calls GetClusterInfo. +func (t *topologyTracker) fetchClusterInfo(ctx context.Context, addr string) (Topology, error) { + callCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + conn, err := t.dial(callCtx, addr) + if err != nil { + return Topology{}, err + } + defer conn.Close() + + cli := pb.NewFutureQClusterClient(conn) + resp, err := cli.GetClusterInfo(callCtx, &pb.ClusterInfoRequest{}) + if err != nil { + return Topology{}, err + } + + topo := Topology{ + LeaderNodeID: resp.GetLeaderNodeId(), + LeaderAddress: resp.GetLeaderAddress(), + UpdatedAt: time.Now(), + Nodes: make([]NodeInfo, 0, len(resp.GetNodes())), + } + for _, n := range resp.GetNodes() { + topo.Nodes = append(topo.Nodes, NodeInfo{ + NodeID: n.GetNodeId(), + Address: n.GetAddress(), + IsLeader: n.GetIsLeader(), + IsAlive: n.GetIsAlive(), + }) + } + return topo, nil +} + +// isUnavailableOrNotLeader reports whether an error returned by a +// producer/consumer stream is worth triggering a topology refresh. +func isUnavailableOrNotLeader(err error) bool { + if err == nil { + return false + } + if err == ErrNotLeader || err == ErrNoLeader || err == ErrStreamClosed { + return true + } + st, ok := status.FromError(err) + if !ok { + return false + } + switch st.Code() { + case codes.Unavailable, codes.Canceled, codes.DeadlineExceeded: + return true + } + return false +} diff --git a/go/go.mod b/go/go.mod index d28c832..530fa6b 100644 --- a/go/go.mod +++ b/go/go.mod @@ -3,14 +3,58 @@ module github.com/futureq-io/sdk/go go 1.26.2 require ( - github.com/futureq-io/protocol/proto/go v0.0.1 - google.golang.org/grpc v1.81.1 + github.com/futureq-io/futureq v0.0.0 + github.com/futureq-io/protocol/proto/go v0.1.9 // MUST USE THIS + github.com/lni/dragonboat/v4 v4.0.0-20250723143628-076c7f6497dc + google.golang.org/grpc v1.82.1 ) require ( - golang.org/x/net v0.51.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.34.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + github.com/lni/vfs v0.2.1-0.20220616104132-8852fd867376 + go.uber.org/zap v1.28.0 +) + +require ( + github.com/DataDog/zstd v1.5.7 // indirect + github.com/HdrHistogram/hdrhistogram-go v1.1.2 // indirect + github.com/VictoriaMetrics/metrics v1.18.1 // indirect + github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cockroachdb/errors v1.11.3 // indirect + github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect + github.com/cockroachdb/pebble v0.0.0-20221207173255-0f086d933dac // indirect + github.com/cockroachdb/redact v1.1.5 // indirect + github.com/getsentry/sentry-go v0.27.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e // indirect + github.com/google/btree v1.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-immutable-radix v1.0.0 // indirect + github.com/hashicorp/go-msgpack v0.5.3 // indirect + github.com/hashicorp/go-multierror v1.0.0 // indirect + github.com/hashicorp/go-sockaddr v1.0.0 // indirect + github.com/hashicorp/golang-lru v0.5.1 // indirect + github.com/hashicorp/memberlist v0.3.1 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/lni/goutils v1.4.0 // indirect + github.com/miekg/dns v1.1.26 // indirect + github.com/pierrec/lz4/v4 v4.1.14 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect + github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect + github.com/valyala/fastrand v1.1.0 // indirect + github.com/valyala/histogram v1.2.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) + +replace github.com/futureq-io/futureq => ../../futureq-v2 diff --git a/go/go.sum b/go/go.sum index 3728a78..3d85fda 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,17 +1,284 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/CloudyKit/fastprinter v0.0.0-20170127035650-74b38d55f37a/go.mod h1:EFZQ978U7x8IRnstaskI3IysnWY5Ao3QgZUKOXlsAdw= +github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible/go.mod h1:HPYO+50pSWkPoj9Q/eq0aRGByCL6ScRlUmiEX5Zgm+w= +github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= +github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= +github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM= +github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= +github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY= +github.com/Joker/jade v1.0.1-0.20190614124447-d475f43051e7/go.mod h1:6E6s8o2AE4KhCrqr6GRJjdC/gNfTdxkIXvuGZZda2VM= +github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0= +github.com/VictoriaMetrics/metrics v1.18.1 h1:OZ0+kTTto8oPfHnVAnTOoyl0XlRhRkoQrD2n2cOuRw0= +github.com/VictoriaMetrics/metrics v1.18.1/go.mod h1:ArjwVz7WpgpegX/JpB0zpNF2h2232kErkEnzH1sxMmA= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da h1:8GUt8eRujhVEGZFFEjBj46YV4rDjvGrNxb0KMWYkL2I= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/futureq-io/protocol/proto/go v0.0.1 h1:pW9bv6HjYXwUvucU2ii2y2h+749gyOU2Sk8q75iEooI= -github.com/futureq-io/protocol/proto/go v0.0.1/go.mod h1:VGAbKxcBAXSQIzqlrePjPXlomId4vUnVD8kgJ82xaGs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cockroachdb/datadriven v1.0.0/go.mod h1:5Ib8Meh+jk1RlHIXej6Pzevx/NLlNvQB9pmSBZErGA4= +github.com/cockroachdb/errors v1.6.1/go.mod h1:tm6FTP5G81vwJ5lC0SizQo374JNCOPrHyXGitRJoDqM= +github.com/cockroachdb/errors v1.8.1/go.mod h1:qGwQn6JmZ+oMjuLwjWzUNqblqk0xl4CVV3SQbGwK7Ac= +github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= +github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= +github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/pebble v0.0.0-20221207173255-0f086d933dac h1:pwyQPbghSh6PC4MgXNvMZjf19LTugkIIPUSRzAD5LEE= +github.com/cockroachdb/pebble v0.0.0-20221207173255-0f086d933dac/go.mod h1:890yq1fUb9b6dGNwssgeUO5vQV9qfXnCPxAJhBQfXw0= +github.com/cockroachdb/redact v1.0.8/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/sentry-go v0.6.1-cockroachdb.2/go.mod h1:8BT+cPK6xvFOcRlk0R8eg+OTkcqI6baNH4xAkpiYVvQ= +github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw= +github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/flosch/pongo2 v0.0.0-20190707114632-bbf5a6c351f4/go.mod h1:T9YF2M40nIgbVgp3rreNmTged+9HrbNTIQf1PsaIiTA= +github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/futureq-io/protocol/proto/go v0.1.9 h1:fMmZYi9xbbgxTHnBISChpasz2gOyDa3uEqXzfSZv+xc= +github.com/futureq-io/protocol/proto/go v0.1.9/go.mod h1:VGAbKxcBAXSQIzqlrePjPXlomId4vUnVD8kgJ82xaGs= +github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= +github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= +github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/ghemawat/stream v0.0.0-20171120220530-696b145b53b9/go.mod h1:106OIgooyS7OzLDOpUGgm9fA3bQENb/cFSyyBmMoJDs= +github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= +github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/gogo/googleapis v0.0.0-20180223154316-0cd9801be74a/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/gogo/status v1.1.0/go.mod h1:BFv9nrluPLmrS0EmGVvLaPNmRosr9KapBYd5/hpY1WM= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e h1:4bw4WeyTYPp0smaXiJZCNnLrvVBqirQVreixayXezGc= +github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3 h1:zKjpN5BK/P5lMYrLmBHdBULWbJ0XpYR+7NGzqkZzoD4= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/memberlist v0.3.1 h1:MXgUXLqva1QvpVEDQW1IQLG0wivQAtmFlHRQ+1vWZfM= +github.com/hashicorp/memberlist v0.3.1/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/hydrogen18/memlistener v0.0.0-20141126152155-54553eb933fb/go.mod h1:qEIFzExnS6016fRpRfxrExeVn2gbClQA99gQhnIcdhE= +github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI= +github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0= +github.com/iris-contrib/i18n v0.0.0-20171121225848-987a633949d0/go.mod h1:pMCz62A0xJL6I+umB2YTlFRwWXaDFA0jy+5HzGiJjqI= +github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/juju/errors v0.0.0-20181118221551-089d3ea4e4d5/go.mod h1:W54LbzXuIE0boCoNJfwqpmkKJ1O4TCTZMetAt6jGk7Q= +github.com/juju/loggo v0.0.0-20180524022052-584905176618/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U= +github.com/juju/testing v0.0.0-20180920084828-472a3e8b2073/go.mod h1:63prj8cnj0tU0S9OHjGJn+b1h0ZghCndfnbQolrYTwA= +github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= +github.com/kataras/golog v0.0.9/go.mod h1:12HJgwBIZFNGL0EJnMRhmvGA0PQGx8VFwrZtM4CqbAk= +github.com/kataras/iris/v12 v12.0.1/go.mod h1:udK4vLQKkdDqMGJJVd/msuMtN6hpYJhg/lSzuxjhO+U= +github.com/kataras/neffos v0.0.10/go.mod h1:ZYmJC07hQPW67eKuzlfY7SO3bC0mw83A3j6im82hfqw= +github.com/kataras/pio v0.0.0-20190103105442-ea782b38602d/go.mod h1:NV88laa9UiiDuX9AhMbDPkGYSPugBOV6yTZB1l2K9Z0= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.9.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.11.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/lni/dragonboat/v4 v4.0.0-20250723143628-076c7f6497dc h1:5KPn/Yn1COC7w2InNxubbp5tqEYhtNy2l/EOt103ZlE= +github.com/lni/dragonboat/v4 v4.0.0-20250723143628-076c7f6497dc/go.mod h1:X35iFANAy9OKDck7Edgi408jnSUwgaSyIbq/XZkcw7M= +github.com/lni/goutils v1.4.0 h1:e1tNN+4zsbTpNvhG5cxirkH9Pdz96QAZ2j6+5tmjvqg= +github.com/lni/goutils v1.4.0/go.mod h1:LIHvF0fflR+zyXUQFQOiHPpKANf3UIr7DFIv5CBPOoU= +github.com/lni/vfs v0.2.1-0.20220616104132-8852fd867376 h1:jX9CoRWNPwrZ2yY3RJFTSwa49qDQqtXglrCByGdQGZg= +github.com/lni/vfs v0.2.1-0.20220616104132-8852fd867376/go.mod h1:LOatfyR8Xeej1jbXybwYGVfCccR0u+BQRG9xg7BD7xo= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= +github.com/mediocregopher/mediocre-go-lib v0.0.0-20181029021733-cb65787f37ed/go.mod h1:dSsfyI2zABAdhcbvkXqgxOxrCsbYeHCPgrZkku60dSg= +github.com/mediocregopher/radix/v3 v3.3.0/go.mod h1:EmfVyvspXz1uZEyPBMyGK+kjWiKQGvsUt6O3Pj+LDCQ= +github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= +github.com/miekg/dns v1.1.26 h1:gPxPSwALAeHJSjarOs00QjVdV9QoBvc1D2ujQUr5BzU= +github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= +github.com/nats-io/nats.go v1.8.1/go.mod h1:BrFz9vVn0fU3AcH9Vn4Kd7W0NpJ651tD5omQ3M8LwxM= +github.com/nats-io/nkeys v0.0.2/go.mod h1:dab7URMsZm6Z/jp9Z5UGa87Uutgc2mVpXLC4B7TDb/4= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.13.0/go.mod h1:+REjRxOmWfHCjfv9TTWB1jD1Frx4XydAD3zm1lskyM0= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c h1:Lgl0gzECD8GnQ5QCWA8o6BtfL6mDH5rQgM4/fX3avOs= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE= +github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w= +github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8= +github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ= +github.com/valyala/histogram v1.2.0/go.mod h1:Hb4kBwb4UxsaNbbbh+RRz8ZR6pdodR57tzWUS3BUzXY= +github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= @@ -24,17 +291,162 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20200513190911-00229845015e/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210909193231-528a39cd75f3/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= +gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= +gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180518175338-11a468237815/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 h1:mJiOtnGp0k/BcSgdu03G2NwnscCfCH+h2QKUBZr18KI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= +gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= From 4807bfd6ba3948f4147bb1173b2089f1bede5cde Mon Sep 17 00:00:00 2001 From: radmehr soleimanian Date: Fri, 31 Jul 2026 00:27:28 +0330 Subject: [PATCH 2/2] add readme --- README.md | 406 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 405 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index acb0e9b..b3c6d54 100644 --- a/README.md +++ b/README.md @@ -1 +1,405 @@ -# SDKs \ No newline at end of file +# FutureQ SDK + +Official client SDKs for [FutureQ](https://github.com/futureq-io/futureq), a distributed, time-bucket-based scheduled message queue backed by Pebble and replicated via [Dragonboat](https://github.com/lni/dragonboat) Raft. + +This repository contains the Go SDK. Additional languages will be added over time. + +--- + +## Go SDK + +**Module:** `github.com/futureq-io/sdk/go` + +```bash +go get github.com/futureq-io/sdk/go@latest +``` + +Requires Go 1.26 or later. + +--- + +### Features + +- **Topology-aware routing** — the SDK tracks the cluster's Raft leader and automatically routes producer / consumer streams to it. On a leader change, streams are torn down and re-established against the new leader without application involvement. +- **Two discovery modes** — lightweight polling via `GetClusterInfo` (default), or an experimental embedded Dragonboat observer that receives push-based topology updates over Raft. +- **Atomic batch publishing** — every batch is committed as a single Raft log entry. Either the whole batch lands or none of it does. +- **Per-batch durability control** — choose between quorum-acknowledged (`AckQuorum`) and fire-and-forget (`AckNone`) per batch. +- **Delayed delivery, TTLs, secondary indexes** — schedule messages for future delivery, expire them automatically, and attach queryable indexes. +- **Consumer groups** — competing-consumer semantics within a group, fan-out across groups. +- **Safe concurrency** — `Client` and `Producer` are safe for use from multiple goroutines. `Consumer.Subscribe` supports configurable handler parallelism. + +--- + +### Quick start + +```go +package main + +import ( + "context" + "log" + "time" + + "github.com/futureq-io/sdk/go/futureq" +) + +func main() { + client, err := futureq.New( + []string{"node1.internal:9000", "node2.internal:9000"}, + futureq.WithInsecure(), + ) + if err != nil { + log.Fatal(err) + } + defer client.Close() + + ctx := context.Background() + + // ── Produce ──────────────────────────────────────────────────────── + producer, err := client.NewProducer(ctx) + if err != nil { + log.Fatal(err) + } + defer producer.Close() + + err = producer.Publish(ctx, futureq.Message{ + Topic: "email", + Payload: []byte(`{"to":"user@example.com"}`), + Delay: 5 * time.Minute, + }) + if err != nil { + log.Fatal(err) + } + + // ── Consume ──────────────────────────────────────────────────────── + consumer, err := client.NewConsumer(ctx, "email", "senders") + if err != nil { + log.Fatal(err) + } + defer consumer.Close() + + err = consumer.Subscribe(ctx, func(d futureq.Delivery) error { + log.Printf("received on %s: %s", d.Topic, d.Payload) + return nil // nil ACKs the message + }) + if err != nil { + log.Fatal(err) + } +} +``` + +--- + +### Connecting + +`futureq.New` takes a list of **seed addresses**. A seed can be any node in the cluster — leader or follower. The SDK uses `GetClusterInfo` on the seeds to discover the current leader and routes all traffic to it. + +```go +client, err := futureq.New( + []string{"node1:9000", "node2:9000", "node3:9000"}, + futureq.WithInsecure(), +) +``` + +If the first seed is unreachable the SDK falls through to the next one. At least one seed must respond during `New`, otherwise an error is returned. + +#### Transport security + +```go +// Plain-text (development). +futureq.New(seeds, futureq.WithInsecure()) + +// TLS with system root CAs (production default). +futureq.New(seeds, futureq.WithTLS(nil)) + +// TLS with a custom config (mutual TLS, custom CA pool, …). +futureq.New(seeds, futureq.WithTLS(myTLSConfig)) +``` + +#### Other dial options + +| Option | Default | Purpose | +| ----------------------------------- | ------- | ---------------------------------------- | +| `WithDialTimeout(d)` | `10s` | Initial connection timeout | +| `WithKeepAlive(time, timeout)` | `30s/10s` | HTTP/2 keep-alive | +| `WithMaxRecvMsgSize(mb)` | `16` | Max inbound message size | +| `WithMaxSendMsgSize(mb)` | `16` | Max outbound message size | +| `WithDialOptions(opts…)` | — | Extra `grpc.DialOption`s (escape hatch) | +| `WithTopologyRefreshInterval(d)` | `10s` | Polling frequency for `GetClusterInfo` | + +--- + +### Topology discovery + +The SDK must know which node is the Raft leader in order to produce and consume. Two mechanisms are provided. + +#### 1. Polling (default) + +Every `WithTopologyRefreshInterval` the SDK calls `GetClusterInfo` on a seed and updates its view of the leader. This is the right choice for almost every deployment: zero moving parts, no extra ports, no extra dependencies. + +```go +futureq.New(seeds, + futureq.WithInsecure(), + futureq.WithTopologyRefreshInterval(5*time.Second), +) +``` + +#### 2. Dragonboat discovery (experimental) + +When `WithDragonboatDiscovery` is set, the SDK embeds a **non-voting Dragonboat replica** of the FutureQ metadata Raft group inside the client process. The brokers replicate committed topology entries to this replica over Raft, so leader changes are visible to the SDK the moment they commit — no polling. + +**Guarantees:** + +- **Non-voting** — the observer never participates in elections and never counts toward commit quorum. It is a pure follower. +- **In-memory only** — the embedded replica stores its WAL, log and snapshots in a [`vfs.NewMem()`](https://pkg.go.dev/github.com/lni/vfs) filesystem. **Nothing is ever written to disk.** +- **Self-cleaning** — when `client.Close()` is called the observer sends `LeaveMetadata` and shuts its `NodeHost` down. + +```go +client, err := futureq.New( + []string{"node1:9000"}, + futureq.WithInsecure(), + futureq.WithDragonboatDiscovery(nil), // sensible defaults +) +``` + +Pass a config struct to customise the observer: + +```go +futureq.WithDragonboatDiscovery(&futureq.MetadataObserverConfig{ + NodeID: 9001, // unique across the cluster + RaftAddress: "0.0.0.0:16300", // must be reachable by brokers + ShardID: 1, // event shard to track + OnTopologyChange: func(topo *metadata.ShardTopology) { + log.Printf("new leader: %d @ %s", topo.LeaderID, topo.LeaderAddr) + }, +}) +``` + +| Field | Default | Purpose | +| ------------------ | ------------------- | ----------------------------------------------------------- | +| `NodeID` | random ≥ 2³² | Observer's Raft node ID. Must not collide with any broker. | +| `RaftAddress` | `127.0.0.1:0` | Host:port the observer's Raft transport binds. | +| `RTTMillisecond` | `50` | Expected RTT between Raft peers (ms). | +| `ShardID` | `1` | Event shard whose leader the SDK tracks. | +| `OnTopologyChange` | `nil` | Optional callback fired on every leader change. | + +> The `RaftAddress` you choose must be **reachable from the brokers** so they can replicate the metadata log to you. In containerised / NAT environments, bind to a routable interface. + +When dragonboat discovery is enabled the polling loop is disabled — the observer is push-based. + +--- + +### Producing + +#### Single message + +```go +err := producer.Publish(ctx, futureq.Message{ + Topic: "email", + Payload: []byte(`{"to":"user@example.com"}`), + Delay: 5 * time.Minute, + TTL: time.Hour, + Indexes: []futureq.Index{ + futureq.StringIndex("user:42"), + futureq.Int64Index(1001), + }, +}) +``` + +| Field | Type | Notes | +| --------- | --------------- | ------------------------------------------------------------------ | +| `Topic` | `string` | Logical channel. Consumers subscribe by topic. | +| `Payload` | `[]byte` | Opaque body. JSON / Protobuf / Avro / anything. | +| `Delay` | `time.Duration` | How long the broker waits before the message becomes deliverable. | +| `TTL` | `time.Duration` | How long the broker keeps the message before discarding it. | +| `Indexes` | `[]Index` | Secondary indexes (expensive — attach only what you query on). | + +`Delay` and `TTL` are measured from the **broker's** receive time, not the client's send time. + +#### Atomic batch + +```go +err := producer.PublishBatch(ctx, []futureq.Message{ + {Topic: "events", Payload: []byte("a")}, + {Topic: "events", Payload: []byte("b"), Delay: time.Minute}, +}, futureq.AckQuorum) +``` + +A batch is written as a **single Raft log entry** — atomic, all-or-nothing. + +#### Durability + +| `AckLevel` | Behaviour | +| ----------- | ------------------------------------------------------------------------------- | +| `AckQuorum` | Broker waits for the batch to be replicated to a quorum of voters before acking. **Default.** | +| `AckNone` | Broker acks immediately. Highest throughput; messages may be lost on leader crash. | + +#### Retries + +```go +policy := futureq.DefaultRetryPolicy() +policy.MaxAttempts = 5 + +err := producer.PublishBatchWithRetry(ctx, msgs, futureq.AckQuorum, policy) +``` + +`DefaultRetryable` retries transient errors (network failures, `ErrNoLeader`, `ErrNotLeader`, `ErrStreamClosed`) and never retries permanent ones (`ErrPublishFailed`). Supply your own `RetryableFunc` to customise. + +--- + +### Consuming + +```go +consumer, err := client.NewConsumer(ctx, + "email", // topic + "senders", // consumer group + futureq.WithConcurrency(4), + futureq.WithAckTimeout(3*time.Second), +) +if err != nil { + log.Fatal(err) +} +defer consumer.Close() + +err = consumer.Subscribe(ctx, func(d futureq.Delivery) error { + // Process d.Payload… + return nil // nil ACKs; non-nil NACKs +}) +``` + +#### Groups + +- Consumers **in the same group** compete for messages — each message is delivered to exactly one member. +- Consumers **in different groups** on the same topic each receive an independent copy (fan-out). + +#### Delivery + +```go +type Delivery struct { + Topic string + Payload []byte + EnqueuedAt time.Time // broker wall-clock when first received + Delay time.Duration // original delay requested by the producer +} +``` + +The broker assigns each delivery an opaque `deliveryTag` (the raw Pebble key). The SDK manages it internally — you never have to echo it back yourself. + +#### Acknowledgement + +- Return `nil` → **ACK**. Broker deletes the message. +- Return a non-nil `error` → **NACK**. Broker re-dispatches the message to another consumer on the next dispatcher tick. +- Panic in the handler → recovered, the message is NACKed, and the panic is logged to stderr. + +#### Concurrency + +`WithConcurrency(n)` spawns up to `n` handler goroutines. With the default `1`, messages are processed serially and in delivery order. With higher values, ordering is relaxed but throughput improves for I/O-bound handlers. + +--- + +### Error handling + +All public methods return typed errors. Use `errors.Is` to test for the sentinel errors: + +```go +switch { +case errors.Is(err, futureq.ErrNoLeader): + // SDK doesn't currently know of a live leader. Retry shortly. +case errors.Is(err, futureq.ErrNotLeader): + // Connected node lost leadership. SDK has already re-resolved. +case errors.Is(err, futureq.ErrStreamClosed): + // Underlying gRPC stream died. Producer/Consumer will redial on next call. +case errors.Is(err, futureq.ErrClosed): + // Method called on a closed Client/Producer/Consumer. +case errors.Is(err, futureq.ErrPublishFailed): + var perr *futureq.PublishError + if errors.As(err, &perr) { + log.Printf("broker rejected batch: %s", perr.ServerMessage) + } +case errors.Is(err, futureq.ErrTopologyUnavailable): + // No seed responded to GetClusterInfo. +} +``` + +--- + +### Cluster inspection + +```go +topo, ok := client.Topology() +if ok { + fmt.Printf("leader: node %d @ %s (%d nodes, updated %s ago)\n", + topo.LeaderNodeID, topo.LeaderAddress, len(topo.Nodes), + time.Since(topo.UpdatedAt).Round(time.Millisecond), + ) +} + +fmt.Println("current leader:", client.Leader()) +``` + +--- + +### Lifecycle + +```go +client, _ := futureq.New(seeds, …) + +producer, _ := client.NewProducer(ctx) +consumer, _ := client.NewConsumer(ctx, "topic", "group") + +// On shutdown: +consumer.Close() // cancels stream, drains in-flight handlers +producer.Close() // closes publish stream +client.Close() // stops discovery, shuts down observer if enabled +``` + +`Close` is idempotent on every type. + +--- + +### Concurrency model + +| Type | Safe for concurrent use? | +| ---------- | ------------------------ | +| `Client` | ✅ Yes | +| `Producer` | ✅ Yes (mutex-serialised) | +| `Consumer` | ⚠️ One goroutine may call `Subscribe` at a time | + +--- + +### Example + +See [`go/futureq/example_test.go`](./go/futureq/example_test.go) for complete, runnable examples. + +--- + +## Repository layout + +``` +. +├── go/ # Go SDK +│ ├── go.mod +│ └── futureq/ +│ ├── client.go # top-level entry point +│ ├── producer.go # publish API +│ ├── consumer.go # subscribe API +│ ├── topology.go # leader tracker +│ ├── observer.go # embedded dragonboat observer +│ ├── message.go # Message / Delivery value types +│ ├── retry.go # retry policies +│ ├── errors.go # sentinel errors +│ └── doc.go # package documentation +└── README.md +``` + +--- + +## Versioning + +This project follows [SemVer](https://semver.org/). Breaking changes to the wire protocol or the public API are only introduced in major versions. The Dragonboat observer API is **experimental** and may change in minor releases while it matures. + +--- + +## License + +MIT — see [LICENSE](./LICENSE).