feat: New Redix and Caches lessons, refreshed existing storage lessons.#3056
feat: New Redix and Caches lessons, refreshed existing storage lessons.#3056doomspork wants to merge 4 commits into
Conversation
a671023 to
8bed803
Compare
8bed803 to
968cee3
Compare
e7a2e00 to
e4ced0f
Compare
brain-geek
left a comment
There was a problem hiding this comment.
This is a massive and awesome update.
Added a few notes that might improve readability/consistency.
|
|
||
| ### Building a Connection Pool | ||
|
|
||
| For high-traffic applications, we might want to use multiple Redis connections. Here's a simple connection pool implementation: |
There was a problem hiding this comment.
Should we maybe skip this section? High-traffic applications are definitely out of scope for basic technology lesson.
There was a problem hiding this comment.
I'm also thinking of removing the pub/sub section now too. I got carried away and wanted to cover too much to be comprehensive
| # Clean up Redis before each test | ||
| ExUnit.after_suite(fn _results -> | ||
| {:ok, conn} = Redix.start_link(Application.get_env(:my_app, :redis_url)) | ||
| Redix.command!(conn, ["FLUSHDB"]) |
There was a problem hiding this comment.
This will create weird concurrency test failures if you use it in async: true tests.
Also, ExUnit.after_suite runs it after full suite, not each test.
There was a problem hiding this comment.
That's fair, let me think about how to capture this best without going to off into the weeds.
| In our `config/test.exs`: | ||
|
|
||
| ```elixir | ||
| config :my_app, :redis_url, "redis://localhost:6379/15" # Use a test database |
There was a problem hiding this comment.
This is the first time we're using redis_url config variable.
Maybe use it in initial supervision tree setup example as well, instead of hardcoding the address?
| In this case, we are passing in the node associated with our IEx session. | ||
|
|
||
| ## Nodes | ||
| This command creates a new schema on the current node. After running this, you'll notice a new directory in your current working directory named something like `Mnesia.nonode@nohost` - this is where Mnesia stores its data files. |
e418813 to
a6b9051
Compare
|
@brain-geek thank you, thank you! I've been slowly chipping away at these lessons locally as I get back into the flow of writing lessons and explore some potential changes. Let me work through your feedback and other changes, then I'll tag you for another look 😁 I also screwed up using 1 PR for all the lessons, I wasn't anticipating getting reviews but I love it 🫶 |
There was a problem hiding this comment.
Pull request overview
Adds new storage lessons for Redis/Cache libraries and substantially refreshes existing in-runtime storage lessons to improve coverage of caching, concurrency, and distribution concepts.
Changes:
- Added new lessons: Redix (Redis client) and Cachex (ETS-based cache library)
- Major rewrite/expansion of the Mnesia lesson (schema, queries, distribution, examples)
- Major refresh of the ETS lesson (table types, concurrency options, practical cache example)
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 13 comments.
| File | Description |
|---|---|
| lessons/en/storage/redix.md | New Redix lesson with usage patterns, telemetry, caching, rate limiting, and transactions |
| lessons/en/storage/cachex.md | New Cachex lesson covering core ops, TTL/expiration, fetch, transactions, stats, and distributed setup |
| lessons/en/storage/mnesia.md | Expanded Mnesia lesson with updated structure, examples, querying, distribution/replication, and best practices |
| lessons/en/storage/ets.md | Updated ETS lesson with table types, concurrency options, richer querying, and a cache example |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| end | ||
|
|
||
| defmodule MyApp.RateLimitPlug do | ||
| use Plug.Conn |
| # Use remote_ip if behind a proxy/load balancer | ||
| to_string(:inet_parse.ntoa(conn.remote_ip)) | ||
| {:header, name} -> | ||
| get_req_header(conn, name) |> List.first() || "anonymous" | ||
| val when is_function(val, 1) -> | ||
| val.(conn) | ||
| nil -> | ||
| # Default fallback: IP |
a76a031 to
7dc1df5
Compare
There was a problem hiding this comment.
Pull request overview
Adds new storage lessons for Redis (Redix) and caching (Cachex) while significantly refreshing the existing ETS and Mnesia lessons to include more advanced patterns and practical examples.
Changes:
- Introduce new
Redixlesson with basic usage, pipelining, telemetry, transactions, and example patterns (cache + rate limiting). - Introduce new
Cachexlesson covering setup, TTL/expiration, fetch/lazy loading, transactions, stats/hooks, and distributed caching. - Refresh
ETSandMnesialessons with updated terminology, concurrency/distribution guidance, and more “real application” examples.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 13 comments.
| File | Description |
|---|---|
| lessons/en/storage/redix.md | New lesson covering Redix usage patterns, telemetry, caching, and rate limiting examples. |
| lessons/en/storage/cachex.md | New Cachex lesson including setup, core/advanced operations, TTL, fetch, transactions, hooks, and distributed mode. |
| lessons/en/storage/ets.md | Major rewrite expanding ETS table types, concurrency options, querying, and an ETS-backed cache example. |
| lessons/en/storage/mnesia.md | Major rewrite expanding Mnesia setup, querying, distributed replication, and application-style examples. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| redis_url = Application.get_env(:my_app, :redis_url, "redis://localhost:6379") | ||
|
|
||
| children = [ | ||
| {Redix, name: :redix, host: redis_url} |
There was a problem hiding this comment.
In the supervision tree example, redis_url is a full Redis URI but it's passed as host:. host: expects a hostname (e.g., "localhost"), so this configuration will fail. Use the URI form supported by Redix (e.g., pass the URI as the first argument / url: option, or use host: + port: parsed from the URI).
| {Redix, name: :redix, host: redis_url} | |
| {Redix, name: :redix, url: redis_url} |
| def exists?(key) do | ||
| case Redix.command(@redix_name, ["EXISTS", key]) do | ||
| {:ok, 1} -> true | ||
| {:ok, 0} -> false |
There was a problem hiding this comment.
exists?/1 only handles {:ok, 0 | 1}. If Redis/Redix returns {:error, reason} (or any other {:ok, _}), this will raise a CaseClauseError. Add a fallback clause (e.g., return false or {:error, reason}) so the example doesn't crash on connection/command errors.
| {:ok, 0} -> false | |
| {:ok, 0} -> false | |
| _ -> false |
|
|
||
| pipeline = [ | ||
| ["INCR", key], | ||
| ["EXPIRE", key, window_seconds] | ||
| ] | ||
|
|
||
| case Redix.pipeline(@redix_name, pipeline) do | ||
| {:ok, [count, _expire_result]} when count <= limit -> | ||
| {:ok, %{allowed: true, count: count, limit: limit}} | ||
|
|
||
| {:ok, [count, _expire_result]} -> | ||
| {:ok, %{allowed: false, count: count, limit: limit}} | ||
|
|
There was a problem hiding this comment.
This rate limiting pipeline resets the key TTL on every request (EXPIRE always runs), which turns the window into an inactivity timeout rather than a fixed window as described. To implement a fixed window, only set the expiry when the counter is created (e.g., when INCR returns 1), or use a Lua script to INCR + set expiry atomically on first increment.
| pipeline = [ | |
| ["INCR", key], | |
| ["EXPIRE", key, window_seconds] | |
| ] | |
| case Redix.pipeline(@redix_name, pipeline) do | |
| {:ok, [count, _expire_result]} when count <= limit -> | |
| {:ok, %{allowed: true, count: count, limit: limit}} | |
| {:ok, [count, _expire_result]} -> | |
| {:ok, %{allowed: false, count: count, limit: limit}} | |
| script = """ | |
| local current = redis.call("INCR", KEYS[1]) | |
| if current == 1 then | |
| redis.call("EXPIRE", KEYS[1], ARGV[1]) | |
| end | |
| return current | |
| """ | |
| case Redix.command(@redix_name, ["EVAL", script, "1", key, window_seconds]) do | |
| {:ok, count} when count <= limit -> | |
| {:ok, %{allowed: true, count: count, limit: limit}} | |
| {:ok, count} -> | |
| {:ok, %{allowed: false, count: count, limit: limit}} |
| ...> Mnesia.select(:users, [ | ||
| ...> { | ||
| ...> {:users, :"$1", :"$2", :"$3", :"$4"}, # Match pattern | ||
| ...> [{'=:=', {:hd, :"$2"}, ?A}], # Guard (name starts with 'A') | ||
| ...> [:"$$"] # Return entire match | ||
| ...> } |
There was a problem hiding this comment.
In this select/2 example, the guard uses {:hd, :"$2"} but :"$2" is bound to the name field which is a binary (e.g., "Alice"); hd/1 expects a list and will fail. Use a binary-safe approach (or adjust the example to store names as charlists) so the match spec is valid.
| # Create schema if it doesn't exist | ||
| case Mnesia.create_schema([node()]) do | ||
| :ok -> :ok | ||
| {:error, {_, {:already_exists, _}}} -> :ok |
There was a problem hiding this comment.
Blog.Database.setup/0 pattern matches on only two create_schema/1 outcomes; any other error tuple will raise a CaseClauseError. Add a catch-all clause (e.g., return/raise a descriptive error) so the example handles failures like permissions/invalid directory cleanly.
| {:error, {_, {:already_exists, _}}} -> :ok | |
| {:error, {_, {:already_exists, _}}} -> :ok | |
| {:error, reason} -> {:error, {:schema_creation_failed, reason}} |
| [{:key, "value1"}, {:key, "value2"}] | ||
| ``` | ||
|
|
||
| #### Duplicating Bag |
There was a problem hiding this comment.
Heading typo: this should be "Duplicate Bag" (or "Duplicate Bag Example") to match the ETS table type name :duplicate_bag and avoid confusion with a different concept.
| #### Duplicating Bag | |
| #### Duplicate Bag |
|
|
||
| @table_name :simple_cache | ||
|
|
||
| def start_link do |
There was a problem hiding this comment.
SimpleCache.start_link/0 doesn't actually start or link a process; it creates an ETS table owned by the caller and returns {:ok, self()}. Under a supervisor this would be misleading (and the table would disappear if the owning process exits). Consider implementing this as a GenServer/Agent which owns the table, or rename the function to reflect that it just initializes the table in the current process.
| def start_link do | |
| def init do |
| {:named_table, true}, | ||
| {:type, :set}, | ||
| {:keypos, 1}, | ||
| {:protection, :protected} |
There was a problem hiding this comment.
The :ets.info/1 output shows {:protection, :protected}, but the cache table is created with :public in SimpleCache.start_link/0. Update either the table options or the sample :ets.info/1 output so they reflect the same configuration.
| {:protection, :protected} | |
| {:protection, :public} |
| To add Cachex to our project let's add it to our `mix.exs` dependencies: | ||
|
|
||
| ```elixir | ||
| def deps do |
There was a problem hiding this comment.
Dependency examples in this repo consistently use defp deps do (see lessons/en/basics/mix.md). This lesson uses def deps do, which is inconsistent with the earlier convention and implies a public function unnecessarily. Update to defp deps do for consistency.
| def deps do | |
| defp deps do |
| defmodule MyApp.CacheLogger do | ||
| use Cachex.Hook | ||
|
|
||
| def init(_), do: {:ok, nil} | ||
|
|
||
| def handle_notify({action, _args}, result, state) do | ||
| Logger.info("Cache #{action}: #{inspect(result)}") | ||
| {:ok, state} | ||
| end |
There was a problem hiding this comment.
This example uses Logger.info/1 inside MyApp.CacheLogger without require Logger. Since Logger calls are macros, the snippet won't compile as-is. Add require Logger in the module (or switch to IO.inspect/1/IO.puts/1 if you want to keep the example dependency-free).
7dc1df5 to
23c634d
Compare
Summary