leader

package
v0.0.0-...-8c930d8 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 11, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package leader runs, on one replica of a deployment at a time, the work a project registers: a loop that polls an external system, a relay that forwards an outbox, the one connection a protocol allows. Every replica campaigns for the name; the one that wins runs the work until it stops being the leader — the process shuts down, or the lease behind the leadership is lost — and the others keep campaigning, so the work moves to another replica within seconds of its leader dying.

Importing the package is what enables the elections: the elector joins the process lifecycle from init, starts once the tables are ready and stops as soon as the process begins to drain; a project that registers no work never links it.

Leadership is a lease on the primary database (see the lease package). The leader renews it every 2 seconds, retrying a renewal that failed, and gives itself up 10 seconds after the last renewal that succeeded, before the database lets another replica claim the name; a name whose leader crashed is free 15 seconds after that leader's last renewal and taken by the next campaign after that, 21 seconds after the crash at most, and a name its leader released — the process shut down, the work returned — is taken by the next campaign at once, within 6 seconds. The work runs on a context that ends the moment the tenure does, and the transactions opened on that context end with it. Work that runs on past that point — it ignores its context — would run beside the new leader's, the very thing the lease exists to rule out, so 5 seconds after the lease was lost — also while the work winds down after the process began shutting down — the process fails and exits without waiting for it: bootstrap ends Run with the failure and the orchestrator restarts the replica. A ClickHouse primary database cannot carry leases, so a registration fails the start there.

The work runs again from scratch on the replica that takes the name over, and its previous run may have been cut anywhere: what it must not repeat, it keeps in the database — the database the tenure's database.Transaction calls verify the lease against — and what it must resume, it finds there.

A registration that cannot be honored — no name, nil work, a name already taken — fails the process at startup rather than dropping the work: the name is the work's identity in every log line about it and the name of its lease.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Register

func Register(fn func(ctx context.Context) error, name string)

Register declares fn as the work named name, run on one replica of the deployment at a time. Registration belongs in package init functions: the elector starts with the process, and a registration after that panics.

fn is expected to run until its context ends. The context ends when the process begins shutting down or the lease behind the leadership is lost — the replica could not renew it for 10 seconds, or found it taken — and whatever fn does after that must stop: a database.Transaction opened on the context refuses to run once the lease is gone, a plain write and a call already on the wire do not, so fn watches the context around its own side effects. fn that has not returned 5 seconds after the lease was lost, shutting down or not, fails the process, see the package documentation. The context carries the tenure's identity — the name and a trace id of the tenure's own, see execctx — so every statement and log line the work produces is annotated with the tenure and found again from any of them; with tracing on the id is that of a root span opened at the tenure's start, the parent of every span the work's operations open, so the trace and the log are one trail.

fn that returns while still the leader — done, or failed — hands the name back: the campaign resumes after the campaign interval, on this replica or another, and fn runs again from scratch. A panic in fn is recovered and logged, and counts as a return with an error.

The framework opens a single connection to SQLite, so there a transaction of fn blocks the renewal of the lease: keep each transaction under 8 seconds, and under 5 when transactions run back to back — a longer one may hold the renewal back until the lease counts as lost, which ends the tenure; one over 10 seconds always does.

A registration that cannot be honored — no name, a name already taken, a nil fn — fails the process at startup.

Example

ExampleRegister registers leader work the way a project does, from the init function of its leader package. Every replica campaigns for the name; the one that wins runs the loop until ctx ends — the process begins shutting down, or the lease behind the leadership is lost — and another replica takes the name over: within about 6 seconds of a leader that shut down, within about 21 of one that crashed.

Each name is campaigned for on its own: two names may be led by two replicas, and nothing tells other code whether this replica leads. Work that comes round on a schedule belongs to cronjob, which already runs each instant on one replica. Registration happens once, in init: a registration made after the elector started panics, and one with no name, a nil function or a name already registered fails the process at startup.

package main

import (
	"context"
	"time"

	"github.com/hydroan/gst/leader"
	"github.com/hydroan/gst/logger"
)

