omnifeed

failover

import "github.com/kinorai/omnifeed/internal/search/failover"

Package failover composes two domain.Searchers into one: a primary that answers every query, and a backup asked only when the primary’s answer is an error, empty, thin, or degraded. The composite is itself a domain.Searcher, so a longer chain is nesting — failover(a, failover(b, c)) — and the transports never learn that more than one searcher exists.

The pieces are kept apart on purpose: Policy decides (pure), merge combines two rankings (pure), and Searcher only runs the two calls and reports what happened.

Index

type Action

Action says what the composite does with the primary’s answer.

type Action int

const (
    // Keep returns the primary's answer as is; the backup is not asked.
    Keep Action = iota
    // Replace answers with the backup alone: the primary has nothing worth
    // keeping.
    Replace
    // Merge answers with both, interleaved: the primary's results are real but
    // weaker than usual, and the backup tops them up.
    Merge
)

type Config

Config configures the Searcher.

type Config struct {
    Primary domain.Searcher
    Backup  domain.Searcher
    Policy  Policy
    // PrimaryBudget, when > 0, bounds how long the primary may take before the
    // backup is asked instead. Without it a hung primary spends its whole
    // timeout-and-retry budget first (tens of seconds on SearXNG) and the
    // backup only starts after that. A primary cut off by the budget counts
    // as an error, so the backup replaces it.
    PrimaryBudget time.Duration
    Logger        *slog.Logger
    // Metrics, when non-nil, receives one omnifeed_search_fallbacks_total
    // increment per search handed to the backup.
    Metrics *observability.Metrics
}

type Decision

Decision is the policy’s verdict on one primary answer.

type Decision struct {
    Action  Action
    Trigger Trigger
}

type Policy

Policy decides, from the primary’s answer alone, whether the backup is worth asking. It is pure — no I/O, no clock — so every rule is a table test.

type Policy struct {
    // MinResults is the result count below which a successful search counts as
    // thin and is topped up. 0 disables the thin trigger.
    MinResults int
}

func (Policy) Decide

func (p Policy) Decide(ctx context.Context, limit int, report domain.SearchReport, err error) Decision

Decide returns the action for the primary’s (report, err) to a query that asked for at most limit results (<= 0: no limit).

Two answers are never failed over. A caller that has gone away: the backup would answer nobody, and spend its own quota doing it. And a query refused for its input (domain.ErrInvalidQuery): the backup would refuse it too.

type Searcher

Searcher answers with Primary, and with Backup when Policy says so.

type Searcher struct {
    // contains filtered or unexported fields
}

func New

func New(cfg Config) *Searcher

New returns a Searcher wired with the given config.

func (*Searcher) Name

func (s *Searcher) Name() string

Name joins the members’ names, e.g. “searxng+tinyfish”. It is the searcher label on omnifeed_search_requests_total, so a dashboard can tell a deployment with a backup from one without. Computed once in New: transports read it on every search.

func (s *Searcher) Search(ctx context.Context, query string, opts domain.SearchOptions) (domain.SearchReport, error)

Search asks the primary, and the backup when the policy calls for it.

When the backup fails too, or replaces a failed primary with nothing, the primary’s answer stands — its results if it had any, its error if not. The caller then sees the original failure, with its reason (quota_exhausted, timeout, …) intact: an empty success would read as “nothing exists” where the truth is “retry later”.

type Trigger

Trigger names why the backup was asked.

type Trigger string

const (
    // TriggerError means the primary failed (pacing refused it, it timed out, the
    // upstream answered an error status, ...).
    TriggerError Trigger = "error"
    // TriggerEmpty means the primary succeeded with no results — on a pool of
    // engines that block silently, the shape of an outage.
    TriggerEmpty Trigger = "empty"
    // TriggerThin means the primary returned fewer results than Policy.MinResults.
    TriggerThin Trigger = "thin"
    // TriggerDegraded means the primary succeeded without a source it relies on
    // (domain.SearchReport.Degraded).
    TriggerDegraded Trigger = "degraded"
)

Generated by gomarkdoc