syncsource

package
v11.2.0 Latest Latest
Warning

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

Go to latest
Published: Aug 18, 2026 License: AGPL-3.0 Imports: 10 Imported by: 0

Documentation

Overview

Package syncsource adapts a repository to the two read seams searchsync defines: searchsync.Fetcher, which the change feed reads one document per event through, and searchsync.Scanner, which a reindex walks the whole source with.

An application indexing nine entities has nine of each, and they differ in three functions: how to read one row, how to page over IDs, and how to turn a row into the subset that gets indexed. Everything else — omitting rows that have since been deleted, holding the byte ordering a reindex depends on, naming the index in the error, wrapping a row in a Document — is the same work every time, and the version of it written out per entity is the version where one of the nine quietly disagrees with the other eight.

source, err := syncsource.New("orders",
    repo.GetOrder,               // func(ctx, id) (*Order, error)
    repo.ScanOrderIDsForReindex, // func(ctx, after, limit) ([]string, error)
    convertOrderToSearchSubset,  // func(*Order) *OrderSearchSubset
)
if err != nil {
    return err
}

syncer, err := syncsource.NewSyncer(source, index, syncsource.WithPillars(pillars))
if err != nil {
    return err
}

reindexer, err := syncsource.NewReindexer(source, index, syncsource.WithPillars(pillars))

The three functions are the repository's, and stay there: FetchFunc is its existing get-by-ID and ScanFunc is its keyset walk over IDs. This package supplies neither, and shouldn't — it does not know what a row is or how to read one.

Scan is implemented in terms of Fetch

Fetcher and Scanner have a correctness relationship their interfaces state nowhere: both must produce the same document for the same row. Where they don't, a reindex overwrites what the change feed wrote with a differently-shaped copy, and the index holds two generations of schema at once with nothing to detect it. Nothing about implementing the two separately, nine times, makes that relationship visible, and the failure it produces is silent.

So there is one transform here, not two, and the cheapest way to guarantee agreement is to have one seam call the other: the scan query names the next page of IDs and the fetch turns them into documents. It costs a second round trip per page, on the background path where that is affordable, and it removes the possibility of two row-to-document transforms drifting apart.

The three things a from-scratch implementation gets wrong

sql.ErrNoRows from the fetch is an expected outcome rather than a failure — the row was deleted between the event being written and the event being handled — and must be omitted from the batch rather than failing it. Failing it instead retries the event until it dead-letters, with the stale document sitting in the index the whole time. Fetch omits it.

A page shortened by that omission does not end the walk. searchsync.Scanner reads a page shorter than limit as the end of the stream, so a Scan that dropped one vanished row out of a full page would stop a reindex partway through and report success. Scan asks for more IDs until it has a full page or the ID stream is genuinely exhausted.

The IDs a reindex walks must ascend in byte order, as Go's < compares strings, and that is checked here rather than repaired. Postgres's default en_US.UTF-8 collation sorts case-insensitively and ignores punctuation, which is a different order; a keyset walk over it wants ORDER BY id COLLATE "C". Sorting each page instead would fix what the page looks like and nothing else — the query is what resumes, so a locale-collated walk still skips every row between one page's largest ID and the next page's first, and arrives downstream in perfect order while doing it. A pruning reindex then deletes those live documents. Scan checks what the ScanFunc returned and fails with searchsync.ErrUnsortedScan.

Text indexes only

NewSyncer and NewReindexer build against textsearch.IndexManager because a ConvertFunc produces a body and not an embedding, and a vector target refuses a document without one. An application indexing vectors builds searchsync.VectorTarget itself and supplies documents that carry embeddings; that is a different transform, not this one with a field added.

Example

Example wires one entity's index sync: the three repository functions become a Source, and the Source becomes both the change feed's consumer and the rebuild's walk.

package main

import (
	"context"
	"fmt"
	"maps"
	"slices"

	searchsync "github.com/primandproper/platform-go/v11/search/sync"
	syncsource "github.com/primandproper/platform-go/v11/search/sync/source"
)

// orderRepository stands in for the application's repository. Both of the
// functions a Source is built from are already on it — a get-by-ID for the
// change feed, and a keyset walk over IDs for a reindex — and neither exists
// for the search sync's benefit.
type orderRepository struct {
	orders map[string]*order
}

