Perplexed.Search.SearXNG.Pool (Perplexed v0.2.0)

Copy Markdown View Source

Health, speed and rate-limit state for SearXNG instances, learned from the searches this node makes. One pool runs under the :perplexed application; tests and hosts can start more with :name.

Ranking

Each instance keeps exponentially weighted moving averages (EWMA) of its response time and failure rate, and is scored

latency_ewma_ms * (1 + failure_penalty * failure_ewma) * jitter

lowest first. An instance not measured yet scores its prior: the searx.space median when it was seed/2ed, else :prior_ms. :jitter multiplies each score by a random factor in 1 +/- jitter, so the fastest few share the load instead of one taking every search. :min_interval_ms ranks an instance used that recently after every rested one, least recently used first.

Circuit breaker

Each instance is a small state machine:

:closed --refusal--> :open --cooldown timer--> :half_open
   ^                   ^                          |
   |                   +--------trial fails-------+
   +------------------------trial answers---------+

A refusal (HTTP 429 or 503, a redirect from SearXNG's limiter, 403/418, a bot challenge, a timeout, a transport error) opens the circuit. The cooldown is the server's own Retry-After / RateLimit-Reset when it sent one, otherwise :cooldown_ms doubled per consecutive refusal, capped at :max_cooldown_ms either way. When it ends a timer moves the instance to :half_open, where it hands out a single trial lease: an answer closes the circuit, a refusal opens it again for twice as long. An open instance is never checked out. An empty or off-topic answer is a soft failure: the instance is reachable, so the circuit closes, but its failure average rises, and :soft_strikes (default 3) in a row open the circuit like a refusal: an instance that keeps answering off-topic is skipped for a while however fast it is.

Leases

checkout/3 picks and reserves instances in one step inside the server, so concurrent searches spread out instead of racing onto the same instance. The pool monitors the caller; if it dies, its leases are released. record/4 reports the outcome and returns the lease.

Rankings and stats are read straight from ETS, so inspection never waits on the process. Instances idle longer than :forget_after_ms are swept out.

Every state change emits [:perplexed, :searxng, :circuit] with metadata instance, from, to and cooldown_ms.

Summary

Functions

Reserves up to count of candidates, best first, skipping open circuits and half-open ones whose trial is already out. Returns the leases to pass to record/4.

Returns a specification to start this module under a supervisor.

The instances in candidates that checkout/3 would hand out, best first, without reserving them. Takes checkout/3's options.

Reports how a leased search went and releases the lease. outcome is {:ok, hits}, {:error, reason}, or {:error, reason, retry_after_ms} when the instance said how long to wait.

Forgets everything the pool learned and cancels its timers.

Sets prior response times (ms) for instances not measured yet, such as searx.space medians from Perplexed.Search.SearXNG.Directory.

Every instance the pool knows, with its stats, best first.

Starts a pool.

The stats for instance (fresh ones when it has none yet).

Types

circuit()

@type circuit() :: :closed | :open | :half_open

lease()

@type lease() :: {String.t(), reference()}

outcome()

@type outcome() ::
  {:ok, non_neg_integer()}
  | {:error, atom()}
  | {:error, atom(), non_neg_integer() | nil}

stats()

@type stats() :: %{
  circuit: circuit(),
  latency_ms: float() | nil,
  failure_rate: float(),
  successes: non_neg_integer(),
  failures: non_neg_integer(),
  consecutive_failures: non_neg_integer(),
  consecutive_soft: non_neg_integer(),
  cooldown_until: integer() | nil,
  in_flight: non_neg_integer(),
  last_outcome: atom() | nil,
  last_used_at: integer() | nil,
  prior_ms: number() | nil,
  seeded_at: integer() | nil
}

Functions

checkout(candidates, count, opts \\ [])

@spec checkout([String.t()], non_neg_integer(), keyword()) :: [lease()]

Reserves up to count of candidates, best first, skipping open circuits and half-open ones whose trial is already out. Returns the leases to pass to record/4.

Options: :pool, :jitter (0 to 1, default 0), :min_interval_ms (default 0), :prior_ms (default 2500), :failure_penalty (default 3.0) and :random (a zero-arity function returning a float in [0, 1), for tests).

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

rank(candidates, opts \\ [])

@spec rank([String.t()], keyword()) :: [String.t()]

The instances in candidates that checkout/3 would hand out, best first, without reserving them. Takes checkout/3's options.

record(lease, outcome, elapsed_ms, opts \\ [])

@spec record(lease(), outcome(), non_neg_integer(), keyword()) :: :ok

Reports how a leased search went and releases the lease. outcome is {:ok, hits}, {:error, reason}, or {:error, reason, retry_after_ms} when the instance said how long to wait.

reset(opts \\ [])

@spec reset(keyword()) :: :ok

Forgets everything the pool learned and cancels its timers.

seed(priors, opts \\ [])

@spec seed([{String.t(), number()}], keyword()) :: :ok

Sets prior response times (ms) for instances not measured yet, such as searx.space medians from Perplexed.Search.SearXNG.Directory.

snapshot(opts \\ [])

@spec snapshot(keyword()) :: [{String.t(), stats()}]

Every instance the pool knows, with its stats, best first.

start_link(opts \\ [])

@spec start_link(keyword()) :: GenServer.on_start()

Starts a pool.

Options: :name (default Perplexed.Search.SearXNG.Pool), :cooldown_ms (default 60000), :max_cooldown_ms (default 1800000), :forget_after_ms (default 21600000) and :sweep_every_ms (default 600000) and :soft_strikes (default 3).

stats(instance, pool \\ __MODULE__)

@spec stats(String.t(), GenServer.name()) :: stats()

The stats for instance (fresh ones when it has none yet).