Nebulex Documentation

repository·main·Indexed 23 days ago

https://github.com/elixir-nebulex/nebulex

An in-memory and distributed caching toolkit for Elixir. Nebulex provides a consistent abstraction layer allowing developers to switch between caching backends such as Redis, Memcached, or local memory. It features declarative caching via decorators, a generational local cache adapter (Nebulex.Adapters.Local), and comprehensive operations for managing entries, counters, and TTL.

Tokens
35.9K
Snippets
96
Records
168
Agent score
79%

What's inside Nebulex

  1. Rebinding variables in block expressions

    main

    Elixir variables are immutable, but can be rebound. When using block expressions like if, case, or cond, you must bind the result of the entire expression to a variable if you want to use the updated value. You cannot rebind a variable inside the block and expect it to persist outside.

    # INVALID: the rebind inside the block is lost
    if connected?(socket) do
      socket = assign(socket, :val, val)
    end
    
    # VALID: the result of the if expression is bound to socket
    socket = 
      if connected?(socket) do
        assign(socket, :val, val)
      end
  2. Understand the Cache-as-SoR pattern

    main

    The cache-as-SoR pattern uses the cache as the primary System-of-Record. The application delegates reading and writing to the cache, which then handles the interaction with the underlying SoR via Read-through and Write-through strategies.

    Advantages

    • Cleaner application code: SoR operations are abstracted away.
    • Flexible strategies: Allows choosing between write-through or write-behind on a per-cache basis.
    • Thundering-herd protection: The cache can manage concurrent requests for the same missing key.

    Disadvantages

    • Abstraction overhead: The code path is less visible and can be harder to debug because the behavior is abstracted behind decorators.
  3. How the v3.0 Cache API works: Ok/Error vs Bang functions

    main

    Nebulex v3 introduces a new API design with two distinct flavors for handling cache operations:

    1. Ok/Error Tuple API: All cache functions now return either {:ok, result} (on success) or {:error, reason} (on failure). This is the preferred method when you need to pattern-match on different outcomes (e.g., handling a Nebulex.KeyError).
    2. Bang (!) API: For every function, there is an alternative version ending in a bang (!). These functions are preferred when you expect the operation to always succeed and want to avoid manual pattern matching. They will raise an error if the operation fails.

    Example of Ok/Error pattern matching:

    case MyApp.Cache.fetch("key") do
      {:ok, value} ->
        # logic for success
      {:error, %Nebulex.KeyError{}} ->
        # logic for missing key
      {:error, reason} ->
        # logic for other errors
    end

    Example of Bang API usage:

    # Instead of :ok = MyApp.Cache.put("key", "value")
    :ok = MyApp.Cache.put!("key", "value")
  4. Use the Coherent adapter for distributed invalidation

    main

    The Nebulex.Adapters.Coherent adapter provides local caching with distributed invalidation. Each node maintains its own local cache, but write operations trigger invalidation events (via Phoenix.PubSub) that cause other nodes to delete the invalidated keys from their local caches. This ensures that the next read on other nodes results in a cache miss and fetches fresh data.

    When configuring, the primary_storage_adapter must be passed inside adapter_opts.

    defmodule MyApp.CoherentCache do
      use Nebulex.Cache,
        otp_app: :my_app,
        adapter: Nebulex.Adapters.Coherent,
        adapter_opts: [primary_storage_adapter: Nebulex.Adapters.Local]
    end
    
    # Configuration example
    config :my_app, MyApp.CoherentCache,
      primary: [
        gc_interval: :timer.hours(12),
        max_size: 1_000_000
      ]
  5. Understand Nebulex adapter behaviors

    main

    When implementing a custom adapter, you must decide which behaviors to implement based on the required functionality:

    • Nebulex.Adapter: The base behaviour. All adapters must implement this.
    • Nebulex.Adapter.KV (Required): Provides core key-value operations such as get, put, and delete. All adapters must implement this.
    • Nebulex.Adapter.Queryable (Optional): Provides query-based operations like delete_all and get_all with filters. Recommended for most adapters.
    • Nebulex.Adapter.Transaction, Nebulex.Adapter.Info, and Nebulex.Adapter.Observable (Optional): Advanced features for transactions, metadata, and observability.
  6. Understand Nebulex cache topologies

    main

    Nebulex provides several topologies depending on your scaling and consistency needs:

    • Local (Nebulex.Adapters.Local): A single-node generational cache with automatic garbage collection.
    • Partitioned (Nebulex.Adapters.Partitioned): A distributed cache that shards data across cluster nodes using consistent hashing.
    • Multilevel (Nebulex.Adapters.Multilevel): A hierarchical cache (e.g., an L1 local cache backed by an L2 distributed cache).
    • Coherent (Nebulex.Adapters.Coherent): A local cache that uses Nebulex.Streams for distributed invalidation, ideal for read-heavy workloads.
  7. Implement declarative caching with Nebulex.Caching

    main

    Nebulex provides declarative caching via the Nebulex.Caching module. By calling use Nebulex.Caching in your module, you gain access to three decorators that automate the cache lifecycle:

    • @decorate cacheable(...): Read-through. Skips function execution on a cache hit; populates the cache on a miss.
    • @decorate cache_put(...): Write-through. Always executes the function and always updates the cache with the result.
    • @decorate cache_evict(...): Invalidation. Executes the function and removes the corresponding entries from the cache.

    These decorators capture key expressions and option lambdas at compile time. Runtime resolution (key generation, cache selection, etc.) is handled by Nebulex.Caching.Decorators.Runtime.

  8. Organize cache entries using Entry Tagging

    main

    Entry tagging allows you to logically group cache entries for easier management and bulk operations. You can attach tags when storing entries via put/4 or via the @cacheable decorator.

    Storing with tags: Use the tag: option in MyCache.put/4 or the opts: [tag: ...] option in the @cacheable decorator.

    Evicting by tags: You can evict all entries associated with a specific tag by using a match_spec in a cache_evict query.

    # Store a product with a category tag
    MyCache.put(
      product.id,
      product,
      tag: "category:#{product.category_id}"
    )
    
    # Using tags with decorators
    @decorate cacheable(
                key: id,
                opts: [ttl: :timer.hours(1), tag: :catalog]
              )
    def get_product(id) do
      Repo.get(Product, id)
    end
    
    # Evict all catalog entries
    @decorate cache_evict(
                query: fn _ ->
                  match_spec(tag: t, where: t == :catalog)
                end
              )
    def refresh_catalog do
      CatalogCache.refresh()
    end
  9. Understand the Nebulex architecture and layers

    main

    Nebulex is organized into three distinct layers to provide a unified caching abstraction similar to how Ecto works for databases:

    1. Application Layer: Your application code using the Nebulex.Cache API or declarative caching decorators.
    2. Core Layer (lib/nebulex/): Provides the abstraction and public API. It contains no storage logic; instead, it defines the behaviours that adapters must implement.
    3. Adapter Layer: Separate Hex packages (e.g., nebulex_redis_adapter, nebulex_local) that contain the actual storage implementation.

    This decoupling allows you to swap backends (e.g., from local ETS in development to Redis in production) without changing your application code.

  10. Use pattern matching instead of conditional logic

    main

    Prefer pattern matching over if/else or case statements. Specifically, use multiple function clauses (matching on function heads) instead of complex conditional logic inside a single function body.

    Note on Maps: %{} matches any map, including those with keys. To check if a map is truly empty, use map_size(map) == 0 in a guard.

  11. Filter cache events for specific conditions

    main

    To improve performance and clarity, you can use filters when registering event listeners. A filter is a function that receives the event and returns true to process it or false to ignore it. Filtering happens before the handler is called, reducing unnecessary function calls.

    You can filter by:

    • Event Type: e.g., only :inserted events.
    • Key Patterns: e.g., only keys starting with "user:".
    • Commands: e.g., only put or put_new commands.
    defmodule Blog.Cache.AnalyticsHandler do
      # Filter functions - return true to process the event, false to ignore it
      def filter_insertions(%{type: :inserted}), do: true
      def filter_insertions(_other), do: false
    
      def filter_user_keys(%{target: {:key, "user:" <> _ = key}}), do: true
      def filter_user_keys(_other), do: false
    end
    
    # Register listeners with specific filters
    Blog.Cache.register_event_listener(
      &Blog.Cache.AnalyticsHandler.handle_insertions/1,
      id: :insertion_tracker,
      filter: &Blog.Cache.AnalyticsHandler.filter_insertions/1
    )
  12. Optimize query performance with QueryHelper

    main

    When performing queries on the cache (e.g., for eviction or lookups), be mindful of complexity. Using Nebulex.Adapters.Local.QueryHelper allows you to construct match specifications.

    • Efficient: Use specific tag lookups. This is generally $O(n)$ where $n$ is the number of entries with that specific tag.
    • Inefficient: Avoid pattern matching on values (e.g., matching a range of prices). This often results in an $O(n)$ scan of all entries in the cache.

    To improve performance, consider denormalizing data or using more specific keys instead of complex queries.

    use Nebulex.Adapters.Local.QueryHelper
    
    # GOOD: Specific tag lookup
    defp query_by_tag(%{args: [tag]}) do
      match_spec tag: t, where: t == ^tag, select: true
    end
    
    # CAREFUL: Value pattern matching (scans all entries)
    defp query_by_price_range(%{args: [min, max]}) do
      match_spec value: %{price: price},
                 where: price >= ^min and price <= ^max,
                 select: true
    end