Dowser.Opensearch.MappingCacher (Dowser.Opensearch v0.1.0)

View Source

Per-node cache of OpenSearch index mappings.

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 _mapping call. Have it return the compiled schema rather than the raw mapping to keep cached values small.

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

Whether the cacher is started at all (and with which options) is decided by the supervisor — see Dowser.Opensearch.Application.

Mappings that are never fetched

A mapping given in the 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"}}}}

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/3 does the same through a running cacher).

When a fetch fails

lookup/2 distinguishes "there is no mapping" from "the mapping could not be fetched"; fetch/2 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.Opensearch.Codec's :mapping_failure.

Summary

Functions

Returns a specification to start this module under a supervisor.

Clears the whole cache.

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

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

Invalidates a single {context, index} entry.

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

Like get/2, but tells a mapping that cannot exist apart from one that could not be fetched — which get/2 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, without fetching anything.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

clear()

Clears the whole cache.

fetch(context, index)

@spec fetch(Dowser.Client.Context.ref(), term()) :: map() | nil

Like lookup/2, 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. Dowser.Opensearch.Codec uses it only under mapping_failure: :ignore.

get(context, index)

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

invalidate(context, index)

Invalidates a single {context, index} entry.

key(context, index)

@spec key(Dowser.Client.Context.t(), term()) :: {String.t(), binary() | nil, term()}

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.Client.Context 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 OpenSearch returns. A plain unauthenticated context hashes to nil, so the common key stays readable.

lookup(context, index)

@spec lookup(Dowser.Client.Context.ref(), term()) ::
  {:ok, map() | nil} | {:error, term()}

Like get/2, but tells a mapping that cannot exist apart from one that could not be fetched — which get/2 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, a cached one, or a freshly fetched one.
  • {:ok, nil} — there is no mapping to be had: no index named, or no cacher running (an application that doesn't cast at all).
  • {:error, reason} — the fetch failed. The mapping may well exist; this request just couldn't see it. Dowser.Opensearch.Codec turns this into a Dowser.Opensearch.MappingError by default, rather than casting nothing and handing back raw JSON values.

A static mapping (config :dowser_opensearch, mappings: %{...}) wins over the cache and is never fetched, so it cannot fail.

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

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

Caches mapping for index directly, 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" => %{...}})
end

Options

  • :context — the context the entry belongs to, as in key/2 (default nil, the unauthenticated one).
  • :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).

For a mapping that is always known — a pinned index, a test suite that never starts the cacher — config :dowser_opensearch, mappings: %{...} needs no running cacher at all.

start_link(opts \\ [])