Packages

macula

10.0.1
10.1.0 10.0.2 10.0.1 10.0.0 9.13.8 9.8.2 9.8.1 9.8.0 9.5.0 9.4.0 9.3.1 9.3.0 9.2.0 9.1.1 9.1.0 9.0.0 8.7.0 8.6.0 8.5.0 8.4.1 8.4.0 8.3.0 8.2.0 8.1.0 8.0.2 8.0.1 8.0.0 7.1.0 7.0.0 6.0.0 5.2.2 5.2.1 5.2.0 5.1.0 5.0.0 4.8.0 4.7.1 4.7.0 4.6.0 4.5.0 4.4.10 4.4.9 4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.1 4.3.0 4.2.9 4.2.8 4.2.7 4.2.6 4.2.5 4.2.4 4.2.3 4.2.2 4.2.1 4.2.0 4.1.1 4.1.0 4.0.0 3.16.0 3.15.3 3.15.2 3.15.1 3.14.0 3.13.0 3.12.1 3.12.0 3.11.1 3.11.0 3.10.3 3.10.2 3.10.1 3.9.0 3.8.0 3.7.0 3.5.0 3.4.0 3.3.0 3.2.0 3.1.0 3.0.0 2.1.1 2.1.0 2.0.0 1.5.2 1.5.1 1.4.30 1.4.29 1.4.28 1.4.27 1.4.26 1.4.25 1.4.24 1.4.23 1.4.22 1.4.21 1.4.20 1.4.19 1.4.18 1.4.17 1.4.16 1.4.15 1.4.14 1.4.13 1.4.11 1.4.10 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.1 1.3.0 1.2.0 1.1.0 1.0.10 1.0.9 1.0.8 1.0.7 1.0.6 1.0.5 1.0.4 1.0.3 1.0.2 1.0.1 1.0.0 0.48.6 0.48.5 0.48.4 0.48.3 0.48.2 0.48.1 0.48.0 0.47.1 0.47.0 0.46.3 0.46.1 0.46.0 0.45.3 0.45.2 0.45.1 0.45.0 0.44.2 0.44.1 0.44.0 0.43.3 0.43.2 0.43.1 0.43.0 0.42.9 0.42.8 0.42.7 0.42.6 0.42.5 0.42.4 0.42.3 0.42.2 0.42.1 0.42.0 0.41.1 0.41.0 0.40.1 0.40.0 0.39.9 0.39.8 0.39.7 0.39.6 0.39.5 0.39.4 0.39.3 0.39.2 0.39.1 0.39.0 0.38.8 0.38.7 0.38.6 0.38.5 0.38.4 0.38.3 0.38.2 0.38.1 0.38.0 0.37.7 0.37.6 0.37.5 0.37.4 0.37.3 0.37.2 0.37.1 0.37.0 0.36.6 0.36.5 0.36.4 0.36.3 0.36.2 0.36.1 0.36.0 0.35.4 0.35.3 0.35.2 0.35.1 0.35.0 0.34.1 0.34.0 0.33.1 0.33.0 0.32.5 0.32.4 0.32.3 0.32.2 0.32.1 0.32.0 0.31.9 0.31.8 0.31.7 0.31.6 0.31.5 0.31.4 0.31.3 0.31.2 0.31.1 0.31.0 0.30.10 0.30.9 0.30.8 0.30.7 0.30.6 0.30.5 0.30.4 0.30.3 0.30.2 0.30.1 0.30.0 0.29.0 0.28.3 0.28.2 0.28.1 0.28.0 0.27.1 0.27.0 0.26.1 0.26.0 0.25.6 0.25.5 0.25.4 0.25.3 0.25.2 0.25.1 0.25.0 0.24.6 0.24.5 0.24.4 0.24.3 0.24.2 0.24.1 0.24.0 0.23.3 0.23.2 0.23.1 0.23.0 0.22.12 0.22.11 0.22.10 0.22.9 0.22.8 0.22.7 0.22.6 0.22.5 0.22.4 0.22.3 0.22.2 0.22.1 0.22.0 0.21.7 0.21.6 0.21.5 0.21.4 0.21.2 0.21.1 0.21.0 0.20.25 0.20.24 0.20.23 0.20.22 0.20.21 0.20.20 0.20.19 0.20.18 0.20.17 0.20.16 0.20.15 0.20.14 0.20.13 0.20.12 0.20.11 0.20.10 0.20.9 0.20.8 0.20.7 0.20.6 0.20.5 0.20.3 0.20.2 0.20.1 0.20.0 0.19.2 0.19.1 0.19.0 0.18.1 0.18.0 0.17.4 0.17.3 0.17.2 0.17.1 0.17.0 0.16.6 0.16.5 0.16.4 0.16.3 0.16.2 0.16.1 0.16.0 0.15.1 0.15.0 0.14.3 0.14.2 0.14.1 0.14.0 0.12.6 0.12.5 0.12.3 0.11.3 0.10.2 0.10.1 0.10.0 0.9.2 0.9.1 0.9.0 0.8.25 0.8.24 0.8.23 0.8.22 0.8.21 0.8.20 0.8.19 0.8.18 0.8.17 0.8.16 0.8.15 0.8.14 0.8.13 0.8.12 0.8.11 0.8.10 0.8.9 0.8.8 0.8.7 0.8.6 0.8.5 0.8.4 0.8.3 0.8.2 0.8.1 0.8.0 0.7.30 0.7.29 0.7.28 0.7.27 0.7.26 0.7.25 0.7.24 0.7.23 0.7.22 0.7.21 0.7.20 0.7.19 0.7.18 0.7.17 0.7.16 0.7.15 0.7.14 0.7.13 0.7.12 0.7.11 0.7.10 0.7.9 0.7.8 0.7.7 0.7.6 0.7.5 0.7.4 0.7.3 0.7.2 0.7.1 0.7.0 0.6.7 0.6.6 0.6.5 0.6.4 0.6.3 0.6.2 0.6.1 0.6.0 0.5.0 0.4.4 0.4.3 0.4.2 0.4.1 0.4.0 0.3.4 0.3.3 0.3.2 0.3.1

Macula HTTP/3 Mesh SDK — connect, subscribe, publish, call, advertise

