diff --git a/CHANGELOG.md b/CHANGELOG.md index efcbc48..ab11976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Release 0.4.1 + +### Bug Fixes + +- Fixed `select_replace` match spec in `put_newer/5` and `put_all_newer/3` when + values contain maps (including maps nested inside tuples or lists). Maps in + match spec bodies are now wrapped with `{:const, map}` via `ms_literal/1`, + which tells ETS to treat them as opaque literals. + ## Release 0.4.0 ### Enhancements diff --git a/lib/partitioned_buffer/partition.ex b/lib/partitioned_buffer/partition.ex index ca2897a..33c4acf 100644 --- a/lib/partitioned_buffer/partition.ex +++ b/lib/partitioned_buffer/partition.ex @@ -524,9 +524,9 @@ defmodule PartitionedBuffer.Partition do # # In match spec bodies, bare tuples are interpreted as operations/function # calls, NOT as literal data. We wrap key and value with ms_literal/1 so - # tuples use the {{...}} constructor form that ETS understands. This handles - # tuples and lists (including nested combinations). Map keys/values with - # embedded tuples are a known limitation of ETS select_replace. + # tuples use the {{...}} constructor form and maps use {:const, map} that + # ETS understands. This handles tuples, maps, and lists (including nested + # combinations). [ { # Match: {entry, key, value, existing_version, updates} where key is literal @@ -549,6 +549,7 @@ defmodule PartitionedBuffer.Partition do # Wraps a term so it is safe to use as a literal in a match spec body. # In match spec bodies, bare tuples are interpreted as operations — not # data. The {{...}} form tells ETS to construct a tuple from its elements. + # Maps use {:const, map} to be treated as opaque literals. defp ms_literal(value) when is_tuple(value) do value |> Tuple.to_list() @@ -561,6 +562,10 @@ defmodule PartitionedBuffer.Partition do Enum.map(value, &ms_literal/1) end + defp ms_literal(value) when is_map(value) do + {:const, value} + end + defp ms_literal(value) do value end diff --git a/mix.exs b/mix.exs index 71cd36f..104bf93 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule PartitionedBuffer.MixProject do use Mix.Project - @version "0.4.0" + @version "0.4.1" @source_url "https://github.com/appcues/partitioned_buffer" def project do diff --git a/test/partitioned_buffer/map_test.exs b/test/partitioned_buffer/map_test.exs index 6c1f3e1..3359241 100644 --- a/test/partitioned_buffer/map_test.exs +++ b/test/partitioned_buffer/map_test.exs @@ -455,6 +455,49 @@ defmodule PartitionedBuffer.MapTest do assert_receive {:process_completed, [{^key, ^value1, 200, 1}]}, @default_timeout end + test "ok: updates existing entry with nested map value", %{buffer: buff} do + key = :nested_map + + value0 = %{ + users: %{admin: %{name: "alice", roles: [:admin, :user]}}, + meta: %{nested: %{deep: %{level: 3}}} + } + + value1 = %{ + users: %{admin: %{name: {:x, "bob"}, roles: [:user]}}, + meta: %{nested: %{deep: %{level: 5, extra: true}}} + } + + assert M.put_newer(buff, key, value0, 100) == :ok + assert M.put_newer(buff, key, value1, 200) == :ok + + assert M.size(buff) == 1 + assert M.get(buff, key) == value1 + + assert_receive {@processing_stop_event, %{duration: _, size: 1}, + %{buffer: ^buff, partition: _}}, + @default_timeout + + assert_receive {:process_completed, [{^key, ^value1, 200, 1}]}, @default_timeout + end + + test "ok: updates existing entry with tuple value containing maps", %{buffer: buff} do + value0 = {:ok, %{a: 1, b: %{c: [1, 2, %{d: 3}]}}} + value1 = {:ok, %{a: 2, b: %{c: [3, 4, %{d: 5}]}}} + + assert M.put_newer(buff, :tuple_map, value0, 100) == :ok + assert M.put_newer(buff, :tuple_map, value1, 200) == :ok + + assert M.size(buff) == 1 + assert M.get(buff, :tuple_map) == value1 + + assert_receive {@processing_stop_event, %{duration: _, size: 1}, + %{buffer: ^buff, partition: _}}, + @default_timeout + + assert_receive {:process_completed, [{:tuple_map, ^value1, 200, 1}]}, @default_timeout + end + test "error: put_newer raises ArgumentError for non-integer version", %{buffer: buff} do assert_raise ArgumentError, ~r/invalid entry/, fn -> M.put_newer(buff, :key1, "value1", "not_an_integer")