type order struct {
	ID       string
	Customer string
	Status   string
	Internal string
}

// orderDoc is the subset that is actually indexed. It is a different type from
// the row on purpose: the row carries fields nobody searches on.
type orderDoc struct {
	Customer string `json:"customer"`
	Status   string `json:"status"`
}

func convertOrder(o *order) *orderDoc {
	return &orderDoc{Customer: o.Customer, Status: o.Status}
}

func (r *orderRepository) GetOrder(_ context.Context, id string) (*order, error) {
	return r.orders[id], nil
}

func (r *orderRepository) ScanOrderIDsForReindex(_ context.Context, after string, limit int) ([]string, error) {
	// A real one is SELECT id FROM orders WHERE id > $1 ORDER BY id COLLATE "C"
	// LIMIT $2. The collation is not optional — see the package documentation.
	page := make([]string, 0, limit)
	for _, id := range slices.Sorted(maps.Keys(r.orders)) {
		if id > after {
			page = append(page, id)
		}

		if len(page) == limit {
			break
		}
	}

	return page, nil
}

// memoryIndex is a stand-in for Algolia or Elasticsearch, satisfying
// textsearch.IndexManager.
type memoryIndex struct {
	docs map[string]any
}

func (i *memoryIndex) Index(_ context.Context, id string, value any) error {
	i.docs[id] = value

	return nil
}

func (i *memoryIndex) Delete(_ context.Context, id string) error {
	delete(i.docs, id)

	return nil
}

func (i *memoryIndex) Wipe(context.Context) error {
	clear(i.docs)

	return nil
}

// Example wires one entity's index sync: the three repository functions become
// a Source, and the Source becomes both the change feed's consumer and the
// rebuild's walk.
func main() {
	ctx := context.Background()

	repo := &orderRepository{orders: map[string]*order{
		"order-1": {ID: "order-1", Customer: "ana", Status: "shipped", Internal: "not indexed"},
		"order-2": {ID: "order-2", Customer: "bo", Status: "pending", Internal: "not indexed"},
	}}
	index := &memoryIndex{docs: map[string]any{}}

	source, err := syncsource.New("orders", repo.GetOrder, repo.ScanOrderIDsForReindex, convertOrder)
	if err != nil {
		panic(err)
	}

	// The rebuild: register reindexer.Job with a jobs.Scheduler, whose
	// distributed lock runs it once across the fleet rather than once per
	// replica.
	reindexer, err := syncsource.NewReindexer(source, index)
	if err != nil {
		panic(err)
	}

	result, err := reindexer.Reindex(ctx)
	if err != nil {
		panic(err)
	}

	fmt.Println("reindexed:", result.Upserted)

	// The change feed: syncer.Handle is a jobs.Handler, so a jobs.Pool supplies
	// the consumption, concurrency, retry and dead-lettering around it.
	syncer, err := syncsource.NewSyncer(source, index)
	if err != nil {
		panic(err)
	}

	repo.orders["order-2"].Status = "shipped"
	if err = syncer.Apply(ctx, searchsync.NewEvent(searchsync.OpUpsert, "order-2")); err != nil {
		panic(err)
	}

	// An upsert whose row has since been deleted is applied as a delete: the
	// source is what the index converges toward, and the source says it is
	// gone.
	delete(repo.orders, "order-1")
	if err = syncer.Apply(ctx, searchsync.NewEvent(searchsync.OpUpsert, "order-1")); err != nil {
		panic(err)
	}

	for _, id := range slices.Sorted(maps.Keys(index.docs)) {
		doc, ok := index.docs[id].(*orderDoc)
		if !ok {
			panic("unexpected document type")
		}

		fmt.Printf("%s: %s is %s\n", id, doc.Customer, doc.Status)
	}

}
Output:
reindexed: 2
order-2: bo is shipped

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrNilFetchFunc indicates a Source built without a way to read one row.
	// It wraps errors.ErrNilInputParameter, so a caller may check either.
	ErrNilFetchFunc = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil search sync fetch func")

	// ErrNilScanFunc indicates a Source built without a way to page over IDs.
	// It wraps errors.ErrNilInputParameter, so a caller may check either.
	ErrNilScanFunc = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil search sync scan func")

	// ErrNilConvertFunc indicates a Source built without a row-to-document
	// transform. It wraps errors.ErrNilInputParameter, so a caller may check
	// either.
	//
	// Refused rather than defaulted to the identity, because there is no
	// identity available: the row type and the document type are different type
	// parameters precisely so that what gets indexed is a deliberate subset of
	// what the row holds.
	ErrNilConvertFunc = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil search sync convert func")

	// ErrNilDocumentBody indicates a ConvertFunc returned nil for a row that
	// exists.
	//
	// It is not the same event as a missing row and must not be conflated with
	// one: a missing row is the source saying the document is gone, and this is
	// the transform saying nothing at all about a row that is still there.
	// Indexing it would store a null body under a live ID; omitting it would
	// quietly drop the document from a rebuild.
	ErrNilDocumentBody = platformerrors.New("search sync convert func returned no document body")
)