func main() {
	// leader/leader.go:
	//
	//	func init() {
	//		leader.Register(followUpstream, "upstream_follower")
	//	}
	leader.Register(func(ctx context.Context) error {
		ticker := time.NewTicker(5 * time.Second)
		defer ticker.Stop()
		for {
			select {
			case <-ctx.Done():
				// The tenure ended. Returning its ending is how a tenure ends:
				// logged as a step-down with its reason, not as a failure.
				return ctx.Err()
			case <-ticker.C:
				if err := pollUpstream(ctx); err != nil && ctx.Err() == nil {
					logger.App.Warnw("polling upstream failed", "err", err)
				}
			}
		}
	}, "upstream_follower")
}

// pollUpstream reads what changed upstream.
func pollUpstream(context.Context) error { return nil }
Example (KafkaConsumer)

ExampleRegister_kafkaConsumer has one replica of the deployment consume a Kafka topic, and another take over when it goes: within about 6 seconds of a leader that shut down, within about 21 of one that crashed or lost the primary database.

The lease is what makes the consumer the only one, so the work assigns the partitions itself instead of joining a consumer group: a group would be a second coordinator, and the membership of a leader that crashed would hold the partitions until the group's session timeout — 45 seconds by default — well past the lease's.

Where the consumption stands lives in the database, written together with each record's results in one database.Transaction opened on the tenure's context. The transaction refuses to run once the lease is lost, so a replica that lost the name cannot record progress in place of the new leader, and the new leader reads the offsets back and goes on from them: no record is skipped and none is applied twice. Offsets committed to Kafka instead would be a write the lease does not check: a record the last leader applied but had not committed yet would be applied again.

The framework knows whether a replica reaches the primary database, not whether it reaches Kafka, and a poll does not fail while Kafka is out of reach, it just brings nothing. So a poll waits 30 seconds at most, and one that brought nothing pings Kafka: a replica that cannot reach it returns, which hands the name back, and waits a campaign interval before it campaigns again, so another replica usually takes the name over. Calls the work makes outside the transaction — a message produced to another topic, a call to another system — are not checked against the lease: their receivers deduplicate.

package main

import (
	"context"
	"time"

	"github.com/cockroachdb/errors"
	"github.com/hydroan/gst/config"
	"github.com/hydroan/gst/database"
	"github.com/hydroan/gst/leader"
	"github.com/hydroan/gst/provider/kafka"
	"github.com/twmb/franz-go/pkg/kgo"
)

func main() {
	leader.Register(func(ctx context.Context) error {
		offsets, err := loadConsumedOffsets(ctx, "events")
		if err != nil {
			return errors.Wrap(err, "load consumed offsets")
		}
		client, err := kafka.New(config.App.Kafka, kgo.ConsumePartitions(map[string]map[int32]kgo.Offset{"events": offsets}))
		if err != nil {
			return errors.Wrap(err, "create kafka client")
		}
		defer client.Close()

		for {
			pollCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
			fetches := client.PollFetches(pollCtx)
			cancel()
			if ctx.Err() != nil {
				// The tenure ended: stop consuming at once.
				return ctx.Err()
			}
			for _, fetchErr := range fetches.Errors() {
				// The end of the poll's own wait is not a failure.
				if !errors.Is(fetchErr.Err, context.DeadlineExceeded) {
					return errors.Wrapf(fetchErr.Err, "fetch %s partition %d", fetchErr.Topic, fetchErr.Partition)
				}
			}
			if fetches.NumRecords() == 0 {
				// A quiet topic, or Kafka out of reach: only a ping tells.
				if err := client.Ping(ctx); err != nil && ctx.Err() == nil {
					return errors.Wrap(err, "reach kafka")
				}
				continue
			}
			for _, record := range fetches.Records() {
				err := database.Transaction(ctx, func(ctx context.Context) error {
					if err := applyEvent(ctx, record.Value); err != nil {
						return err
					}
					return saveConsumedOffset(ctx, record.Topic, record.Partition, record.Offset+1)
				})
				if err != nil {
					return errors.Wrapf(err, "apply the event at %s partition %d offset %d", record.Topic, record.Partition, record.Offset)
				}
			}
		}
	}, "event_consumer")
}

