Documentation
¶
Overview ¶
Package searchsync keeps a search index in step with the database it is derived from.
Write the row, update the index: two systems, no shared commit, and when the second step fails they diverge permanently with nothing to detect it. That is the dual-write problem the outbox package closes for messaging, and it is the same problem here — so this package closes it the same way, by making the index event part of the transaction that changed the row and applying it afterwards from a consumer.
The workaround this replaces is polling: walk a sample of rows on a timer and re-upsert them. It is expensive in proportion to the table rather than to the change rate, and it is only ever probabilistically correct — a row the sampler has not reached is a row the index is wrong about, and no amount of running it turns that into a guarantee. The v10 removal of search/text/indexing is that sampler.
The application owns what a document looks like. This package owns that the index converges on it.
The three parts ¶
```mermaid
flowchart LR
subgraph txn["one transaction"]
row["row change"]
event["outbox Event"]
end
row --- event
event --> relay["outbox.Relay"]
relay --> pool["jobs.Pool"]
pool --> handle["Syncer.Handle"]
handle --> fetcher["Fetcher"]
fetcher --> target["Target<br/>(text or vector index)"]
reindexer["Reindexer<br/>(jobs.Scheduler)"] --> scanner["Scanner"]
scanner --> target
```
Enqueue an Event in the transaction that changed the row. The outbox Relay publishes it. A jobs.Pool consumes it and hands it to Syncer.Handle, which reads the document back from the application's Fetcher and writes it to the Target. Separately, a Reindexer walks the whole source on a schedule and rebuilds.
Writing the event ¶
err := client.WithTransaction(ctx, func(q database.Tx) error {
if err := updateOrder(ctx, q, order); err != nil {
return err
}
return writer.Enqueue(ctx, q,
searchsync.NewEvent(searchsync.OpUpsert, order.ID).Message("orders-index"))
})
The event lives or dies with the row change, because outbox.Writer.Enqueue takes the caller's executor and there is no way to hold one outside a transaction. Event.Message keys the outbox message by document ID, which is what buys per-document ordering: the outbox admits a keyed message only when no older message with that key is still pending, so at most one event per document is in flight across the whole relay fleet, however many relays are running. Events for different documents stay free to interleave.
Registering the event rather than writing it ¶
The call above is correct and forgettable. Nothing about a repository method that writes an order says the write owes an index event, so the next one enqueues its data-change message alone, compiles, and passes review — and the index is wrong from then until the next rebuild, with nothing in between able to notice, because the event that went missing is one no consumer was waiting for.
Register it on the Writer instead and the call site is never asked:
writer, err := outbox.NewWriter(client.Dialect(),
outbox.WithWriterSideEffect("orders-index",
func(_ context.Context, _ database.SQLQueryExecutor, msgs []outbox.Message) ([]outbox.Message, error) {
events := make([]outbox.Message, 0, len(msgs))
for _, msg := range msgs {
changed, ok := msg.Payload.(OrderChanged)
if !ok {
continue
}
events = append(events,
searchsync.NewEvent(searchsync.OpUpsert, changed.OrderID).Message("orders-index"))
}
return events, nil
}))
Every transaction that enqueues an order data-change now writes the index event too, by the same statement, whether or not whoever wrote it was thinking about the index. The derivation is the application's because the payload is: outbox never looks inside a Message.Payload, which is also why the dependency runs only one way — searchsync imports outbox, and outbox knows nothing of searchsync.
Writing the event at the call site stays right where the call site is genuinely choosing: a targeted re-index after a manual repair, or an OpDelete one branch of one method emits. outbox's own documentation draws that line in full.
The event names a document; it does not carry one ¶
Every applied event reads the row back through the Fetcher. That is one source read per event, and it is what the correctness rests on.
An event that carries the document body has to be applied in the order it was written, or the index ends up holding an older body than the one it already had. An event that carries only an ID cannot: whenever it is applied, and however many times, it indexes the row's current state. Out-of-order delivery converges. Redelivery converges — and redelivery is normal operation, not an error case, since the outbox is at-least-once by construction and cannot be otherwise.
The same indirection settles the awkward case honestly. An upsert whose row has since been deleted finds nothing, and is applied as a delete: the source is what the index converges toward, and the source says the document is gone. Leaving it would strand a document in the index that no later event will ever mention again.
The cost is real — a read per event, where a fat event would have needed none — and it is the price of not making delivery order load-bearing. It also pays for itself once: the Fetcher that serves the change feed and the Scanner that serves the reindex are the same transform from row to document, so there is one definition of what a document is rather than two that can drift.
Implementing the two seams ¶
Fetcher and Scanner are two interfaces with a correctness relationship neither one states: both must produce the same document for the same row, or 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. Implementing them separately, once per entity, makes that relationship invisible in exactly the places it has to hold.
search/sync/source is the way not to. It builds both from the three functions they actually differ in — read one row, page over IDs, turn a row into the indexed subset — and implements the scan in terms of the fetch, so the two cannot disagree. It also handles the three things a from-scratch pair gets wrong: a row deleted between the event and its handling is omitted rather than failing the batch, a page that omission shortened is refilled rather than ending the walk, and the scanned IDs are checked against the byte ordering below rather than sorted into looking like it.
An application whose documents come from somewhere other than a repository — several joined queries, an API, a computed embedding — implements the two interfaces directly, and owes them that agreement itself.
Consuming ¶
The Syncer owns no goroutine and reads from no queue. Handle is a jobs.Handler:
syncer, err := searchsync.NewSyncer("orders", orderSource, target,
searchsync.WithSyncerLogger(logger),
searchsync.WithSyncerTracerProvider(tracerProvider),
searchsync.WithSyncerMetricsProvider(metricsProvider))
if err != nil {
return err
}
pool, err := jobs.NewPool(ctx, &jobs.PoolConfig{
Topic: "orders-index",
Concurrency: 8,
}, consumerProvider, syncer.Handle, jobs.WithPoolDeadLetter(deadLetter))
if err != nil {
return err
}
go pool.Run()
defer func() { _ = pool.Close(shutdownCtx) }()
Concurrency, retry with backoff, dead-lettering, panic containment and draining shutdown are all the Pool's, which does them carefully already. Nothing here reimplements them.
A payload that will not decode, or an event with no document ID, comes back wrapped in retry.Unretryable so the Pool dead-letters it immediately rather than failing the same way three more times while healthy events wait behind it.
Recording what the index holds ¶
querygen treats last_indexed_at as the column that marks a table as one this package mirrors: its presence is what makes it emit the reindex scan, and it is database-owned, so no create or update a caller writes may supply it. Until there was a writer, that was a column the conventions reserved and nothing filled in.
WithSyncerStamper is the writer. Give a Syncer a Stamper and it records every document the index accepted:
stamps, err := searchsync.NewStampBuffer(func(ctx context.Context, ids []string) error {
_, err := queries.MarkOrdersAsIndexed(ctx, db, ids)
return err
}, batching.WithLogger(logger), batching.WithMetricsProvider(metricsProvider))
if err != nil {
return err
}
defer func() { _ = stamps.Close(shutdownCtx) }()
syncer, err := searchsync.NewSyncer("orders", orderSource, target,
searchsync.WithSyncerStamper(stamps))
MarkOrdersAsIndexed is querygen's, emitted from the same column list as the scan that reads the column back — one UPDATE over WHERE id = ANY, which is the shape the flush is holding.
Accepted is the operative word. A delete stamps nothing, because there is no document left to have indexed. An upsert whose row has since vanished is applied as a delete, and stamps nothing either. A failed write stamps nothing. The column says what the index holds, not what was attempted, which is the only reading that makes the reindex scan over it mean anything.
The write is buffered, and that is not throughput tuning. One UPDATE per applied document, issued from all eight workers of a Pool at once, is concurrent statements taking row locks on the same rows in whatever order each one built them — Postgres 40P01, holding a pool connection while it deadlocks. A batching.Buffer collapses the repeats, flushes from a single goroutine on an interval, and emits in id order: one stamping write in flight, one lock order. Nothing reads the column back in the same breath, which is what makes a Buffer right here rather than a GroupCommit. Its own instruments — batching_buffer_* — are how a failing stamp is seen, since by the time a flush runs there is no caller left to hand an error to.
The Buffer is the caller's to build and to Close, because it owns a goroutine and a Syncer does not. NewStampBuffer exists so the one part that is load-bearing rather than tunable — the id ordering — is not something each wiring site has to remember.
A Reindexer has no counterpart and should not: it writes every document there is, so stamping it would make the column a record of when the last rebuild ran, the same value on every row, rather than of how current each document is.
Rebuilding ¶
reindexer, err := searchsync.NewReindexer("orders", orderSource, target,
searchsync.WithReindexPruner(indexIDs))
if err != nil {
return err
}
if err = scheduler.Register(reindexer.Job(jobs.MustCron("0 4 * * *"), time.Hour)); err != nil {
return err
}
Like retention.Sweeper, the Reindexer owns no ticker: it is registered with a jobs.Scheduler, whose distributed lock is what makes the rebuild happen once across a fleet rather than once per replica. Ten replicas each doing a full scan of the same table is ten times the load on the source at the one moment the source is already under a full scan.
What a rebuild can and cannot repair ¶
The upsert half is straightforward: walk the source in batches, write everything. That covers a bootstrap into an empty index, a mapping change, and any drift on the write side.
The delete half — removing documents whose rows are gone — needs to know what the index currently holds, and nothing here can ask. textsearch.Index and vectorsearch.Index model upsert, delete, and query; enumeration is a different operation on every backend (Algolia browses, Elasticsearch scrolls with a point-in-time, pgvector selects) and none of those narrow interfaces model it. So it comes from outside, as an Enumerator passed to WithReindexPruner.
Without one, a rebuild is upsert-only. That is not a degraded mode with a caveat, it is the other mode: it is exactly right for a bootstrap and a mapping change, and it is not drift repair. A Reindexer reports which one it is on every span.
With one, the rebuild merges two streams ordered by document ID — the source's and the index's. A source ID the index has not reached is written; an index ID the source has passed is deleted. One walk of each side, bounded memory, no generation column.
Both streams must agree on what ascending means ¶
Scanner.Scan and Enumerator.Scan promise ascending *byte* order, as Go's < compares strings. Not "sorted" — sorted by that comparison specifically.
This is not pedantry. Postgres's default en_US.UTF-8 collation sorts case-insensitively and ignores punctuation; byte order does not. If the source walks in one order and the index walks in another, the merge's second inference — this index ID is behind the source, so its row must be gone — is false, and the rebuild deletes documents that are perfectly alive. A keyset walk over Postgres wants ORDER BY id COLLATE "C".
So the Reindexer checks rather than trusts. Every page is verified strictly ascending and free of empty IDs before any of it is applied, and a violation aborts with ErrUnsortedScan. A stream in a locale collation trips it, because that stream is not in byte order either. The check costs a comparison per document and buys the one failure in this package that would silently destroy data.
Nothing enforces it on the change-feed path, which needs no ordering at all — this is the reindex's constraint alone.
Two targets, and the embedding ¶
TextTarget adapts a textsearch.IndexManager; VectorTarget adapts a vectorsearch.IndexWriter. Anything else is two methods.
A vector index needs an embedding, so Document carries one, and a text target ignores it. Computing it here would mean this package holding an embeddings client and deciding when to spend on it — which model, on which fields, at what cost per rebuild — all of which is the application's call. The Source produces the embedding alongside the body, in the one place that already knows what the document is.
Watching it ¶
Nothing here fails loudly. The Pool swallows handler errors by design and the Scheduler swallows job errors, so the instruments are how you learn the index has stopped tracking the database. Pass the metrics provider.
search_sync_lag_ms is the one that matters, and it is the reason Event carries OccurredAt at all: the distance between when a row changed and when the index agreed. Every other instrument here is a rate or a count, and none of them separates "applying events steadily" from "applying events steadily while falling further behind". A p99 of four seconds is a search index; a p99 of four hours is a search index nobody should be reading. Pair it with the outbox's outbox_backlog_age_seconds, which answers the same question one hop upstream — between them, a lag that is climbing is attributable to the relay or to this consumer rather than to "search is slow".
It is measured across a process boundary, so it inherits whatever clock agreement the fleet has. A consumer whose clock runs behind the writer's records zero rather than a negative lag, and an event with no OccurredAt records nothing rather than a lag measured from the epoch.
The rest: search_sync_events_applied against search_sync_events_failed, each carrying the index name and the op; search_sync_documents_vanished, which counts upserts that turned into deletes because the row was already gone — a small steady rate is ordinary, a spike is a bulk delete working its way through; search_sync_apply_latency_ms; and for rebuilds search_sync_reindex_documents, search_sync_reindex_pruned, search_sync_reindex_batches, search_sync_reindex_failures and search_sync_reindex_latency_ms.
Spans cover each applied event and each rebuild, carrying the document ID, the op, the lag, and — on a rebuild — what it scanned, wrote and pruned.
There is no config subpackage ¶
Every other assembly seam in this module has one because there is something to select from the environment: a provider, a connection string, a credential. There is nothing of the kind here. The Source and the Target are application code, the index name is a constant in that code, and the only number worth tuning is the reindex batch size.
What does come from the environment already has a home: the topic, concurrency and retry policy belong to jobscfg.PoolConfig, the search backend to textsearchcfg or vectorsearchcfg, and the outbox to outboxcfg. A config package here would only wrap those and add a fourth name for the same knobs.
Non-goals ¶
No index schema management: creating an index, its mappings, its dimension and distance metric are the backends' construction-time concerns, and they disagree about all of them.
No fan-out. One Syncer serves one index from one topic. An application indexing five kinds of document runs five, which costs five registrations and buys five independent lag readings, five retry budgets, and five failure domains — where a single multiplexed consumer with a type discriminator buys one of each and makes the slowest index everyone's problem.
Example ¶
Example applies a change feed. In a service the events arrive from a jobs.Pool consuming the topic the outbox relayed them to, and Handle is the Pool's handler; Apply is the same work with the decoding already done.
package main
import (
"context"
"fmt"
"slices"
"sort"
searchsync "github.com/primandproper/platform-go/v13/search/sync"
)
// order is the domain object, and orderDoc is what the index holds. The
// application owns both, and owns the transform between them — this package
// never looks inside either.
type order struct {
ID string
Customer string
Status string
}
type orderDoc struct {
Customer string `json:"customer"`
Status string `json:"status"`
}
// orderStore stands in for the table. A real one runs two queries: one that
// loads orders by ID, and one that pages them by ID for a rebuild.
type orderStore struct {
orders map[string]order
}
func (s *orderStore) document(o order) searchsync.Document[orderDoc] {
return searchsync.Document[orderDoc]{
ID: o.ID,
Body: &orderDoc{Customer: o.Customer, Status: o.Status},
}
}
// Fetch is the change feed's half: current documents for the IDs asked about,
// omitting any whose row is gone. The omission is what tells the Syncer to
// remove the document rather than leave a tombstone.
func (s *orderStore) Fetch(_ context.Context, ids ...string) ([]searchsync.Document[orderDoc], error) {
docs := make([]searchsync.Document[orderDoc], 0, len(ids))
for _, id := range ids {
if o, ok := s.orders[id]; ok {
docs = append(docs, s.document(o))
}
}
return docs, nil
}
// Scan is the rebuild's half: a keyset walk in ascending byte order. Against
// Postgres this is ORDER BY id COLLATE "C" — the default collation sorts
// case-insensitively, which is a different order, and the rebuild's pruning
// half compares this stream against the index's.
func (s *orderStore) Scan(_ context.Context, after string, limit int) ([]searchsync.Document[orderDoc], error) {
ids := make([]string, 0, len(s.orders))
for id := range s.orders {
if id > after {
ids = append(ids, id)
}
}
sort.Strings(ids)
ids = ids[:min(len(ids), limit)]
docs := make([]searchsync.Document[orderDoc], 0, len(ids))
for _, id := range ids {
docs = append(docs, s.document(s.orders[id]))
}
return docs, nil
}
// memoryIndex is a stand-in for a search backend, and doubles as the
// Enumerator a rebuild prunes with — a real one walks the backend's own
// browse, scroll, or select.
type memoryIndex struct {
docs map[string]*orderDoc
}
func (i *memoryIndex) Upsert(_ context.Context, docs ...searchsync.Document[orderDoc]) error {
for _, doc := range docs {
i.docs[doc.ID] = doc.Body
}
return nil
}
func (i *memoryIndex) Delete(_ context.Context, ids ...string) error {
for _, id := range ids {
delete(i.docs, id)
}
return nil
}
func (i *memoryIndex) Scan(_ context.Context, after string, limit int) ([]string, error) {
ids := make([]string, 0, len(i.docs))
for id := range i.docs {
if id > after {
ids = append(ids, id)
}
}
sort.Strings(ids)
return ids[:min(len(ids), limit)], nil
}
func (i *memoryIndex) contents() []string {
ids := make([]string, 0, len(i.docs))
for id := range i.docs {
ids = append(ids, id)
}
slices.Sort(ids)
return ids
}
func main() {
ctx := context.Background()
store := &orderStore{orders: map[string]order{
"order-1": {ID: "order-1", Customer: "ada", Status: "placed"},
"order-2": {ID: "order-2", Customer: "grace", Status: "placed"},
}}
index := &memoryIndex{docs: map[string]*orderDoc{}}
syncer, err := searchsync.NewSyncer[orderDoc]("orders", store, index)
if err != nil {
panic(err)
}
for _, id := range []string{"order-1", "order-2"} {
if err = syncer.Apply(ctx, searchsync.NewEvent(searchsync.OpUpsert, id)); err != nil {
panic(err)
}
}
fmt.Println("indexed:", index.contents())
// The row changes, and the event says only which document to re-read.
store.orders["order-1"] = order{ID: "order-1", Customer: "ada", Status: "shipped"}
if err = syncer.Apply(ctx, searchsync.NewEvent(searchsync.OpUpsert, "order-1")); err != nil {
panic(err)
}
fmt.Println("order-1 is now:", index.docs["order-1"].Status)
// An upsert whose row has since been deleted applies as a delete: the
// source is what the index converges toward, and it says the row is gone.
delete(store.orders, "order-2")
if err = syncer.Apply(ctx, searchsync.NewEvent(searchsync.OpUpsert, "order-2")); err != nil {
panic(err)
}
fmt.Println("indexed:", index.contents())
}
Output: indexed: [order-1 order-2] order-1 is now: shipped indexed: [order-1]
Index ¶
- Constants
- Variables
- func NewStampBuffer(write func(ctx context.Context, ids []string) error, opts ...batching.Option) (*batching.Buffer[string], error)
- type Document
- type Enumerator
- type Event
- type Fetcher
- type Op
- type ReindexOption
- func WithReindexBatchSize(n int) ReindexOption
- func WithReindexLogger(logger logging.Logger) ReindexOption
- func WithReindexMetricsProvider(metricsProvider metrics.Provider) ReindexOption
- func WithReindexPruner(pruner Enumerator) ReindexOption
- func WithReindexTracerProvider(tracerProvider tracing.Provider) ReindexOption
- type ReindexResult
- type Reindexer
- type Scanner
- type Stamper
- type Syncer
- type SyncerOption
- func WithSyncerClock(c clock.Clock) SyncerOption
- func WithSyncerLogger(logger logging.Logger) SyncerOption
- func WithSyncerMetricsProvider(metricsProvider metrics.Provider) SyncerOption
- func WithSyncerStamper(stamper Stamper) SyncerOption
- func WithSyncerTracerProvider(tracerProvider tracing.Provider) SyncerOption
- type Target
Examples ¶
Constants ¶
const DefaultReindexBatchSize = 500
DefaultReindexBatchSize is how many documents a reindex scans and writes at a time when WithReindexBatchSize names no other number.
const ReindexJobPrefix = "reindex-"
ReindexJobPrefix is prepended to the index name to form the jobs.Job name a Reindexer registers under. The name is the scheduler's lock key, so it is spelled in one place: two replicas that disagree about it both run the reindex.
Variables ¶
var ( // ErrEmptyName indicates a Syncer or Reindexer built without an index name. // // Refused rather than defaulted, because the name is the metric attribute // every instrument here carries. A service syncing three indexes under one // blank name has one lag histogram covering all three, which is the reading // most likely to be quoted and least likely to be true. ErrEmptyName = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "empty search index name") // ErrNilSource indicates a nil Fetcher or Scanner. It wraps // errors.ErrNilInputParameter, so a caller may check either. ErrNilSource = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil search sync source") // ErrNilTarget indicates a nil Target. It wraps // errors.ErrNilInputParameter, so a caller may check either. ErrNilTarget = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil search sync target") // ErrNilIndex indicates a nil index handed to TextTarget or VectorTarget. // It wraps errors.ErrNilInputParameter, so a caller may check either. ErrNilIndex = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil search index") // ErrInvalidEvent indicates an event that cannot be applied — no document // ID, or an op this package does not know. Handle wraps it with // retry.Unretryable, because a payload that is malformed now will be // malformed on every redelivery, and each of those attempts is latency the // healthy events behind it spend waiting. ErrInvalidEvent = platformerrors.New("invalid search sync event") // ErrEmptyDocumentID indicates a Source produced a document with no ID. // Indexing it would either overwrite whatever the backend keys an empty ID // to or fail inside the backend, and neither says what actually went wrong. ErrEmptyDocumentID = platformerrors.New("search sync document has no ID") // ErrUnsortedScan indicates a Scanner or Enumerator returned IDs that do // not ascend in byte order. // // It aborts the reindex rather than being tolerated, and that is the point // of checking. Pruning merges the source's ordered IDs against the index's, // and treats an index ID that the source stream has passed as a document // whose row is gone. If either stream is in a different order — a collation // that sorts case-insensitively is enough — that inference is wrong and the // reindex deletes documents that are perfectly alive. ErrUnsortedScan = platformerrors.New("search sync scan returned out-of-order IDs") )
Functions ¶
func NewStampBuffer ¶
func NewStampBuffer(write func(ctx context.Context, ids []string) error, opts ...batching.Option) (*batching.Buffer[string], error)
NewStampBuffer builds the buffered writer a Syncer stamps through: keys coalesced in memory and flushed through write on an interval or when the buffer fills.
write is handed the whole flushed set rather than one id at a time, because one statement per flush is the entire reason the write is buffered. Its natural implementation is the bulk stamp querygen emits for any table carrying last_indexed_at — MarkXAsIndexed, an UPDATE over WHERE id = ANY — so the two halves fit together without the application writing either:
stamps, err := searchsync.NewStampBuffer(func(ctx context.Context, ids []string) error {
_, err := queries.MarkOrdersAsIndexed(ctx, db, ids)
return err
}, batching.WithLogger(logger), batching.WithMetricsProvider(metricsProvider))
if err != nil {
return err
}
defer func() { _ = stamps.Close(shutdownCtx) }()
syncer, err := searchsync.NewSyncer("orders", orderSource, target,
searchsync.WithSyncerStamper(stamps))
Buffered rather than direct, and that is not a throughput optimization. One UPDATE per applied document, issued from every worker of a jobs.Pool at once, is concurrent statements taking row locks on the same popular rows in whatever order each caller built them in — Postgres 40P01, holding a pool connection while it deadlocks, until endpoints with nothing to do with that table start failing. A Buffer collapses the repeats, flushes from one goroutine, and emits in id order: one stamping write in flight, one lock order.
That ordering is pinned here rather than left to opts, because it is the half that is load-bearing and the half that looks optional. Everything else about the buffer — its interval, its flush timeout, how many ids it holds before flushing early, and the three observability pillars — is the caller's, and a batching.Option this constructor has no use for is accepted and ignored just as batching's own constructors accept it.
A Buffer owns a goroutine and must be Closed, which is why it is returned to the caller rather than built inside NewSyncer. A Syncer owns no goroutine and has no lifecycle; acquiring one through an option would be a shutdown obligation that nothing in its signature mentions.
Types ¶
type Document ¶
type Document[T any] struct { // Body is the indexable form of the domain object. It is handed to the // index as-is. Body *T // ID must match the DocumentID of the events describing this document, and // the IDs an Enumerator reports. It is the key everything here joins on. ID string // Embedding is the document's vector, and is used only by a vector Target — // a text Target ignores it. A vector Target rejects a document without one, // because an unembedded vector document is not a document the index can // hold. Embedding []float32 }
Document is what the index holds: an ID and a body the application shaped.
The body is the application's business entirely — this package never looks inside it. What this package owns is that whatever the Source produces is what the index ends up holding.
type Enumerator ¶
type Enumerator interface {
// Scan returns up to limit document IDs sorting strictly after
// `after`, in ascending byte order — the same contract, and the same
// collation caveat, as Scanner.Scan.
Scan(ctx context.Context, after string, limit int) ([]string, error)
}
Enumerator lists the IDs an index currently holds, so a reindex can remove the ones the source no longer has. See WithReindexPruner.
It is not part of Target, and no implementation ships here, because neither textsearch.Index nor vectorsearch.Index can enumerate: Algolia browses, Elasticsearch scrolls, pgvector selects, and the narrow upsert/delete/query interfaces those sit behind deliberately do not model any of it. An application that wants pruning has the backend's own client already and is the only party that can say how to walk it.
type Event ¶
type Event struct {
// OccurredAt is when the change happened, stamped by the writing process.
// It exists for one reason: the difference between it and the instant the
// Syncer applies the event is the indexing lag, which is the only number
// that says whether search results are current.
//
// It is therefore read across a process boundary, and is only as good as
// the clock agreement between the writer and the consumer. A consumer whose
// clock runs behind the writer's reports zero lag rather than a negative
// one.
OccurredAt time.Time `json:"occurredAt"`
// DocumentID identifies the document in both the source and the index. The
// two must agree — it is the whole basis of "last write wins on the same
// doc ID", and of the reindex's merge of the two.
DocumentID string `json:"documentID"`
// Op says whether the row was written or removed.
Op Op `json:"op"`
}
Event is one index-relevant change, written into the outbox by the same transaction that changed the row and consumed by a Syncer.
It names a document; it does not carry one. The Syncer reads the row back through the Source before indexing, which is what makes the index converge regardless of the order events arrive in or how many times each one does — see the package documentation.
The wire format is JSON, because the outbox's is: a payload is marshaled at Enqueue with encoding.EncodeJSON and republished verbatim inside a json.RawMessage.
func NewEvent ¶
NewEvent stamps an event for documentID at the current instant.
Event's fields are exported and it has no unexported state, so a test that needs a specific OccurredAt builds one directly rather than threading a clock through a package-level function.
func (Event) Message ¶
Message renders the event as an outbox.Message bound for topic, ready to hand to outbox.Writer.Enqueue inside the transaction that made the change:
err := client.WithTransaction(ctx, func(q database.Tx) error {
if err := updateOrder(ctx, q, order); err != nil {
return err
}
return writer.Enqueue(ctx, q,
searchsync.NewEvent(searchsync.OpUpsert, order.ID).Message("orders-index"))
})
The document ID becomes the outbox message key, which is what buys per- document ordering: the outbox admits a keyed message only when no older message with that key is still pending, so at most one event per document is ever in flight across the whole relay fleet. Events for different documents stay free to interleave.
type Fetcher ¶
type Fetcher[T any] interface { // Fetch returns the current document for each of ids, in any order, // omitting any whose row no longer exists. // // Omission is meaningful and must not be an error: it is how the Syncer // learns a row was deleted between the event being written and the // event being applied, and it removes the document rather than leaving // a tombstone in the index. Fetch(ctx context.Context, ids ...string) ([]Document[T], error) }
Fetcher reads documents back out of the source. It is the application's half of the change feed: this package decides when a document needs indexing, and the Fetcher decides what that document looks like.
Fetch is variadic because the reindex path and the change-feed path share it, and they ask for very different numbers of documents at a time — the change feed asks for one per event, a batch loader for many.
type Op ¶
type Op string
Op says what happened to the source row an Event describes.
const ( // OpUpsert says the row was created or updated. The Syncer reads it back // and indexes whatever it finds — including nothing, which it applies as a // delete. OpUpsert Op = "upsert" // OpDelete says the row is gone. The Syncer removes the document without // reading anything back; there is nothing left to read. OpDelete Op = "delete" )
type ReindexOption ¶
type ReindexOption func(*options)
ReindexOption configures a Reindexer.
func WithReindexBatchSize ¶
func WithReindexBatchSize(n int) ReindexOption
WithReindexBatchSize sets how many documents are scanned and written at a time. A non-positive size is ignored, leaving DefaultReindexBatchSize.
func WithReindexLogger ¶
func WithReindexLogger(logger logging.Logger) ReindexOption
WithReindexLogger attaches a logger.
func WithReindexMetricsProvider ¶
func WithReindexMetricsProvider(metricsProvider metrics.Provider) ReindexOption
WithReindexMetricsProvider attaches a metrics provider.
func WithReindexPruner ¶
func WithReindexPruner(pruner Enumerator) ReindexOption
WithReindexPruner supplies the index-side enumeration that lets a reindex delete documents whose source rows are gone.
Without one a reindex is upsert-only: it converges the index toward the source and never removes anything, which is right for a bootstrap into an empty index and for a mapping change, and not enough for drift repair. The two modes are named rather than one being a degraded version of the other — a reindexer reports which one it is on its spans, and the package documentation says what each is for.
A nil Enumerator is ignored rather than treated as pruning with nothing to prune, which would delete the entire index.
func WithReindexTracerProvider ¶
func WithReindexTracerProvider(tracerProvider tracing.Provider) ReindexOption
WithReindexTracerProvider attaches a tracer provider, enabling a span per reindex and per batch.
type ReindexResult ¶
type ReindexResult struct {
// Scanned is how many source documents the walk read.
Scanned int64
// Upserted is how many of them were written to the index. It trails
// Scanned only when the reindex failed mid-batch.
Upserted int64
// Pruned is how many documents were deleted because the index held them
// and the source no longer does. It is always zero without a pruner.
Pruned int64
// Batches is how many writes the walk made, upserts and deletes together.
Batches int64
}
ReindexResult is what a reindex did. It is returned even when the reindex fails partway, describing everything that landed before it stopped.
type Reindexer ¶
type Reindexer[T any] struct { // contains filtered or unexported fields }
Reindexer rebuilds an index from its source: for bootstrap, after a mapping change, or to repair drift the change feed missed.
It owns no goroutines and no ticker. Register it with a jobs.Scheduler, whose distributed lock is what makes the rebuild run once across a fleet rather than once per replica — see Job.
func NewReindexer ¶
func NewReindexer[T any](name string, source Scanner[T], target Target[T], opts ...ReindexOption) (*Reindexer[T], error)
NewReindexer builds a Reindexer over source and target.
name identifies the index in spans, in metric attributes, and — prefixed with ReindexJobPrefix — as the scheduler lock key the rebuild runs under. It must be stable across deploys: renaming it during a rollout lets an old replica and a new one rebuild the same index at the same time.
Without WithReindexPruner the rebuild is upsert-only. That is the right mode for a bootstrap and for a mapping change, and it cannot repair a document whose row is gone — nothing here can enumerate an index, so the walk over the index side has to be supplied.
func (*Reindexer[T]) Job ¶
Job returns the rebuild as a scheduled job, for registration with a jobs.Scheduler:
if err = scheduler.Register(reindexer.Job(jobs.MustCron("0 4 * * *"), time.Hour)); err != nil {
return err
}
leaseTTL must comfortably exceed how long the rebuild takes. The lease is not renewed while a job runs, so a rebuild that outlasts it lets a second replica start rebuilding the same index — wasteful rather than corrupting, since every write is idempotent, but it doubles the load on the source at the one moment the source is already under a full scan.
func (*Reindexer[T]) Reindex ¶
func (r *Reindexer[T]) Reindex(ctx context.Context) (*ReindexResult, error)
Reindex walks the source and writes every document to the index, pruning the documents the index holds and the source does not when a pruner was supplied.
A failed batch stops the walk and returns what landed before it, rather than skipping ahead. There is nothing to salvage by continuing: the walk is a keyset scan that has to resume from somewhere, the next scheduled rebuild starts over from the beginning anyway, and every write it repeats is idempotent. A half-finished rebuild leaves the index in exactly the state a half-finished rebuild should — closer to the source than it was.
Example ¶
ExampleReindexer_Reindex rebuilds an index that has drifted in both directions: missing a document the source has, and holding one whose row is gone. Repairing the second half needs the Enumerator — nothing in this module can enumerate a search index, so the walk over the index side is supplied.
package main
import (
"context"
"fmt"
"slices"
"sort"
searchsync "github.com/primandproper/platform-go/v13/search/sync"
)
// order is the domain object, and orderDoc is what the index holds. The
// application owns both, and owns the transform between them — this package
// never looks inside either.
type order struct {
ID string
Customer string
Status string
}
type orderDoc struct {
Customer string `json:"customer"`
Status string `json:"status"`
}
// orderStore stands in for the table. A real one runs two queries: one that
// loads orders by ID, and one that pages them by ID for a rebuild.
type orderStore struct {
orders map[string]order
}
func (s *orderStore) document(o order) searchsync.Document[orderDoc] {
return searchsync.Document[orderDoc]{
ID: o.ID,
Body: &orderDoc{Customer: o.Customer, Status: o.Status},
}
}
// Fetch is the change feed's half: current documents for the IDs asked about,
// omitting any whose row is gone. The omission is what tells the Syncer to
// remove the document rather than leave a tombstone.
func (s *orderStore) Fetch(_ context.Context, ids ...string) ([]searchsync.Document[orderDoc], error) {
docs := make([]searchsync.Document[orderDoc], 0, len(ids))
for _, id := range ids {
if o, ok := s.orders[id]; ok {
docs = append(docs, s.document(o))
}
}
return docs, nil
}
// Scan is the rebuild's half: a keyset walk in ascending byte order. Against
// Postgres this is ORDER BY id COLLATE "C" — the default collation sorts
// case-insensitively, which is a different order, and the rebuild's pruning
// half compares this stream against the index's.
func (s *orderStore) Scan(_ context.Context, after string, limit int) ([]searchsync.Document[orderDoc], error) {
ids := make([]string, 0, len(s.orders))
for id := range s.orders {
if id > after {
ids = append(ids, id)
}
}
sort.Strings(ids)
ids = ids[:min(len(ids), limit)]
docs := make([]searchsync.Document[orderDoc], 0, len(ids))
for _, id := range ids {
docs = append(docs, s.document(s.orders[id]))
}
return docs, nil
}
// memoryIndex is a stand-in for a search backend, and doubles as the
// Enumerator a rebuild prunes with — a real one walks the backend's own
// browse, scroll, or select.
type memoryIndex struct {
docs map[string]*orderDoc
}
func (i *memoryIndex) Upsert(_ context.Context, docs ...searchsync.Document[orderDoc]) error {
for _, doc := range docs {
i.docs[doc.ID] = doc.Body
}
return nil
}
func (i *memoryIndex) Delete(_ context.Context, ids ...string) error {
for _, id := range ids {
delete(i.docs, id)
}
return nil
}
func (i *memoryIndex) Scan(_ context.Context, after string, limit int) ([]string, error) {
ids := make([]string, 0, len(i.docs))
for id := range i.docs {
if id > after {
ids = append(ids, id)
}
}
sort.Strings(ids)
return ids[:min(len(ids), limit)], nil
}
func (i *memoryIndex) contents() []string {
ids := make([]string, 0, len(i.docs))
for id := range i.docs {
ids = append(ids, id)
}
slices.Sort(ids)
return ids
}
func main() {
ctx := context.Background()
store := &orderStore{orders: map[string]order{
"order-1": {ID: "order-1", Customer: "ada", Status: "placed"},
"order-2": {ID: "order-2", Customer: "grace", Status: "placed"},
}}
index := &memoryIndex{docs: map[string]*orderDoc{
"order-1": {Customer: "ada", Status: "placed"},
"order-9": {Customer: "gone", Status: "placed"},
}}
reindexer, err := searchsync.NewReindexer[orderDoc]("orders", store, index,
searchsync.WithReindexPruner(index))
if err != nil {
panic(err)
}
result, err := reindexer.Reindex(ctx)
if err != nil {
panic(err)
}
fmt.Printf("scanned %d, upserted %d, pruned %d\n", result.Scanned, result.Upserted, result.Pruned)
fmt.Println("indexed:", index.contents())
}
Output: scanned 2, upserted 2, pruned 1 indexed: [order-1 order-2]
type Scanner ¶
type Scanner[T any] interface { // Scan returns up to limit documents whose IDs sort strictly after // `after`, in ascending byte order. An empty `after` starts at the // beginning, and a page shorter than limit ends the walk. // // Ascending *byte* order, as Go's < compares strings, not whatever // collation the database defaults to. Postgres's en_US.UTF-8 sorts // case-insensitively and ignores punctuation, which is a different // order; a keyset walk wants ORDER BY id COLLATE "C". The Reindexer // checks the order it is given rather than trusting it, because the // pruning half of a reindex compares two ordered streams and a // disagreement between their orders would delete live documents. Scan(ctx context.Context, after string, limit int) ([]Document[T], error) }
Scanner walks every document in the source, for a reindex.
It is separate from Fetcher because the two paths need different things and most applications implement them against different queries. A type that does both satisfies both.
type Stamper ¶
type Stamper interface {
// Add records that the index now holds the documents with these ids.
Add(ids ...string)
}
Stamper records that the index accepted documents, so the rows behind them can be marked as indexed.
It is one method, and it is deliberately the non-blocking, error-free one. Stamping is a side effect of applying an event: the event has already been applied by the time it happens, nothing reads the column back in the same breath, and a Syncer that failed an event because it could not update a bookkeeping timestamp would dead-letter a document the index is holding correctly.
*batching.Buffer[string] satisfies it as it stands, which is what it is shaped for — see NewStampBuffer. An application stamping through something else implements one method.
type Syncer ¶
type Syncer[T any] struct { // contains filtered or unexported fields }
Syncer applies index change events to one index.
It owns no goroutines and reads from no queue. Handle is a jobs.Handler, so the consumption, concurrency, retry, dead-lettering and panic containment around it all come from jobs.Pool, which already does those four things carefully — see the package documentation for the wiring.
That holds with a Stamper too: the buffering behind one, and the goroutine that flushes it, belong to the caller who built it.
func NewSyncer ¶
func NewSyncer[T any](name string, source Fetcher[T], target Target[T], opts ...SyncerOption) (*Syncer[T], error)
NewSyncer builds a Syncer that reads documents from source and writes them to target.
name identifies the index in every span and every metric attribute. It must be unique within a process and stable across deploys — two syncers under one name give one lag reading covering both, and renaming one starts its history over.
func (*Syncer[T]) Apply ¶
Apply brings the index into agreement with the source for one document.
The event says which document changed; the source says what it now is. An upsert reads the row back and indexes whatever it finds — and finding nothing is a delete, not an error, because the source is what the index converges toward and the source says the document is gone. A delete needs no read.
That indirection is what makes the sync order-insensitive. Two events for one document applied out of order both end up indexing the row's current state, and a redelivered event indexes it again, which is the same thing.
func (*Syncer[T]) Handle ¶
Handle decodes one relayed event and applies it. It satisfies jobs.Handler, which is how it is meant to be used:
pool, err := jobs.NewPool(ctx, &jobs.PoolConfig{
Topic: "orders-index",
Concurrency: 8,
}, consumerProvider, syncer.Handle, jobs.WithPoolDeadLetter(deadLetter))
A payload that will not decode, or an event that is not applicable, is returned wrapped with retry.Unretryable so the Pool dead-letters it at once instead of failing the same way three more times while healthy events wait behind it.
type SyncerOption ¶
type SyncerOption func(*options)
SyncerOption configures a Syncer. The zero configuration works: an absent logger logs nowhere, an absent tracer provider traces nowhere, and an absent metrics provider records nothing.
func WithSyncerClock ¶
func WithSyncerClock(c clock.Clock) SyncerOption
WithSyncerClock swaps the clock the lag is measured against. Tests generally do not need it: under testing/synctest the default clock already runs on bubble time.
func WithSyncerLogger ¶
func WithSyncerLogger(logger logging.Logger) SyncerOption
WithSyncerLogger attaches a logger.
func WithSyncerMetricsProvider ¶
func WithSyncerMetricsProvider(metricsProvider metrics.Provider) SyncerOption
WithSyncerMetricsProvider 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 package documentation.
func WithSyncerStamper ¶
func WithSyncerStamper(stamper Stamper) SyncerOption
WithSyncerStamper supplies what a Syncer tells about the documents an index accepted, so that last_indexed_at on the rows behind them can be maintained.
The column is a convention this module already derives from — querygen treats its presence as what marks a table as one search/sync mirrors, forbids any caller from supplying it, and emits the reindex scan that reads it. This is the writer that convention names. Without one the Syncer stamps nothing, which is the right behavior for an index whose source table does not carry the column at all.
Pass a Buffer from NewStampBuffer rather than a direct writer. One UPDATE per applied document from every worker of a jobs.Pool at once is how a stamping write deadlocks against itself; the reason, and what the Buffer does about it, are in NewStampBuffer.
A Syncer stamps only the documents the index actually took. A delete stamps nothing, because there is no document left to record having indexed; an upsert whose row has since vanished is applied as a delete and stamps nothing either; and a failed write stamps nothing, since the whole value of the column is that it says what the index holds rather than what was attempted.
There is no reindex counterpart, and that is a decision rather than an omission. A Reindexer writes every document there is, so stamping it would make the column a record of when the last rebuild ran — the same value on every row — rather than of how current each document is, which is the reading the reindex scan itself depends on.
func WithSyncerTracerProvider ¶
func WithSyncerTracerProvider(tracerProvider tracing.Provider) SyncerOption
WithSyncerTracerProvider attaches a tracer provider, enabling a span per applied event.
type Target ¶
type Target[T any] interface { // Upsert inserts or replaces documents, keyed by ID. Upsert(ctx context.Context, docs ...Document[T]) error // Delete removes documents by ID. IDs the index does not hold are not // an error. Delete(ctx context.Context, ids ...string) error }
Target is the index side of the sync, narrowed to the two operations convergence needs. TextTarget and VectorTarget adapt this module's search packages to it; an application indexing into something else implements two methods.
Both operations must be idempotent, and both already are for every backend here: an upsert is last-write-wins on the document ID and a delete of an absent document is not an error. That is what lets the whole pipeline run on at-least-once delivery without a deduplication key.
func TextTarget ¶
func TextTarget[T any](index textsearch.IndexManager) (Target[T], error)
TextTarget adapts a textsearch.IndexManager — Algolia, Elasticsearch, or the noop — to a Target.
It takes the IndexManager half rather than a whole textsearch.Index[T] because syncing never reads: the write half is the entire surface this needs, and asking for the read half too would make the document type a Syncer holds and the hit type a search returns the same type, which they routinely are not. That is also why the type parameter is on TextTarget rather than inferred — it is the Syncer's document type, spelled once at the wiring site.
func VectorTarget ¶
func VectorTarget[T any](index vectorsearch.IndexWriter[T]) (Target[T], error)
VectorTarget adapts a vectorsearch.IndexWriter — pgvector, Qdrant, or the noop — to a Target.
T is the vector index's metadata type, so a Document's Body is what comes back as a QueryResult's Metadata. The embedding comes from the Source along with the body: computing it here would mean this package holding an embeddings client and deciding when to spend on it, which is the application's call and its cost.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
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.
|
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. |