Functions

func NewReindexer

func NewReindexer[E, T any](source *Source[E, T], index textsearch.IndexManager, opts ...Option) (*searchsync.Reindexer[T], error)

NewReindexer builds the rebuild backstop for this Source's index.

A Syncer keeps an index current; a Reindexer rebuilds one. They answer different failures — a Syncer cannot repair an index that was already wrong before the first event was written, and a full walk is far too expensive to be the steady-state path — which is why both are built from the one Source rather than either being derived from the other.

It owns no ticker. Register the result with a jobs.Scheduler, whose distributed lock is what makes the rebuild run once across a fleet rather than once per replica:

if err = scheduler.Register(reindexer.Job(jobs.MustCron("0 4 * * *"), time.Hour)); err != nil {
    return err
}

func NewSyncer

func NewSyncer[E, T any](source *Source[E, T], index textsearch.IndexManager, opts ...Option) (*searchsync.Syncer[T], error)

NewSyncer builds the searchsync.Syncer that applies one index event for this Source's entity, writing into index.

It owns no goroutine and reads from no queue: its Handle is a jobs.Handler, and the jobs.Pool calling it supplies concurrency, retry with backoff, dead-lettering and a draining shutdown.

Types

type ConvertFunc

type ConvertFunc[E, T any] func(*E) *T

ConvertFunc turns a row into the subset that is actually indexed. It is called only for rows that exist, so it never receives nil, and returning nil for a row that does exist is ErrNilDocumentBody.

type FetchFunc

type FetchFunc[E any] func(ctx context.Context, id string) (*E, error)

FetchFunc reads one row by ID. It is the repository's existing get-by-ID method.

A row that is gone is reported as sql.ErrNoRows or as a nil entity with no error, and either is an expected outcome here rather than a failure — see Fetch. Any other error is a real one and fails the batch.

type Option

type Option func(*options)

Option configures what NewSyncer and NewReindexer build. The zero configuration works: an absent logger logs nowhere, an absent tracer provider traces nowhere, and an absent metrics provider records nothing.

It is one type for both constructors rather than two, because the pillars are the same three things either way and a wiring site that builds both from one Source passes the same options to each.

Option is not parameterized on the Source's type arguments even though the constructors it configures are. Go cannot infer a type argument from a call's result type, so a WithLogger[Order, OrderDoc](logger) would have to be spelled out at every call site, forever, to configure something that does not depend on either type.

func WithLogger

func WithLogger(logger logging.Logger) Option

WithLogger attaches a logger.

func WithMetricsProvider

func WithMetricsProvider(metricsProvider metrics.Provider) Option

WithMetricsProvider attaches a metrics provider. Without one there is no lag histogram, which is the one instrument that distinguishes a working sync from a stopped one — see the searchsync package documentation.

func WithPillars

func WithPillars(p *observability.Pillars) Option

WithPillars attaches a logger, tracer provider, and metrics provider in one go, for the common case where a caller has already built them together. A nil Pillars attaches nothing.

It is applied in order with the individual options, so a caller can hand over its pillars and then override one of them.

func WithReindexOptions

func WithReindexOptions(opts ...searchsync.ReindexOption) Option

WithReindexOptions passes options through to the searchsync.Reindexer that NewReindexer builds — WithReindexBatchSize and WithReindexPruner in particular. NewSyncer ignores these.

Pruning is worth a thought rather than a default. Without a pruner a rebuild converges the documents the source has and leaves behind any the source no longer names; deletions still reach the index through the change feed, which is where they are timely anyway. With one, a rebuild also repairs the documents a missed delete stranded — and nothing behind textsearch.Index can enumerate an index, so the Enumerator has to come from the application.