// loadConsumedOffsets reads, for every partition of topic, the offset its
// consumption goes on from: the one stored after the last record applied, or
// the start of a partition never consumed.
func loadConsumedOffsets(context.Context, string) (map[int32]kgo.Offset, error) {
	return map[int32]kgo.Offset{}, nil
}

// applyEvent applies one event to the project's tables.
func applyEvent(context.Context, []byte) error { return nil }

// saveConsumedOffset stores the offset the consumption of a partition goes on
// from.
func saveConsumedOffset(context.Context, string, int32, int64) error { return nil }
Example (OutboxRelay)

ExampleRegister_outboxRelay forwards the records waiting in an outbox table. The progress lives in the database, so the replica that takes the name over picks up where the last leader stopped.

The mark that a record went out is written in a database.Transaction opened on the tenure's context: once the lease is lost, the transaction refuses to run before its first statement, so a replica that lost the name cannot mark what the new leader is handling. A write outside such a transaction is not checked against the lease, and neither is the send, a call on the wire: the loop looks at ctx before each record, and the receiver deduplicates by record ID, since a record sent just before the lease was lost is sent again by the new leader. On SQLite, where the framework opens a single connection, keep each transaction under 8 seconds, and under 5 when they run back to back: a longer one holds the lease renewal back until the lease counts as lost.

package main

import (
	"context"
	"time"

	"github.com/hydroan/gst/database"
	"github.com/hydroan/gst/leader"
	"github.com/hydroan/gst/logger"
)

func main() {
	leader.Register(func(ctx context.Context) error {
		for {
			records, err := pendingOutboxRecords(ctx, 100)
			if err != nil && ctx.Err() == nil {
				logger.App.Warnw("reading the outbox failed", "err", err)
			}
			for _, record := range records {
				if ctx.Err() != nil {
					break
				}
				if err := sendOutboxRecord(ctx, record); err != nil {
					logger.App.Warnw("sending an outbox record failed", "err", err, "record", record.ID)
					break
				}
				err := database.Transaction(ctx, func(ctx context.Context) error {
					return markOutboxRecordSent(ctx, record.ID)
				})
				if err != nil {
					logger.App.Warnw("marking an outbox record sent failed", "err", err, "record", record.ID)
					break
				}
			}
			select {
			case <-ctx.Done():
				return ctx.Err()
			case <-time.After(time.Second):
			}
		}
	}, "outbox_relay")
}

// outboxRecord is one record waiting in the outbox.
type outboxRecord struct {
	ID int64
}

// pendingOutboxRecords reads up to limit records not sent yet, oldest first.
func pendingOutboxRecords(context.Context, int) ([]outboxRecord, error) { return nil, nil }

// sendOutboxRecord sends one record, with its ID for the receiver to
// deduplicate by.
func sendOutboxRecord(context.Context, outboxRecord) error { return nil }

// markOutboxRecordSent marks a record sent.
func markOutboxRecordSent(context.Context, int64) error { return nil }
Example (Resume)

ExampleRegister_resume carries a long scan across leaders. The work starts from scratch on every tenure — nothing it keeps in memory survives a change of leader — so it reads where the scan stands from the database first, and writes each batch's results together with the new position in one transaction: a tenure cut short anywhere loses at most the batch in flight, which the next leader scans again.

package main

import (
	"context"
	"time"

	"github.com/cockroachdb/errors"
	"github.com/hydroan/gst/database"
	"github.com/hydroan/gst/leader"
)

