Dowser.MappingCacher (Dowser.Client v0.5.0)

View Source

Per-node cache of index mappings, shared by the backend packages.

A backend package defines its own cacher with use, which gives it its own process, its own ETS table and its own application environment:

defmodule Dowser.OpenSearch.MappingCacher do
  use Dowser.MappingCacher, otp_app: :dowser_opensearch
end

The backend package doesn't start it: an application that casts against the cluster's mappings adds it to its own supervision tree, with its options:

children = [MyApp.Search, {Dowser.OpenSearch.MappingCacher, ttl: :timer.minutes(10)}]

Asked without having been started, it reports {:not_started, cacher} — which Dowser.Codec treats as a mapping it couldn't fetch.

The functions below take that module as their first argument (server); the module use defines exposes them without it — start_link/1, child_spec/1, key/2, get/2, lookup/2, fetch/2, put/3, invalidate/2 and clear/0 — and implements Dowser.Mapping with fetch_mapping/2, which makes it a backend's default mapping source.

A GenServer owns an ETS table; reads go straight to ETS (concurrent, lock-free), and only misses/expired entries go through the GenServer — which de-duplicates concurrent fetches for the same key (single-flight).

Entries are keyed by {endpoint, scope, index}, so the same index reached through different contexts is cached separately, and each fetch honours its context. The scope is what distinguishes two contexts pointing at the same endpoint — see key/2.

  • :ttl — entry lifetime in ms (default 5 min).
  • :sweep_interval — how often expired entries are actively purged, in ms (default 1 min). nil/0 disables active sweeping; lazy expiration on read still applies.
  • :fetch — (context, index -> {:ok, value} | {:error, reason}), how a mapping is loaded on a miss. Defaults to a Dowser.Client GET /{index}/_mapping call.

  • :eager — preload at startup via handle_continue/2: false (default, lazy), a list of {context, index}, or a 0-arity fun returning one.

Mappings that are never fetched

A mapping given in the backend's application environment answers for its index without any request at all — and so can never fail to be fetched:

config :dowser_opensearch,
  mappings: %{
    "posts" => %{
      "properties" => %{"published_at" => %{"type" => "date", "format" => "strict_date_time"}}
    }
  }

It wins over the cache. Use it for an index whose mapping is pinned, and in test suites, where it makes casting behave as it does in production without a cluster to ask (put/4 does the same through a running cacher).

When a fetch fails

lookup/4 distinguishes "there is no mapping" from "the mapping could not be fetched"; fetch/4 is the lenient read that folds both into nil. The difference matters because the second silently changes the type of what a caller gets back — see Dowser.Codec's :mapping_failure.

Summary

Types

A cache key: {endpoint, scope, index}.

Functions

Returns a specification to start this module under a supervisor.

A child spec starting the cacher named server.

Clears the whole cache of server.

Like lookup/4, but returns the mapping directly, or nil when there is none to be had or the fetch failed.

Returns the mapping for {context, index} from server, fetching lazily on a miss.

Invalidates a single {context, index} entry of server.

The cache key for {context, index}: {endpoint, scope, index}.

Like get/3, but tells a mapping that cannot exist apart from one that could not be fetched — which get/3 reports the same way, and which the caller must not: casting against no mapping is correct for the first and silently wrong for the second.

Caches mapping for index directly in server, without fetching anything.

Starts the cacher named server, which also names its ETS table.

Types

key()

@type key() :: {String.t(), binary() | nil, term()}

A cache key: {endpoint, scope, index}.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

child_spec(server, opts)

@spec child_spec(atom(), keyword()) :: Supervisor.child_spec()

A child spec starting the cacher named server.

clear(server)

@spec clear(atom()) :: :ok

Clears the whole cache of server.

fetch(server, otp_app, context, index)

@spec fetch(atom(), atom(), Dowser.Repo.t(), term()) :: map() | nil

Like lookup/4, but returns the mapping directly, or nil when there is none to be had or the fetch failed.

The lenient read: a mapping that can't be resolved degrades a cast to identity rather than failing the request.

get(server, context, index)

@spec get(atom(), Dowser.Repo.t(), term()) :: {:ok, term()} | {:error, term()}

Returns the mapping for {context, index} from server, fetching lazily on a miss.

invalidate(server, context, index)

@spec invalidate(atom(), Dowser.Repo.t(), term()) :: :ok

Invalidates a single {context, index} entry of server.

key(context, index)

@spec key(Dowser.Repo.Config.t(), term()) :: key()

The cache key for {context, index}: {endpoint, scope, index}.

Two contexts pointing at the same endpoint do not necessarily see the same mapping — different credentials can mean field-level security hiding fields, or an alias resolving to a different concrete index — so the credentials are part of what identifies an entry.

They are hashed into it rather than stored. A cache key lives in an ETS table for the lifetime of the entry, where any process can read it and a crash dump would carry it; Dowser.Repo.Config goes as far as redacting :auth from its own Inspect, and putting it in a table here would undo that.

The hash is SHA-256, truncated to 128 bits. An application that builds contexts from end-user credentials makes the hashed value attacker- influenced, and a collision there would serve one tenant another's mapping — so the cheaper :erlang.phash2/1 is the wrong tool: its default range is 2^27, which a targeted collision search exhausts in seconds.

:http_opts is hashed alongside :auth, since a credential can also arrive as a header and a proxy or TLS setting can change which cluster answers. The purely client-side fields (:profile, :keys, :decoder, :encoder) are not: they can't change what the backend returns. A plain unauthenticated context hashes to nil, so the common key stays readable.

lookup(server, otp_app, context, index)

@spec lookup(atom(), atom(), Dowser.Repo.t(), term()) ::
  {:ok, map() | nil} | {:error, term()}

Like get/3, but tells a mapping that cannot exist apart from one that could not be fetched — which get/3 reports the same way, and which the caller must not: casting against no mapping is correct for the first and silently wrong for the second.

  • {:ok, mapping} — a static mapping (from otp_app's environment), a cached one, or a freshly fetched one.
  • {:ok, nil} — there is no mapping to be had: no index named, or no repo to ask.
  • {:error, {:not_started, server}} — the cacher isn't running. A backend package doesn't start it: an application that casts against the cluster's mappings starts it in its supervision tree. A static mapping still answers without it.
  • {:error, reason} — the fetch failed. The mapping may well exist; this request just couldn't see it. Dowser.Codec turns this into a Dowser.MappingError by default, rather than casting nothing and handing back raw JSON values.

A static mapping wins over the cache and is never fetched, so it cannot fail.

put(server, index, mapping, opts \\ [])

@spec put(atom(), term(), map(), keyword()) :: :ok

Caches mapping for index directly in server, without fetching anything.

The seam tests use, so that casting behaves the way it does in production instead of degrading to identity for want of a cluster to ask:

setup do
  Dowser.OpenSearch.MappingCacher.put("posts", %{"properties" => %{...}}, repo: MyApp.Search)
end

Options

  • :repo (required) — the repo the entry belongs to, as in key/2: a Dowser.Repo module or [endpoint: ...]. Raises an ArgumentError when it can't be resolved.
  • :ttl — entry lifetime in ms (default :infinity, unlike a fetched entry: an entry put by hand is not a cached answer that can go stale).

start_link(server, opts \\ [])

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

Starts the cacher named server, which also names its ETS table.