Files
rose-ash/next/kernel/backfill.erl
giles 9621599606
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 49s
fed-sx-m2: Step 9a — pure-functional backfill slicing + 20 tests
New next/kernel/backfill.erl owns the §13.3 backfill mode
slicing. Given an outbox log + a mode, returns the activity
list to send to a new follower as backfill.

Public API:
  slice/2(Mode, LogState)               default Wrap=false
  slice/3(Mode, LogState, Wrap)         Wrap=true wraps entries
  wrap_backfill/1                       add {backfilled, true}
  parse_mode/1                          lift Follow :backfill field

Modes:
  none                       new follower: forward-only content
  full                       entire outbox
  {last_n, N}                last N activities (FIFO)
  {last_t, T, NowFn}         entries with :published in
                             (NowFn()-T .. NowFn()]
  {since_cid, Cid}           entries after the one with :id = Cid
                             (consumes the matched entry; returns
                             every entry after it)

wrap_backfill/1 marks each entry {backfilled, true}. Per §13.3
wrapped bodies preserve :id so the receiver's replay defence
still catches duplicates from the live stream.

parse_mode/1 accepts:
  nil / none / full / {last_n, _} / {last_t, _, _} /
  {since_cid, _} — pass through or normalize
  Proplist with :mode + :limit -> {last_n, N}
  Proplist with :mode + :duration -> {last_t, T, fun() -> 0 end}
  Proplist with :mode = full -> full
  Anything else -> none (open-world default)

Substrate gotchas re-confirmed and worked around:
  - lists:nthtail/2 not registered — rolled drop_n/2
  - Pattern-alias 'Pat = Var' not supported by this port's
    parser — parse_mode/1 clauses use explicit deconstruction

20/20 in next/tests/backfill.sh covering all five modes plus
edge cases (N=0, N>length, T=0 -> empty window, since_cid
hit/miss/unknown), wrap_backfill semantics, parse_mode for
atoms / tuple shapes / proplists / unknown / nil.

Step 9b (outbox listing ?since=Cid&limit=N pagination) and
Step 9c (Follow-Accept-backfill wiring) layer on top.
Conformance preserved at 761/761.
2026-06-07 05:39:46 +00:00

137 lines
5.0 KiB
Erlang

-module(backfill).
-export([slice/2, slice/3,
wrap_backfill/1, parse_mode/1,
all_entries/1, last_n_entries/2, last_t_entries/3,
since_cid_entries/2, none_entries/0]).
%% Backfill mode slicing per design §13.3 / Step 9. When A follows B
%% with a backfill spec, B's kernel slices the outbox log into the
%% appropriate window and delivers each entry as
%% `{backfilled, true}`-marked envelopes alongside forward-going
%% activity.
%%
%% Mode shapes (per the Follow activity's `:backfill` field):
%% none — newer follower sees only forward content
%% {last_n, N} — backfill last N activities (FIFO order)
%% {last_t, T, NowFn} — backfill activities with :published in
%% (Now - T .. Now]. NowFn is a 0-arity fun
%% so tests can fake-time it.
%% full — backfill the entire outbox
%%
%% slice/2 returns the activity list. slice/3 also wraps each entry
%% with `{backfilled, true}` so projections can decide whether to
%% re-fold or skip (the §13.3 Backfilled bodies preserve the
%% original `:id` so replay defence still works on the receiver).
%%
%% parse_mode/1 lifts the Follow activity's `:backfill` proplist
%% (or atom) into the internal mode tuple. Unknown shapes fall back
%% to `none` — the default open-world policy.
slice(Mode, LogState) ->
slice(Mode, LogState, false).
slice(Mode, LogState, Wrap) ->
Entries = log:entries(LogState),
Slice = case Mode of
none -> none_entries();
full -> all_entries(Entries);
{last_n, N} -> last_n_entries(N, Entries);
{last_t, T, NowFn} -> last_t_entries(T, NowFn, Entries);
{since_cid, Cid} -> since_cid_entries(Cid, Entries);
_ -> none_entries()
end,
case Wrap of
true -> wrap_backfill(Slice);
_ -> Slice
end.
%% ── Mode-specific entry selection ─────────────────────────────
all_entries(Entries) -> Entries.
none_entries() -> [].
%% last_n_entries/2 — tail N entries in FIFO order.
last_n_entries(N, _) when N =< 0 -> [];
last_n_entries(N, Entries) ->
Len = length(Entries),
case Len =< N of
true -> Entries;
false -> drop_n(Len - N, Entries)
end.
drop_n(0, L) -> L;
drop_n(_, []) -> [];
drop_n(N, [_ | Rest]) -> drop_n(N - 1, Rest).
%% last_t_entries/3 — entries whose :published is within the last
%% T units of (NowFn() - T .. NowFn()]. T and :published are
%% integers (seconds-since-epoch in production; opaque ints in tests).
last_t_entries(T, NowFn, Entries) when is_integer(T), T >= 0 ->
Now = NowFn(),
Cutoff = Now - T,
[E || E <- Entries, in_window(E, Cutoff, Now)];
last_t_entries(_, _, _) -> [].
in_window(Activity, Cutoff, Now) ->
case envelope:get_field(published, Activity) of
{ok, P} when is_integer(P), P > Cutoff, P =< Now -> true;
_ -> false
end.
%% since_cid_entries/2 — every entry after the one with :id = Cid.
%% If Cid isn't in the log, returns [] (caller's pointer is stale).
%% Used by `GET /actors/<id>/outbox?since=Cid` pagination.
since_cid_entries(_Cid, []) -> [];
since_cid_entries(Cid, [E | Rest]) ->
case envelope:get_field(id, E) of
{ok, Cid} -> Rest;
_ -> since_cid_entries(Cid, Rest)
end.
%% wrap_backfill/1 — append `{backfilled, true}` to each entry.
%% The receiving projection scheduler reads this field and chooses
%% whether to fold (re-emit) or skip (already known via replay
%% defence on `:id`).
wrap_backfill([]) -> [];
wrap_backfill([E | Rest]) ->
[E ++ [{backfilled, true}] | wrap_backfill(Rest)].
%% parse_mode/1 — Lift a Follow activity's `:backfill` value into the
%% internal mode tuple. Accepts:
%% nil / not_found -> none
%% none -> none
%% full -> full
%% {last_n, N} -> {last_n, N} (already-parsed shape)
%% {last_t, T, NowFn} -> pass-through
%% Proplist with :mode + :limit / :duration -> parsed
%% Unknown shape -> none (open-world default).
parse_mode(nil) -> none;
parse_mode(none) -> none;
parse_mode(full) -> full;
parse_mode({last_n, N}) -> {last_n, N};
parse_mode({last_t, T, NowFn}) -> {last_t, T, NowFn};
parse_mode({since_cid, Cid}) -> {since_cid, Cid};
parse_mode(List) when is_list(List) ->
case envelope:get_field(mode, List) of
{ok, last_n} ->
case envelope:get_field(limit, List) of
{ok, N} when is_integer(N) -> {last_n, N};
_ -> none
end;
{ok, last_t} ->
case envelope:get_field(duration, List) of
{ok, T} when is_integer(T) -> {last_t, T, fun () -> 0 end};
_ -> none
end;
{ok, full} -> full;
{ok, none} -> none;
_ -> none
end;
parse_mode(_) -> none.