func main() {
	leader.Register(func(ctx context.Context) error {
		cursor, err := loadScanCursor(ctx)
		if err != nil {
			return errors.Wrap(err, "load scan cursor")
		}
		for {
			batch, next, err := scanRecordsAfter(ctx, cursor, 500)
			if err != nil {
				return errors.Wrap(err, "scan records")
			}
			if len(batch) == 0 {
				select {
				case <-ctx.Done():
					return ctx.Err()
				case <-time.After(10 * time.Second):
				}
				continue
			}
			err = database.Transaction(ctx, func(ctx context.Context) error {
				return saveScanBatch(ctx, batch, next)
			})
			if err != nil {
				return errors.Wrap(err, "save scan batch")
			}
			cursor = next
		}
	}, "record-scan")
}

// loadScanCursor reads the position the scan reached.
func loadScanCursor(context.Context) (int64, error) { return 0, nil }

// scanRecordsAfter reads up to limit record IDs past cursor, and the cursor
// after them.
func scanRecordsAfter(context.Context, int64, int) ([]int64, int64, error) { return nil, 0, nil }

// saveScanBatch writes a batch's results and the new cursor.
func saveScanBatch(context.Context, []int64, int64) error { return nil }
Example (Returning)

ExampleRegister_returning shows what each way out of the work means. The work is expected to run until ctx ends, and returning that ending — ctx.Err() or context.Cause(ctx), wrapped or not — ends the tenure normally. Returning before ctx ends, done or failed, hands the name back: the campaign resumes a few seconds later and the work runs again from scratch, on this replica or another; a returned failure is logged, and so is a panic, which is recovered. A transient failure is better retried in place, keeping the name.

A failure of the work's own that meets the ending is reported only joined with it by errors.Join. Attached by errors.CombineErrors or formatted in with %v it goes unseen, and the tenure reads as ended normally.

package main

import (
	"context"
	"time"

	"github.com/cockroachdb/errors"
	"github.com/hydroan/gst/leader"
	"github.com/hydroan/gst/logger"
)

func main() {
	leader.Register(func(ctx context.Context) error {
		for {
			err := relayOnce(ctx)
			switch {
			case ctx.Err() != nil:
				// The tenure ended; a failure of the relay's own stays reported.
				return errors.Join(err, ctx.Err())
			case errors.Is(err, errRelayMisconfigured):
				// No retry fixes this here: hand the name back.
				return err
			case err != nil:
				logger.App.Warnw("relaying failed, retrying", "err", err)
			}
			select {
			case <-ctx.Done():
				return ctx.Err()
			case <-time.After(time.Second):
			}
		}
	}, "relay")
}

// errRelayMisconfigured reports a relay whose configuration cannot work.
var errRelayMisconfigured = errors.New("relay misconfigured")

// relayOnce relays what is pending.
func relayOnce(context.Context) error { return nil }
Example (SingleConnection)

ExampleRegister_singleConnection holds the one session an upstream allows for the whole deployment. The leader dials when its tenure starts and closes the session the moment the tenure ends — which is also what unblocks a read that takes no context. The work must return in time: 5 seconds after the lease was lost, work still running would be running beside the new leader's session, and the process fails and exits without waiting for it.

package main

import (
	"context"
	"net"

	"github.com/cockroachdb/errors"
	"github.com/hydroan/gst/leader"
	"github.com/hydroan/gst/logger"
)

func main() {
	leader.Register(func(ctx context.Context) error {
		var dialer net.Dialer
		conn, err := dialer.DialContext(ctx, "tcp", "upstream.example:7000")
		if err != nil {
			return errors.Wrap(err, "dial upstream")
		}
		defer func() { _ = conn.Close() }()
		stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
		defer stop()

		buf := make([]byte, 4096)
		for {
			n, err := conn.Read(buf)
			if ctx.Err() != nil {
				return ctx.Err()
			}
			if err != nil {
				return errors.Wrap(err, "read upstream session")
			}
			if err := handleUpstreamMessage(ctx, buf[:n]); err != nil && ctx.Err() == nil {
				logger.App.Warnw("handling an upstream message failed", "err", err)
			}
		}
	}, "upstream-session")
}

// handleUpstreamMessage applies one message of the upstream session.
func handleUpstreamMessage(context.Context, []byte) error { return nil }

Types

This section is empty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL
⌂ 换网址