Skip to content

Commit 0f76f5c

Browse files
committed
feat(queue): add :sort_key runtime option to control ordering
Per-push :sort_key option on Tidefall.Queue (mirrors :partition_key), validated via the new Tidefall.Queue.Options. ETS :ordered_set key is {sort_key, ref}: sort_key defaults to System.monotonic_time() or is derived from an arity-1/arity-0 function; ref is retained so distinct items never collide. Ordering is per-partition. Includes tests, docs, usage-rules, and a CHANGELOG entry.
1 parent 57821e2 commit 0f76f5c

8 files changed

Lines changed: 338 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,24 @@ All notable changes to this project will be documented in this file.
55
This project adheres to
66
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## Unreleased
9+
> [Full Changelog](https://github.com/cabol/tidefall/compare/v1.0.0-rc.0...HEAD)
10+
11+
### Enhancements
12+
13+
- [Tidefall.Queue] Added the `:sort_key` runtime option on `push/3` to control
14+
how buffered items are ordered within a partition. Defaults to insertion
15+
order; accepts a function (arity 1 applied to each item, or arity 0 evaluated
16+
per item) that returns the sort term. Ordering is per partition and no item
17+
is ever dropped.
18+
819
## [v1.0.0-rc.0](https://github.com/cabol/tidefall/tree/v1.0.0-rc.0) (2026-06-13)
920

1021
### Enhancements
1122

12-
- `Tidefall.Queue` — insertion-ordered ETS buffer (`:ordered_set`) that
23+
- [Tidefall.Queue] Insertion-ordered ETS buffer (`:ordered_set`) that
1324
accumulates items and drains them to a processor in periodic batches.
14-
- `Tidefall.HashMap` — coalescing key-value buffer (`:set`, last-write-wins)
25+
- [Tidefall.HashMap] Coalescing key-value buffer (`:set`, last-write-wins)
1526
with version-aware conditional writes (`put_newer/4`, `put_all_newer/3`)
1627
and an optional `:key_hasher` for complex keys.
1728
- Module-based buffers via `use Tidefall.Queue` / `use Tidefall.HashMap`

lib/tidefall/buffer.ex

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ defmodule Tidefall.Buffer do
2525
2626
#{Tidefall.Buffer.Options.runtime_options_docs()}
2727
28+
Each buffer type may accept additional runtime options of its own —
29+
see `Tidefall.Queue` (e.g. `:sort_key`) and `Tidefall.HashMap`
30+
(e.g. `:key_hasher`) for their full option docs.
31+
2832
"""
2933

3034
alias Tidefall.Buffer.{Options, Partition}
@@ -165,7 +169,7 @@ defmodule Tidefall.Buffer do
165169

166170
## Private functions
167171

168-
@compile [inline: [lookup: 1]]
172+
@compile inline: [lookup: 1]
169173
defp lookup(buffer) do
170174
Registry.lookup(Tidefall.Registry, buffer)
171175
end

lib/tidefall/hash_map.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -639,7 +639,7 @@ defmodule Tidefall.HashMap do
639639
## Private functions
640640

641641
# Iniline common instructions
642-
@compile [inline: [new_entry: 3, new_entry: 4]]
642+
@compile inline: [new_entry: 3, new_entry: 4]
643643

644644
defp new_entry(ets_key, raw_key, value) do
645645
entry(key: ets_key, raw_key: raw_key, value: value)

lib/tidefall/queue.ex

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ defmodule Tidefall.Queue do
2323
| | |
2424
v v v
2525
+-------+ +-------+ +-------+ ETS :ordered_set
26-
| P 0 | | P 1 | | P N-1 | Key: {monotonic_time, ref}
26+
| P 0 | | P 1 | | P N-1 | Key: {sort_key, ref}
2727
+-------+ +-------+ +-------+ Val: item
2828
| | |
2929
v v v
@@ -34,17 +34,20 @@ defmodule Tidefall.Queue do
3434
```
3535
3636
Items are routed to partitions via `phash2`, stored in
37-
`:ordered_set` ETS tables keyed by `{monotonic_time, ref}`
38-
(ensuring insertion-time ordering with uniqueness), and
39-
periodically flushed to the processor in batches.
37+
`:ordered_set` ETS tables keyed by `{sort_key, ref}`, and
38+
periodically flushed to the processor in batches. `sort_key`
39+
is `System.monotonic_time()` by default — giving insertion-time
40+
order — or the term produced by the `:sort_key` runtime option;
41+
`ref` keeps every key unique so no item is ever overwritten.
42+
Ordering is **per partition** (see `:sort_key` under runtime options).
4043
4144
## Start options
4245
4346
#{Tidefall.Buffer.Options.start_options_docs()}
4447
4548
## Runtime options
4649
47-
#{Tidefall.Buffer.Options.runtime_options_docs()}
50+
#{Tidefall.Queue.Options.runtime_options_docs()}
4851
4952
## Examples
5053
@@ -112,13 +115,18 @@ defmodule Tidefall.Queue do
112115
import Record, only: [defrecordp: 2]
113116

114117
alias Tidefall.Buffer
115-
alias Tidefall.Buffer.{Definition, Options, Partition}
116-
117-
# Queue-specific key record (ordered by insertion time).
118-
# The `timestamp` ensures order by insertion time (asc) while the
119-
# `ref` makes each entry unique since there may be multiple entries
120-
# with the same timestamp.
121-
defrecordp(:key, timestamp: nil, ref: nil)
118+
alias Tidefall.Buffer.{Definition, Partition}
119+
alias Tidefall.Queue.Options
120+
121+
# Queue-specific key record.
122+
#
123+
# `sort_key` is the primary ordering term — `System.monotonic_time()`
124+
# by default (insertion order), or whatever the `:sort_key` runtime
125+
# option resolves to. `ref` is always retained as the uniqueness
126+
# tiebreaker: since `make_ref()` is unique, distinct items never
127+
# collide in the `:ordered_set`, so nothing is overwritten even when
128+
# two items share the same `sort_key`.
129+
defrecordp(:key, sort_key: nil, ref: nil)
122130

123131
# Entry record stored in ETS. Queue only needs key/value; the
124132
# match spec returns just the value to the processor.
@@ -223,20 +231,24 @@ defmodule Tidefall.Queue do
223231
# Custom partition routing with fixed key (all items to same partition)
224232
push(:my_buffer, log_entry, partition_key: :logs)
225233
234+
# Custom ordering: drain by a value-derived sort key (per partition)
235+
push(:my_buffer, event, sort_key: & &1.priority)
236+
226237
"""
227238
@spec push(buffer(), item() | [item()], keyword()) :: :ok
228239
def push(buffer, item_or_batch, opts \\ [])
229240

230241
def push(buffer, batch, opts) when is_list(batch) do
231242
opts = Options.validate_runtime_options!(opts)
232243
partition_key = Keyword.fetch!(opts, :partition_key)
244+
sort_key = Keyword.get(opts, :sort_key)
233245

234246
batch
235247
|> Enum.group_by(&Buffer.get_partition(buffer, partition_key, &1))
236248
|> Enum.each(fn {partition, items} ->
237249
partition
238250
|> Partition.current_table()
239-
|> :ets.insert(Enum.map(items, &new_entry(build_key(), &1)))
251+
|> :ets.insert(Enum.map(items, &new_entry(build_key(sort_key, &1), &1)))
240252
end)
241253
end
242254

@@ -300,9 +312,22 @@ defmodule Tidefall.Queue do
300312
## Private functions
301313

302314
# Iniline common instructions
303-
@compile [inline: [build_key: 0, new_entry: 2]]
315+
@compile inline: [new_entry: 2]
316+
317+
# Default (no `:sort_key`): order by insertion time.
318+
defp build_key(nil, _item) do
319+
key(sort_key: System.monotonic_time(), ref: make_ref())
320+
end
304321

305-
defp build_key, do: key(timestamp: System.monotonic_time(), ref: make_ref())
322+
# Arity-1: derive the sort term from the item.
323+
defp build_key(fun, item) when is_function(fun, 1) do
324+
key(sort_key: fun.(item), ref: make_ref())
325+
end
326+
327+
# Arity-0: generate the sort term at push time.
328+
defp build_key(fun, _item) when is_function(fun, 0) do
329+
key(sort_key: fun.(), ref: make_ref())
330+
end
306331

307332
defp new_entry(key, value), do: entry(key: key, value: value)
308333
end

lib/tidefall/queue/options.ex

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
defmodule Tidefall.Queue.Options do
2+
@moduledoc false
3+
4+
alias Tidefall.Buffer
5+
6+
# Queue-specific runtime options, layered on top of the shared runtime
7+
# options exposed by `Tidefall.Buffer.Options` (currently `:partition_key`).
8+
# `:sort_key` is resolved per call (like `:partition_key`), so the client
9+
# builds the ETS key without any per-buffer lookup.
10+
queue_runtime_opts = [
11+
sort_key: [
12+
type: {:or, [{:fun, 1}, {:fun, 0}]},
13+
required: false,
14+
doc: """
15+
Controls the term used to order buffered items within a partition.
16+
The ETS key is `{sort_key_term, ref}`; `ref` is always retained for
17+
uniqueness, so distinct items are never overwritten regardless of the
18+
`:sort_key` value.
19+
20+
Can be one of:
21+
22+
* Omitted (default) — items order by insertion time
23+
(`System.monotonic_time/0`), i.e. the order they were pushed.
24+
* A function of arity 1 — applied to each item to derive its sort
25+
term (e.g. `& &1.priority`, or an event timestamp carried in the
26+
payload).
27+
* A function of arity 0 — evaluated per item to generate the sort
28+
term at push time (e.g. a custom clock or sequence).
29+
30+
> #### Ordering scope and ties {: .info}
31+
>
32+
> Ordering is **per partition** — items are routed across partitions
33+
> first, and each partition's batch is ordered independently. For a
34+
> single global order use `partitions: 1` or a `:partition_key` that
35+
> co-locates the items you need ordered together. Order among items
36+
> with the **same** sort term is unspecified (broken by `ref`),
37+
> consistent with how same-timestamp items already drain today. Sort
38+
> terms are compared with Erlang's total term order, so prefer
39+
> integers, atoms, or binaries; complex terms (maps, tuples) sort in
40+
> non-obvious ways.
41+
"""
42+
]
43+
]
44+
45+
# Runtime schema: shared opts + Queue opts.
46+
@runtime_opts_schema NimbleOptions.new!(Buffer.Options.runtime_opts() ++ queue_runtime_opts)
47+
48+
## API
49+
50+
@spec runtime_options_docs() :: binary()
51+
def runtime_options_docs do
52+
NimbleOptions.docs(@runtime_opts_schema)
53+
end
54+
55+
@spec validate_runtime_options!(keyword()) :: keyword()
56+
def validate_runtime_options!(opts) do
57+
NimbleOptions.validate!(opts, @runtime_opts_schema)
58+
end
59+
end
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
defmodule Tidefall.Queue.OptionsTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Tidefall.Queue.Options
5+
6+
describe "validate_runtime_options!/1 — :sort_key" do
7+
test "ok: accepts an arity-1 function" do
8+
fun = fn item -> item end
9+
10+
assert Options.validate_runtime_options!(sort_key: fun)[:sort_key] == fun
11+
end
12+
13+
test "ok: accepts an arity-0 function" do
14+
fun = fn -> 1 end
15+
16+
assert Options.validate_runtime_options!(sort_key: fun)[:sort_key] == fun
17+
end
18+
19+
test "ok: omitting :sort_key validates and leaves it unset" do
20+
refute Keyword.has_key?(Options.validate_runtime_options!([]), :sort_key)
21+
end
22+
23+
test "ok: the shared :partition_key option still validates alongside :sort_key" do
24+
opts = Options.validate_runtime_options!(sort_key: fn -> 1 end, partition_key: :p)
25+
26+
assert opts[:partition_key] == :p
27+
end
28+
29+
test "error: rejects a non-function term" do
30+
for bad <- [:high, 123, "x", %{}] do
31+
assert_raise NimbleOptions.ValidationError, fn ->
32+
Options.validate_runtime_options!(sort_key: bad)
33+
end
34+
end
35+
end
36+
37+
test "error: rejects a function of the wrong arity" do
38+
assert_raise NimbleOptions.ValidationError, fn ->
39+
Options.validate_runtime_options!(sort_key: fn _a, _b -> :x end)
40+
end
41+
end
42+
end
43+
44+
describe "runtime_options_docs/0" do
45+
test "ok: renders the :sort_key option" do
46+
assert Options.runtime_options_docs() =~ "sort_key"
47+
end
48+
end
49+
end

0 commit comments

Comments
 (0)