Current section

Files

Jump to
macula src macula_content_transfer.erl
Raw

src/macula_content_transfer.erl

%%%-------------------------------------------------------------------
%%% @doc Addressable content-store put/get, with a real, peer-visible
%%% abort, real pause/resume, and (for chunked content) parallel
%%% multi-stream chunk transfer — the foundation `macula_feeder'/
%%% `macula_download' (and, later, `macula_pusher'/`macula_upload')
%%% build on. See PLAN_PUSH_UPLOAD.md, Phases 1-3.
%%%
%%% `macula:put_content/2'/`get_content/2' are ONE opaque blocking
%%% call each: pick a link, open a dedicated content stream, run the
%%% transfer, close the stream — no handle exists mid-transfer, so
%%% cancelling meant killing whatever process happened to be blocked
%%% in the call. That killed the caller's wait, but never touched the
%%% underlying stream: `macula_station_link' — not the killed process
%%% — owns the stream's `content_stream_bufs'/`content_pending' state,
%%% so a `terminate/2' that never runs (because the blocking call's
%%% own process was `exit(_, kill)'d mid-`gen_server:call') leaked
%%% that state on the link forever, cleaned up only by the eventual
%%% `content_call_timeout' firing against an already-dead caller.
%%%
%%% This module owns the picked link and the open stream itself, in
%%% its own gen_server state, updated as soon as a worker process
%%% resolves them — so `cancel/1,3' can always reach in and tear the
%%% stream down explicitly, from any point in the transfer's
%%% lifecycle, with nothing left to time out.
%%%
%%% == Cancel is a real abort, not a dropped connection ==
%%%
%%% `cancel/3' resets every currently-open content stream's send side
%%% via `macula_quic:reset_stream/2' (through `macula_station_link:
%%% abort_content_stream/4') — a QUIC RESET_STREAM frame the PEER's
%%% own read genuinely observes as `{quic, stream_closed, PeerStream,
%%% {reset, Code}}', not merely a connection that went away. This is
%%% NOT `macula_stream:abort/3' (streaming RPC's abort) — that targets
%%% a `macula_stream' gen_server's own STREAM_ERROR application
%%% framing, and a content-transfer stream is not one of those; it is
%%% a raw QUIC dedicated stream owned directly by `macula_station_link'
%%% (see that module's `open_content_stream/1'). The two "stream"
%%% concepts share a name and nothing else — do not reuse
%%% `macula_stream:abort/3' here.
%%%
%%% == Lifecycle ==
%%%
%%% `start_put/2,3', `start_put_station/4,5', `start_get/2,3',
%%% `start_get_station/4,5' return `{ok, Pid}' immediately; the
%%% resolve/dial sequence runs in a linked worker. `await/1,2' blocks
%%% for the outcome (`{ok, Mcid}' / `{ok, Bytes}' / `{error, Reason}')
%%% — repeatable and from any process; the result is cached once
%%% known. The process does NOT self-terminate on completion (a second
%%% `await/1' after success must still answer) — call `cancel/1' when
%%% done with the handle, whether the transfer succeeded, failed, or
%%% is still in flight; on an already-resolved transfer this is a pure
%%% reap (nothing left to abort).
%%%
%%% == Pause/resume (chunked content only) ==
%%%
%%% Single-block content is one wire round trip — there is no "between
%%% chunks" for it to pause at, so `pause/1' on a single-block transfer
%%% is a harmless no-op (the transfer just runs to completion). For
%%% chunked content, each chunk's own put/get is still ONE uninterrupted
%%% blocking call underneath (pausing mid-chunk would leave a half-sent
%%% block the station can't verify) — what `pause/1' actually controls
%%% is whether the NEXT chunk starts, on EVERY stream, once whichever
%%% chunk is currently in flight on it finishes. `resume/1' re-arms
%%% every stream from exactly its own next un-sent/un-fetched chunk,
%%% never from the start. `cancel/1,3' still works at any point, paused
%%% or not — every stream with a chunk step in flight has it killed and
%%% reset exactly as described above; a stream idle between chunks
%%% (nothing in flight) just gets its stream reset directly.
%%%
%%% == Multi-stream parallel chunk transfer (chunked content only) ==
%%%
%%% Chunks are distributed round-robin (`Index rem StreamCount') across
%%% up to `stream_count' dedicated content streams on the SAME link
%%% (`Opts''s `stream_count' key, default 4, capped at the actual chunk
%%% count so a 2-chunk transfer never opens more than 2 streams) —
%%% each stream runs its own independent chunk-by-chunk loop
%%% concurrently, all driven by this ONE gen_server via
%%% `handle_continue/2' (never by the streams' own worker processes,
%%% which each do exactly one network call and report back). The
%%% manifest is put (or, for a get, its chunks reassembled and
%%% verified) only once every stream has drained its own share. A get
%%% doesn't know the chunk count — and therefore how many streams are
%%% worth opening — until its manifest is fetched, so it starts on the
%%% ONE stream the connect step already opened and expands to more once
%%% the count is known; a put knows upfront and opens every extra
%%% stream immediately. If opening an extra stream fails, this degrades
%%% gracefully to fewer streams rather than failing the transfer — a
%%% single-stream transfer is still a correct, if slower, one. A single
%%% stream's own chunk failing (a genuine `{error, _}', not a crash)
%%% fails the WHOLE transfer, same as a sequential transfer would —
%%% every other stream's in-flight work is killed and every stream
%%% reset before the caller's `await/1,2' sees the error.
%%%
%%% == Correlation-id registry ==
%%%
%%% Each transfer mints a `share_id' (`crypto:strong_rand_bytes(16)',
%%% overridable via `Opts''s `share_id' key so a wrapper that already
%%% publishes it in a `sharing.*_started_v1' mesh fact — see
%%% `macula_feeder'/`macula_download' — can keep the same id) and
%%% registers it in `macula_content_transfer_registry', so a caller
%%% that only saw the id in a published fact, not the pid, can still
%%% resolve it to `cancel/1,3'.
%%% @end
%%%-------------------------------------------------------------------
-module(macula_content_transfer).
-behaviour(gen_server).
-export([start_put/2, start_put/3,
start_put_station/4, start_put_station/5,
start_get/2, start_get/3,
start_get_station/4, start_get_station/5]).
-export([await/1, await/2, cancel/1, cancel/3, pause/1, resume/1, share_id/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, handle_continue/2]).
-ifdef(TEST).
-export([verify_block_hash/2]).
-endif.
-define(CONTENT_REALM, <<0:256>>).
-define(CONTENT_PUT_BLOCK_PROC, <<"_content.put_block">>).
-define(CONTENT_GET_BLOCK_PROC, <<"_content.get_block">>).
-define(CONTENT_PUT_MANIFEST_PROC, <<"_content.put_manifest">>).
-define(CONTENT_GET_MANIFEST_PROC, <<"_content.get_manifest">>).
%% Bigger timeout than DHT records — chunks are 256 KiB and a put
%% writes through the file-backed store on the relay.
-define(CONTENT_BLOCK_TIMEOUT_MS, 15_000).
-define(CONTENT_MANIFEST_TIMEOUT_MS, 5_000).
-define(CONTENT_RETRY_BACKOFF_MS, 200).
%% Default parallelism for a chunked transfer's streams — see the
%% moduledoc's "Multi-stream" section. Overridable per transfer via
%% `Opts''s `stream_count' key; always clamped to at least 1 and at
%% most the actual chunk count regardless of what's requested.
-define(DEFAULT_STREAM_COUNT, 4).
-type kind() :: put | get.
-type dial() :: {pooled, macula:pool()}
| {station, macula:pool(), macula_client:seed(), pos_integer(), map()}.
%% One dedicated content stream's own independent chunk-by-chunk queue.
%% `remaining' holds items not yet dispatched (PUT: `{Index, Bytes}';
%% GET: plain `Index'); `in_flight' is the ONE item currently out for
%% its network round trip (or `undefined'), tracked separately rather
%% than left at the head of `remaining' so a step's outcome doesn't
%% need to echo its own item back — the gen_server already knows what
%% it dispatched. A lane is done for good once `remaining = []',
%% `in_flight = undefined', `worker = undefined'.
-record(lane, {
stream :: reference(),
remaining :: [term()],
in_flight :: term() | undefined,
worker :: pid() | undefined
}).
%% Chunked-transfer driving state — `undefined' in `#state.chunk' for a
%% single-block transfer (nothing to drive between chunks) and for a
%% chunked one until its content stream is open.
%%
%% put: `manifest' is known upfront (pure, `macula_manifest:create/1',
%% no network) and so are all `lanes' — every chunk is assigned to one
%% immediately (see `content_link_chunked/4'). `lanes' shrinking to "all
%% empty and idle" (`lane_done/1') triggers the final put_manifest step.
%%
%% get: `manifest' and `lanes' both start `undefined' — fetching the
%% manifest (on the one stream the connect step already opened) IS the
%% first step; only once it's back, and `chunk_count' with it, can
%% `lanes' be set up and chunks distributed. `acc' accumulates fetched
%% chunks keyed by index (not arrival order — different lanes finish in
%% whatever order their own network calls happen to complete in), so
%% reassembly reads it back out in the correct 0..chunk_count-1 order
%% regardless of which lane delivered which chunk first.
-record(chunk, {
manifest :: map() | undefined,
lanes :: [#lane{}] | undefined,
chunk_count :: non_neg_integer() | undefined,
acc :: #{non_neg_integer() => binary()}
}).
-record(state, {
kind :: kind(),
payload :: binary(), % put: Bytes; get: Mcid
share_id :: binary(),
link_pid :: pid() | undefined,
stream :: reference() | undefined,
worker :: pid() | undefined,
result :: {ok, term()} | {error, term()} | undefined,
waiters :: [gen_server:from()],
paused :: boolean(),
chunk :: #chunk{} | undefined,
stream_count :: pos_integer()
}).
%%%===================================================================
%%% API
%%%===================================================================
%% @doc Start an addressable put through the pool's own connected
%% link (whichever `macula_client:pick_connected_link/1' picks).
-spec start_put(macula:pool(), binary()) -> {ok, pid()}.
start_put(Pool, Bytes) -> start_put(Pool, Bytes, #{}).
%% @doc As `start_put/2'. `Opts' may carry `share_id' (binary,
%% overrides the minted default) and `stream_count' (positive integer,
%% default 4 — see the moduledoc's "Multi-stream" section; irrelevant
%% for a single-block transfer).
-spec start_put(macula:pool(), binary(), map()) -> {ok, pid()}.
start_put(Pool, Bytes, Opts) when is_pid(Pool), is_binary(Bytes), is_map(Opts) ->
gen_server:start_link(?MODULE, {put, {pooled, Pool}, Bytes, Opts}, []).
%% @doc As `start_put/2', dialing `Station' directly (reusing a live
%% link or dialing + waiting up to `TimeoutMs' for one) instead of
%% picking from the pool's existing links — the addressable
%% counterpart to `macula:put_content_station/4'.
-spec start_put_station(macula:pool(), macula_client:seed(), binary(),
pos_integer()) -> {ok, pid()}.
start_put_station(Pool, Station, Bytes, TimeoutMs) ->
start_put_station(Pool, Station, Bytes, TimeoutMs, #{}).
%% @doc As `start_put_station/4'. `Opts' may carry `share_id',
%% `stream_count' (see `start_put/3') plus a per-call TLS trust
%% override for this dial — `verify', `expected_node_id',
%% `pin_tls_cert' (see `macula:put_content_station/5').
-spec start_put_station(macula:pool(), macula_client:seed(), binary(),
pos_integer(), map()) -> {ok, pid()}.
start_put_station(Pool, Station, Bytes, TimeoutMs, Opts)
when is_pid(Pool), is_binary(Bytes), is_integer(TimeoutMs), TimeoutMs > 0,
is_map(Opts) ->
LinkOpts = maps:with([verify, expected_node_id, pin_tls_cert], Opts),
gen_server:start_link(?MODULE,
{put, {station, Pool, Station, TimeoutMs, LinkOpts}, Bytes, Opts}, []).
%% @doc Start an addressable get through the pool's own connected
%% link. See `macula:get_content/2'.
-spec start_get(macula:pool(), macula:mcid()) -> {ok, pid()}.
start_get(Pool, Mcid) -> start_get(Pool, Mcid, #{}).
%% @doc As `start_get/2'. `Opts' may carry `share_id' and
%% `stream_count' (see `start_put/3').
-spec start_get(macula:pool(), macula:mcid(), map()) -> {ok, pid()}.
start_get(Pool, Mcid, Opts) when is_pid(Pool), is_binary(Mcid), is_map(Opts) ->
gen_server:start_link(?MODULE, {get, {pooled, Pool}, Mcid, Opts}, []).
%% @doc As `start_get/2', dialing `Station' directly — the addressable
%% counterpart to `macula:get_content_station/4'.
-spec start_get_station(macula:pool(), macula_client:seed(), macula:mcid(),
pos_integer()) -> {ok, pid()}.
start_get_station(Pool, Station, Mcid, TimeoutMs) ->
start_get_station(Pool, Station, Mcid, TimeoutMs, #{}).
%% @doc As `start_get_station/4'. `Opts' as `start_put_station/5'.
-spec start_get_station(macula:pool(), macula_client:seed(), macula:mcid(),
pos_integer(), map()) -> {ok, pid()}.
start_get_station(Pool, Station, Mcid, TimeoutMs, Opts)
when is_pid(Pool), is_binary(Mcid), is_integer(TimeoutMs), TimeoutMs > 0,
is_map(Opts) ->
LinkOpts = maps:with([verify, expected_node_id, pin_tls_cert], Opts),
gen_server:start_link(?MODULE,
{get, {station, Pool, Station, TimeoutMs, LinkOpts}, Mcid, Opts}, []).
%% @doc Block for the transfer's outcome: `{ok, Mcid}' (put),
%% `{ok, Bytes}' (get), or `{error, Reason}'. Safe to call more than
%% once, from more than one process, before or after the result is
%% known.
-spec await(pid()) -> {ok, term()} | {error, term()}.
await(Pid) -> await(Pid, infinity).
%% @doc As `await/1' with an explicit timeout on THIS call only — a
%% timeout here does not cancel the transfer itself.
-spec await(pid(), timeout()) -> {ok, term()} | {error, term()}.
await(Pid, Timeout) -> gen_server:call(Pid, await, Timeout).
%% @doc As `cancel/3' with a default code/message.
-spec cancel(pid()) -> ok.
cancel(Pid) -> cancel(Pid, 0, <<"cancelled">>).
%% @doc Cancel `Pid''s transfer and reap the process. Resets every
%% currently-open content stream with `Code' — genuinely peer-visible,
%% see the moduledoc. `Message' is local-only (logged at the link; QUIC
%% RESET_STREAM carries only the numeric code on the wire). If the
%% transfer already resolved (success or failure), this is a pure reap
%% — nothing left to abort. Either way `await/1,2' answers
%% `{error, cancelled}' to anyone still waiting.
-spec cancel(pid(), non_neg_integer(), binary()) -> ok.
cancel(Pid, Code, Message)
when is_integer(Code), Code >= 0, is_binary(Message) ->
gen_server:call(Pid, {cancel, Code, Message}).
%% @doc Pause a chunked transfer between chunks, on every stream — each
%% stream's in-flight chunk (if any) still completes, no stream closes,
%% but none of them starts its next chunk until `resume/1'. A no-op on
%% a single-block transfer or one that has already resolved (nothing to
%% pause either way).
-spec pause(pid()) -> ok.
pause(Pid) -> gen_server:call(Pid, pause).
%% @doc Resume a transfer paused via `pause/1' — every stream continues
%% from exactly its own next un-sent/un-fetched chunk, never from the
%% start. A no-op if not actually paused, not chunked, or already
%% resolved.
-spec resume(pid()) -> ok.
resume(Pid) -> gen_server:call(Pid, resume).
%% @doc This transfer's `share_id', for publishing in a mesh fact or
%% looking itself up later via `macula_content_transfer_registry'.
-spec share_id(pid()) -> binary().
share_id(Pid) -> gen_server:call(Pid, share_id).
%%%===================================================================
%%% gen_server callbacks
%%%===================================================================
%% @private
init({Kind, Dial, Payload, Opts}) ->
process_flag(trap_exit, true),
ShareId = maps:get(share_id, Opts, crypto:strong_rand_bytes(16)),
StreamCount = maps:get(stream_count, Opts, ?DEFAULT_STREAM_COUNT),
ok = macula_content_transfer_registry:register_share(ShareId, self()),
Self = self(),
Worker = spawn_link(fun() -> connect_and_run(Self, Kind, Dial, Payload) end),
{ok, #state{kind = Kind, payload = Payload, share_id = ShareId,
worker = Worker, waiters = [], paused = false, chunk = undefined,
stream_count = StreamCount}}.
%% @private
handle_call(await, From, #state{result = undefined, waiters = Waiters} = State) ->
{noreply, State#state{waiters = [From | Waiters]}};
handle_call(await, _From, #state{result = Result} = State) ->
{reply, Result, State};
handle_call(share_id, _From, #state{share_id = Id} = State) ->
{reply, Id, State};
handle_call(pause, _From, State) ->
{reply, ok, State#state{paused = true}};
handle_call(resume, _From, #state{paused = true, chunk = Chunk, result = undefined} = State)
when Chunk =/= undefined ->
{reply, ok, State#state{paused = false}, {continue, next_step}};
handle_call(resume, _From, State) ->
{reply, ok, State#state{paused = false}};
handle_call({cancel, _Code, _Message}, _From, #state{result = Result} = State)
when Result =/= undefined ->
{stop, normal, ok, State};
handle_call({cancel, Code, Message}, _From,
#state{worker = Worker, link_pid = LinkPid, stream = Stream,
chunk = Chunk, waiters = Waiters} = State) ->
kill_worker(Worker),
kill_lane_workers(Chunk),
abort_all_streams(LinkPid, Stream, Chunk, Code, Message),
[gen_server:reply(From, {error, cancelled}) || From <- Waiters],
{stop, normal, ok, State#state{result = {error, cancelled}, waiters = []}};
handle_call(_Request, _From, State) ->
{reply, {error, unsupported}, State}.
%% @private
handle_cast(_Msg, State) -> {noreply, State}.
%% @private
%% The connect worker's link/stream — for a single-block transfer it
%% keeps running (unchanged since Phase 1: transfers, closes, reports
%% `content_result' itself). For chunked content it stops here and
%% hands off: this process starts driving the chunk-by-chunk loop(s).
handle_info({content_link, LinkPid, Stream}, #state{kind = Kind, payload = Payload} = State) ->
NewState = State#state{link_pid = LinkPid, stream = Stream},
case is_chunked(Kind, Payload) of
true -> content_link_chunked(Kind, LinkPid, Stream, Payload, NewState);
false -> {noreply, NewState}
end;
%% Single-block path only (chunked finalizes via `finalize/2' instead).
handle_info({content_result, Result}, #state{waiters = Waiters} = State) ->
[gen_server:reply(From, Result) || From <- Waiters],
{noreply, State#state{result = Result, waiters = []}};
%% One lane's step outcome — identified by which stream reported it,
%% since (unlike Phase 1-2) more than one step can be in flight at
%% once, one per open stream.
handle_info({lane_step_result, Stream, Outcome}, #state{chunk = #chunk{lanes = Lanes}} = State)
when Lanes =/= undefined ->
lane_result(lists:keyfind(Stream, #lane.stream, Lanes), Outcome, State);
%% A single (non-lane) step's outcome — the get-manifest-fetch
%% bootstrap, or the put-manifest finalize. There is only ever one of
%% these in flight at a time.
handle_info({step_result, Outcome}, #state{worker = Worker} = State)
when is_pid(Worker) ->
step_result(Outcome, State#state{worker = undefined});
handle_info({'EXIT', Worker, Reason}, #state{result = undefined} = State)
when Reason =/= normal ->
exit_from_tracked_worker(is_tracked_worker(Worker, State), Reason, State);
handle_info(_Msg, State) ->
{noreply, State}.
exit_from_tracked_worker(true, Reason, State) ->
{stop, {worker_crashed, Reason}, State};
exit_from_tracked_worker(false, _Reason, State) ->
{noreply, State}.
is_tracked_worker(Worker, #state{worker = Worker}) ->
true;
is_tracked_worker(Worker, #state{chunk = #chunk{lanes = Lanes}}) when Lanes =/= undefined ->
lists:keymember(Worker, #lane.worker, Lanes);
is_tracked_worker(_Worker, _State) ->
false.
%% @private
handle_continue(next_step, #state{paused = true} = State) ->
{noreply, State};
handle_continue(next_step, #state{chunk = #chunk{lanes = undefined}} = State) ->
%% Only a get reaches this: its manifest isn't fetched yet, so
%% there's nothing to distribute across streams yet either.
dispatch_get_manifest_step(State);
handle_continue(next_step, State) ->
dispatch_lanes_or_finish(State).
kill_worker(Worker) when is_pid(Worker) ->
unlink(Worker),
exit(Worker, kill);
kill_worker(undefined) ->
ok.
kill_lane_workers(undefined) -> ok;
kill_lane_workers(#chunk{lanes = undefined}) -> ok;
kill_lane_workers(#chunk{lanes = Lanes}) ->
lists:foreach(fun(#lane{worker = W}) -> kill_worker(W) end, Lanes).
abort_stream_if_open(LinkPid, Stream, Code, Message)
when is_pid(LinkPid), is_reference(Stream) ->
macula_station_link:abort_content_stream(LinkPid, Stream, Code, Message);
abort_stream_if_open(_LinkPid, _Stream, _Code, _Message) ->
ok.
abort_all_streams(LinkPid, PrimalStream, undefined, Code, Message) ->
abort_stream_if_open(LinkPid, PrimalStream, Code, Message);
abort_all_streams(LinkPid, PrimalStream, #chunk{lanes = undefined}, Code, Message) ->
abort_stream_if_open(LinkPid, PrimalStream, Code, Message);
abort_all_streams(LinkPid, _PrimalStream, #chunk{lanes = Lanes}, Code, Message) ->
lists:foreach(fun(#lane{stream = S}) ->
abort_stream_if_open(LinkPid, S, Code, Message)
end, Lanes).
%%%===================================================================
%%% Connect worker — resolve link, open the FIRST content stream. A
%%% single-block transfer keeps running in this same process; a
%%% chunked one hands off to the gen_server as soon as `content_link'
%%% is sent (see `handle_info/2' above) and this function returns
%%% right after — any additional streams a chunked transfer wants are
%%% opened by the gen_server itself (see `content_link_chunked/4' /
%%% `open_extra_streams/2'), not by this worker.
%%%===================================================================
-spec connect_and_run(pid(), kind(), dial(), binary()) -> term().
connect_and_run(Parent, Kind, Dial, Payload) ->
case connect(Dial) of
{ok, LinkPid, Stream} ->
Parent ! {content_link, LinkPid, Stream},
run_if_single_block(is_chunked(Kind, Payload), Parent, Kind, LinkPid, Stream, Payload);
{error, _} = E ->
Parent ! {content_result, E}
end.
run_if_single_block(true, _Parent, _Kind, _LinkPid, _Stream, _Payload) ->
ok;
run_if_single_block(false, Parent, Kind, LinkPid, Stream, Payload) ->
Result = transfer(Kind, LinkPid, Stream, Payload),
catch macula_station_link:close_content_stream(LinkPid, Stream),
Parent ! {content_result, Result}.
connect({pooled, Pool}) ->
open_on_link(macula_client:pick_connected_link(Pool));
connect({station, Pool, Station, TimeoutMs, LinkOpts}) ->
open_on_link(macula_client:ensure_content_link(Pool, Station, LinkOpts, TimeoutMs)).
open_on_link({error, _} = E) -> E;
open_on_link({ok, LinkPid}) ->
stream_opened(macula_station_link:open_content_stream(LinkPid), LinkPid).
stream_opened({ok, Stream}, LinkPid) -> {ok, LinkPid, Stream};
stream_opened({error, _} = E, _LinkPid) -> E.
%% @doc Known upfront, no network needed: put by size against the
%% chunk threshold, get by the MCID's own codec byte.
is_chunked(put, Bytes) -> byte_size(Bytes) > macula_manifest:default_chunk_size();
is_chunked(get, <<1, 16#56, _/binary>>) -> true;
is_chunked(get, <<1, 16#55, _/binary>>) -> false.
transfer(put, LinkPid, Stream, Bytes) -> put_single_block(LinkPid, Stream, Bytes);
transfer(get, LinkPid, Stream, Mcid) -> get_single_block(LinkPid, Stream, Mcid).
%%%===================================================================
%%% Single block — one wire round trip, runs entirely in the connect
%%% worker exactly as Phase 1 shipped it. Untouched by Phases 2-3:
%%% there is no "between chunks" or "another stream" for either to mean
%%% anything here.
%%%===================================================================
put_single_block(LinkPid, Stream, Bytes) ->
Hash = macula_blake3_nif:hash(Bytes),
MCID = <<1, 16#55, Hash/binary>>,
classify_put_content(put_block(LinkPid, Stream, MCID, Bytes), MCID).
put_block(LinkPid, Stream, MCID, Bytes) ->
call_on_stream_with_retry(LinkPid, Stream, ?CONTENT_PUT_BLOCK_PROC,
#{mcid => MCID, payload => Bytes},
?CONTENT_BLOCK_TIMEOUT_MS).
classify_put_content({ok, ok}, MCID) -> {ok, MCID};
classify_put_content({ok, hash_mismatch}, _MCID) -> {error, hash_mismatch};
classify_put_content({ok, Reply}, _MCID) -> {error, {unexpected_reply, Reply}};
classify_put_content({error, _} = E, _MCID) -> E.
get_single_block(LinkPid, Stream, MCID) ->
classify_get_content(get_block(LinkPid, Stream, MCID), MCID).
get_block(LinkPid, Stream, MCID) ->
call_on_stream_with_retry(LinkPid, Stream, ?CONTENT_GET_BLOCK_PROC,
#{mcid => MCID}, ?CONTENT_BLOCK_TIMEOUT_MS).
classify_get_content({ok, not_found}, _MCID) -> {error, not_found};
classify_get_content({ok, Bin}, MCID) when is_binary(Bin) ->
verify_block_hash(MCID, Bin);
classify_get_content({ok, Reply}, _MCID) -> {error, {unexpected_reply, Reply}};
classify_get_content({error, _} = E, _MCID) -> E.
%% The station verified this block's hash at PUT time; a station
%% fetched FROM (especially via `start_get_station/5', deliberately
%% dialing a caller-chosen peer) is not necessarily the one that stored
%% it, so re-verify client-side rather than trusting whoever answered.
%% Chunked content gets the equivalent check from `macula_manifest:
%% verify/2' over the reassembled whole (see `dispatch_terminal_step/1''s
%% get clause below); single-block content had no client-side check at
%% all before this (fixed pre-Phase-1, carried forward here unchanged
%% — see `macula_content_block_hash_tests').
-spec verify_block_hash(macula:mcid(), binary()) ->
{ok, binary()} | {error, hash_mismatch | invalid_mcid}.
verify_block_hash(<<1, 16#55, Hash:32/binary>>, Bin) ->
hash_result(macula_blake3_nif:hash(Bin) =:= Hash, Bin);
verify_block_hash(_MCID, _Bin) ->
{error, invalid_mcid}.
hash_result(true, Bin) -> {ok, Bin};
hash_result(false, _Bin) -> {error, hash_mismatch}.
%%%===================================================================
%%% Chunked — driven step by step by THIS process (the gen_server), not
%%% the connect worker. A get starts on the ONE stream connect already
%%% opened (fetching the manifest — see `dispatch_get_manifest_step/1')
%%% since the chunk count isn't known before that; a put knows its
%%% chunk count upfront and sets up every stream immediately (see
%%% `content_link_chunked/4'). Once streams — "lanes" — exist, each
%%% runs its own independent chunk queue: `dispatch_lanes_or_finish/1'
%%% starts a worker for any idle lane that still has work, one short-
%%% lived worker per single network call, so `cancel/3' can always
%%% kill whichever ones are in flight and `pause'/`resume' (checked
%%% right here, uniformly, whether there's one lane or several) gate
%%% every lane the same way. Once every lane is empty and idle, the one
%%% remaining step — put the manifest, or (pure, no network)
%%% reassemble+verify a get — runs and finalizes the transfer.
%%% PLAN_PUSH_UPLOAD.md Phases 2-3.
%%%===================================================================
content_link_chunked(put, LinkPid, Stream, Bytes, #state{stream_count = DesiredN} = State) ->
{ok, Manifest, Chunks} = macula_manifest:create(Bytes),
Lanes = setup_put_lanes(LinkPid, Stream, DesiredN, Chunks),
Chunk = #chunk{manifest = Manifest, lanes = Lanes, chunk_count = undefined, acc = #{}},
{noreply, State#state{worker = undefined, chunk = Chunk}, {continue, next_step}};
content_link_chunked(get, _LinkPid, _Stream, _Mcid, State) ->
%% Manifest not known yet — fetching it (on the one stream we have
%% so far) IS the first step; lanes get set up once it's back (see
%% `get_manifest_result/2').
Chunk = #chunk{manifest = undefined, lanes = undefined, chunk_count = undefined, acc = #{}},
{noreply, State#state{chunk = Chunk}, {continue, next_step}}.
setup_put_lanes(LinkPid, Stream0, DesiredN, Chunks) ->
N = max(1, min(DesiredN, length(Chunks))),
Streams = [Stream0 | open_extra_streams(LinkPid, N - 1)],
Items = lists:zip(lists:seq(0, length(Chunks) - 1), Chunks),
distribute_lanes(Streams, Items, fun({Index, _Bytes}) -> Index end).
setup_get_lanes(LinkPid, Stream0, DesiredN, ChunkCount) ->
N = max(1, min(DesiredN, ChunkCount)),
Streams = [Stream0 | open_extra_streams(LinkPid, N - 1)],
Items = lists:seq(0, ChunkCount - 1),
distribute_lanes(Streams, Items, fun(Index) -> Index end).
%% Opening an extra stream is a local QUIC operation on an already-live
%% connection (allocate a stream id, no peer round trip) — fast and
%% ordinarily infallible, but if one DOES fail this degrades to fewer
%% streams rather than failing the whole transfer over it.
open_extra_streams(_LinkPid, N) when N =< 0 -> [];
open_extra_streams(LinkPid, N) ->
lists:filtermap(fun(_) ->
case macula_station_link:open_content_stream(LinkPid) of
{ok, S} -> {true, S};
{error, _} -> false
end
end, lists:seq(1, N)).
%% Round-robin `Items' across `Streams' by `KeyFun(Item) rem length(Streams)',
%% each lane's own queue kept in ascending original order.
distribute_lanes(Streams, Items, KeyFun) ->
NumStreams = length(Streams),
Grouped = lists:foldl(fun(Item, Acc) ->
LaneIdx = KeyFun(Item) rem NumStreams,
maps:update_with(LaneIdx, fun(L) -> [Item | L] end, [Item], Acc)
end, #{}, Items),
[#lane{stream = S, remaining = lists:reverse(maps:get(I, Grouped, [])),
in_flight = undefined, worker = undefined}
|| {I, S} <- lists:zip(lists:seq(0, NumStreams - 1), Streams)].
dispatch_get_manifest_step(#state{kind = get, payload = Mcid} = State) ->
start_single_step(State, fun(Self, LinkPid, Stream) ->
step_get_manifest(Self, LinkPid, Stream, Mcid)
end).
%% For every lane with no worker currently in flight and work left,
%% start one step (one network call) on it. Idempotent to call
%% redundantly — a lane already busy, or already empty, is a no-op —
%% which is what lets every lane's own completion just re-trigger this
%% uniformly rather than needing to know about any other lane's state.
dispatch_lanes_or_finish(#state{chunk = #chunk{lanes = Lanes} = Chunk} = State) ->
NewLanes = [maybe_start_lane(State, L) || L <- Lanes],
NewState = State#state{chunk = Chunk#chunk{lanes = NewLanes}},
finish_if_all_lanes_done(NewState).
maybe_start_lane(_State, #lane{worker = W} = Lane) when is_pid(W) ->
Lane;
maybe_start_lane(_State, #lane{remaining = []} = Lane) ->
Lane;
maybe_start_lane(#state{kind = Kind, link_pid = LinkPid, chunk = #chunk{manifest = Manifest}},
#lane{stream = Stream, remaining = [Item | Rest]} = Lane) ->
Self = self(),
Worker = spawn_link(fun() -> run_lane_step(Self, Kind, LinkPid, Stream, Manifest, Item) end),
Lane#lane{remaining = Rest, in_flight = Item, worker = Worker}.
run_lane_step(Self, put, LinkPid, Stream, Manifest, {Index, Bytes}) ->
{ok, ChunkMcid} = macula_manifest:chunk_mcid(Manifest, Index, blake3),
Outcome = put_chunk_outcome(classify_put_content(put_block(LinkPid, Stream, ChunkMcid, Bytes), ChunkMcid)),
Self ! {lane_step_result, Stream, Outcome};
run_lane_step(Self, get, LinkPid, Stream, Manifest, Index) ->
{ok, ChunkMcid} = macula_manifest:chunk_mcid(Manifest, Index, blake3),
Outcome = classify_get_content(get_block(LinkPid, Stream, ChunkMcid), ChunkMcid),
Self ! {lane_step_result, Stream, Outcome}.
put_chunk_outcome({ok, _}) -> ok;
put_chunk_outcome({error, _} = E) -> E.
finish_if_all_lanes_done(#state{chunk = #chunk{lanes = Lanes}} = State) ->
case lists:all(fun lane_done/1, Lanes) of
true -> dispatch_terminal_step(State);
false -> {noreply, State}
end.
lane_done(#lane{remaining = [], in_flight = undefined, worker = undefined}) -> true;
lane_done(_) -> false.
dispatch_terminal_step(#state{kind = put, chunk = #chunk{manifest = Manifest}} = State) ->
start_single_step(State, fun(Self, LinkPid, Stream) ->
step_put_manifest(Self, LinkPid, Stream, Manifest)
end);
dispatch_terminal_step(#state{kind = get,
chunk = #chunk{manifest = Manifest, chunk_count = N, acc = Acc}} = State) ->
Reassembled = iolist_to_binary([maps:get(I, Acc) || I <- lists:seq(0, N - 1)]),
finalize(State, verify_result(macula_manifest:verify(Manifest, Reassembled), Reassembled)).
%% A non-lane step: the get-manifest-fetch bootstrap, or the
%% put-manifest finalize — both use the transfer's own primal stream
%% directly (for a chunked put that's also lane 0's stream; for a get
%% it's the stream the connect step opened, before any lanes exist).
start_single_step(#state{link_pid = LinkPid, stream = Stream} = State, StepFun) ->
Self = self(),
Worker = spawn_link(fun() -> StepFun(Self, LinkPid, Stream) end),
{noreply, State#state{worker = Worker}}.
step_put_manifest(Self, LinkPid, Stream, #{mcid := MCID} = Manifest) ->
Outcome = classify_put_manifest(
call_on_stream_with_retry(LinkPid, Stream, ?CONTENT_PUT_MANIFEST_PROC,
#{manifest => Manifest}, ?CONTENT_MANIFEST_TIMEOUT_MS),
MCID),
Self ! {step_result, Outcome}.
classify_put_manifest({ok, ok}, MCID) -> {ok, MCID};
classify_put_manifest({ok, Reply}, _MCID) -> {error, {unexpected_reply, Reply}};
classify_put_manifest({error, _} = E, _MCID) -> E.
step_get_manifest(Self, LinkPid, Stream, Mcid) ->
Outcome = classify_get_manifest_step(
call_on_stream_with_retry(LinkPid, Stream, ?CONTENT_GET_MANIFEST_PROC,
#{mcid => Mcid}, ?CONTENT_MANIFEST_TIMEOUT_MS)),
Self ! {step_result, Outcome}.
classify_get_manifest_step({ok, not_found}) -> {error, not_found};
classify_get_manifest_step({ok, Wire}) when is_map(Wire) -> macula_manifest:from_wire(Wire);
classify_get_manifest_step({ok, Reply}) -> {error, {unexpected_reply, Reply}};
classify_get_manifest_step({error, _} = E) -> E.
%% The ONLY non-lane step for a get is the manifest fetch; for a put
%% it's the manifest put, which always finalizes (success or failure)
%% since it's the transfer's last step by construction.
step_result(Outcome, #state{kind = get} = State) ->
get_manifest_result(Outcome, State);
step_result(Outcome, #state{kind = put} = State) ->
finalize(State, Outcome).
get_manifest_result({ok, Manifest}, #state{link_pid = LinkPid, stream = Stream0,
stream_count = DesiredN} = State) ->
#{chunk_count := N} = Manifest,
Lanes = setup_get_lanes(LinkPid, Stream0, DesiredN, N),
NewChunk = #chunk{manifest = Manifest, lanes = Lanes, chunk_count = N, acc = #{}},
{noreply, State#state{chunk = NewChunk}, {continue, next_step}};
get_manifest_result({error, _} = E, State) ->
finalize(State, E).
%% A lane's step outcome. `false' (no matching lane) is a stray/stale
%% message — lanes live for the whole chunk phase, so this should never
%% happen in practice; treated as a defensive no-op rather than a crash.
lane_result(false, _Outcome, State) ->
{noreply, State};
lane_result(#lane{} = Lane, {error, _} = E, State) ->
fail_chunked(State, Lane, E);
lane_result(#lane{in_flight = Item} = Lane, Outcome, #state{kind = Kind, chunk = Chunk} = State) ->
{NewLane, NewChunk} = apply_lane_success(Kind, Lane, Item, Outcome, Chunk),
FinalChunk = replace_lane(NewChunk, NewLane),
{noreply, State#state{chunk = FinalChunk}, {continue, next_step}}.
apply_lane_success(put, Lane, _Item, ok, Chunk) ->
{Lane#lane{in_flight = undefined, worker = undefined}, Chunk};
apply_lane_success(get, Lane, Index, {ok, Bin}, #chunk{acc = Acc} = Chunk) ->
{Lane#lane{in_flight = undefined, worker = undefined}, Chunk#chunk{acc = Acc#{Index => Bin}}}.
replace_lane(#chunk{lanes = Lanes} = Chunk, #lane{stream = S} = NewLane) ->
Chunk#chunk{lanes = lists:keyreplace(S, #lane.stream, Lanes, NewLane)}.
%% One stream's chunk genuinely failed (not crashed) — the whole
%% transfer fails with it. Every OTHER lane's in-flight worker (if any)
%% is killed; every lane's stream, including the failed one's, is
%% closed by `finalize/2' below exactly as a successful chunked
%% transfer's would be.
fail_chunked(#state{chunk = #chunk{lanes = Lanes}} = State, FailedLane, Error) ->
OtherLanes = [L || L <- Lanes, L#lane.stream =/= FailedLane#lane.stream],
lists:foreach(fun(#lane{worker = W}) -> kill_worker(W) end, OtherLanes),
finalize(State, Error).
%% Chunked terminal outcome — mirrors `content_result''s job for the
%% single-block path, but the close happens here instead of in a
%% worker (a chunked transfer's step workers never held a stream open
%% past their own one call).
finalize(#state{link_pid = LinkPid, stream = Stream, chunk = Chunk, waiters = Waiters} = State, Outcome) ->
close_all_streams(LinkPid, Stream, Chunk),
[gen_server:reply(From, Outcome) || From <- Waiters],
{noreply, State#state{result = Outcome, waiters = [], worker = undefined}}.
close_all_streams(LinkPid, PrimalStream, undefined) ->
close_stream_safely(LinkPid, PrimalStream);
close_all_streams(LinkPid, PrimalStream, #chunk{lanes = undefined}) ->
close_stream_safely(LinkPid, PrimalStream);
close_all_streams(LinkPid, _PrimalStream, #chunk{lanes = Lanes}) ->
lists:foreach(fun(#lane{stream = S}) -> close_stream_safely(LinkPid, S) end, Lanes).
close_stream_safely(LinkPid, Stream) when is_pid(LinkPid), is_reference(Stream) ->
catch macula_station_link:close_content_stream(LinkPid, Stream);
close_stream_safely(_LinkPid, _Stream) ->
ok.
verify_result(ok, Reassembled) -> {ok, Reassembled};
verify_result({error, _} = E, _Bin) -> E.
%%%===================================================================
%%% Retry — a `_content.*' CALL on the transfer's pinned dedicated
%%% stream, retried on a BOLT#4 error whose OWN retry policy says to.
%%% Verbatim port of macula:call_on_stream_with_retry/5,6.
%%%===================================================================
call_on_stream_with_retry(LinkPid, Stream, Procedure, Payload, TimeoutMs) ->
call_on_stream_with_retry(LinkPid, Stream, Procedure, Payload, TimeoutMs, 3).
call_on_stream_with_retry(LinkPid, Stream, Procedure, Payload, TimeoutMs,
AttemptsLeft) ->
retry_stream_result(
macula_station_link:call_on_stream(LinkPid, Stream, ?CONTENT_REALM,
Procedure, Payload, TimeoutMs),
LinkPid, Stream, Procedure, Payload, TimeoutMs, AttemptsLeft).
retry_stream_result({error, {call_error, Code, _Name}} = E, LinkPid, Stream,
Procedure, Payload, TimeoutMs, AttemptsLeft)
when AttemptsLeft > 1 ->
retry_stream_if_retryable(macula_bolt4:is_retryable(Code), E, LinkPid,
Stream, Procedure, Payload, TimeoutMs,
AttemptsLeft);
retry_stream_result(Result, _LinkPid, _Stream, _Procedure, _Payload,
_TimeoutMs, _AttemptsLeft) ->
Result.
retry_stream_if_retryable(true, _E, LinkPid, Stream, Procedure, Payload,
TimeoutMs, AttemptsLeft) ->
timer:sleep(?CONTENT_RETRY_BACKOFF_MS),
call_on_stream_with_retry(LinkPid, Stream, Procedure, Payload, TimeoutMs,
AttemptsLeft - 1);
retry_stream_if_retryable(false, E, _LinkPid, _Stream, _Procedure, _Payload,
_TimeoutMs, _AttemptsLeft) ->
E.