func WithSyncerOptions

func WithSyncerOptions(opts ...searchsync.SyncerOption) Option

WithSyncerOptions passes options through to the searchsync.Syncer that NewSyncer builds — WithSyncerClock, and anything added there later.

The pillars are not among them: they arrive as this package's own options, so one set of them configures whichever of the two things a wiring site builds. NewReindexer ignores these.

func WithTracerProvider

func WithTracerProvider(tracerProvider tracing.Provider) Option

WithTracerProvider attaches a tracer provider, enabling a span per applied event and per rebuild.

type ScanFunc

type ScanFunc func(ctx context.Context, after string, limit int) ([]string, error)

ScanFunc returns up to limit IDs sorting strictly after `after`, in ascending byte order. It is the repository's keyset walk over the table — conventionally its ScanXIDsForReindex method — and a page shorter than limit means the walk is over.

Ascending *byte* order, as Go's < compares strings, not whatever collation the database defaults to. Against Postgres that is ORDER BY id COLLATE "C"; see the package documentation for what the default collation costs a reindex.

type Source

type Source[E, T any] struct {
	// contains filtered or unexported fields
}

Source is a searchsync.Fetcher and a searchsync.Scanner over one entity, built from the three functions that are all the two seams actually differ in.

E is the row the repository returns; T is the search subset the index holds.

func New

func New[E, T any](name string, fetch FetchFunc[E], scan ScanFunc, convert ConvertFunc[E, T]) (*Source[E, T], error)

New builds a Source over one entity.

name is the index this Source feeds. It appears in every error this package returns, so a failure says which index it came from rather than only that a fetch failed, and it is the name NewSyncer and NewReindexer carry into their spans and metric attributes.

The three functions are refused rather than defaulted when nil. A Source with a nil function is one that panics on the first event it is handed, in a background consumer, some time after the wiring that built it returned successfully.

func (*Source[E, T]) Fetch

func (s *Source[E, T]) Fetch(ctx context.Context, ids ...string) ([]searchsync.Document[T], error)

Fetch returns the current document for each of ids, omitting any whose row no longer exists.

The omission is the interesting half of the contract. A missing row is not an error and must not be reported as one: it is how the Syncer learns that a row was deleted between the event being written and the event being applied, and it responds by removing the document rather than leaving a tombstone in the index. Reporting it as an error instead would retry the event until it dead-lettered, and leave the deleted document in the index the whole time.

Documents come back in the order the IDs were given, minus the omissions. searchsync.Fetcher promises no order and the change feed asks for one document at a time, so nothing outside this package should lean on it — but Scan does, and it is the reason Scan needs no sort of its own.

func (*Source[E, T]) Name

func (s *Source[E, T]) Name() string

Name is the index this Source feeds.

func (*Source[E, T]) Scan

func (s *Source[E, T]) Scan(ctx context.Context, after string, limit int) ([]searchsync.Document[T], error)

Scan returns up to limit documents whose IDs sort strictly after `after`, in ascending byte order, for a reindex to walk.

It pages IDs through the ScanFunc and turns them into documents through Fetch, which is what makes the two seams agree: there is one row-to-document transform, and the reindex reaches it the same way the change feed does.

Two things it does that a straight scan-then-fetch does not:

It refills a page that Fetch shortened. searchsync.Scanner reads a page shorter than limit as the end of the stream, and Fetch omits rows that have been deleted, so a full page containing one vanished row would otherwise end a reindex partway through and report success. This asks for more IDs until it has limit documents or the ScanFunc itself comes up short, which is the only thing that actually means the walk is over.

It checks the IDs the ScanFunc returned rather than sorting them, and fails with searchsync.ErrUnsortedScan if they do not ascend strictly after the cursor. Sorting would repair the symptom and hide the disease: a ScanFunc in a locale collation returns a page that sorts into perfect order and still skips every row between this page's largest ID and the next page's first, because the query — not the page — is what resumes. A pruning reindex then deletes those live documents. The check is also what guarantees the cursor advances, so the refill above cannot spin.

The two together are why nothing here sorts: the IDs ascend because they were checked to, and Fetch hands documents back in the order it was given them.

Jump to

Keyboard shortcuts

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