Packages
macula
12.4.0
15.6.0
15.5.0
15.4.0
15.3.0
15.2.0
15.1.0
15.0.0
15.0.0-rc.3
15.0.0-rc.2
15.0.0-rc.1
14.11.0
14.10.0
14.9.0
14.8.2
14.8.1
14.8.0
14.7.0
14.6.0
14.5.0
14.4.0
14.3.0
14.2.1
14.2.0
14.1.0
14.0.0
13.6.0
13.5.0
13.4.0
13.3.0
13.2.2
13.2.1
13.2.0
13.1.0
13.0.1
13.0.0
12.12.0
12.11.1
12.11.0
12.10.0
12.9.1
12.9.0
12.8.0
12.7.0
12.6.0
12.5.1
12.5.0
12.4.0
12.3.0
12.2.1
12.2.0
12.1.0
12.0.0
11.5.0
11.4.0
11.3.1
11.3.0
11.2.0
11.1.0
11.0.0
10.25.0
10.24.0
10.23.0
10.22.0
10.21.0
10.20.3
10.20.2
10.20.0
10.19.2
10.19.1
10.19.0
10.18.0
10.17.0
10.16.0
10.15.0
10.14.5
10.14.4
10.14.2
10.14.1
10.14.0
10.13.2
10.13.1
10.11.0
10.10.2
10.10.1
10.10.0
10.9.1
10.9.0
10.8.0
10.7.0
10.5.8
10.5.7
10.5.6
10.5.5
10.5.4
10.5.3
10.5.2
10.5.1
10.5.0
10.4.0
10.2.0
10.1.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
Current section
Files
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's
%%% abort_content_stream) — 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.
%%%
%%% == Link I/O ==
%%%
%%% A transfer picks or dials its link, and opens, calls on, closes and
%%% aborts its content streams, through six functions:
%%% `pick_connected_link/1' and `ensure_station_link/4', the
%%% `macula_client' ones by default, and `open_content_stream/1',
%%% `call_on_stream/6', `close_content_stream/2' and
%%% `abort_content_stream/4', the `macula_station_link' ones. The start
%%% functions take all six in a `link_io' option, to run a transfer on
%%% something else, such as a test's scripted link. A set without one of
%%% them, with a function of another arity or with a key outside the six
%%% is refused with `function_clause', in the caller.
%%%
%%% == Transfer I/O ==
%%%
%%% A feeder or a download starts, awaits and cancels its transfer through
%%% `transfer_io()' functions: `start_put/3', `start_put_station/5',
%%% `start_get/3' and `start_get_station/5', `await/1' and `cancel/1',
%%% this module's own by default. Each takes the ones it calls in a
%%% `transfer_io' start option, checked by `transfer_io/2'.
%%%
%%% == 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).
-include("macula_quic_error_codes.hrl").
-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]).
%% The check of the transfer functions a feeder or a download is given.
-export([transfer_io/2]).
-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()}.
%% The functions a transfer reaches its link with, by key; see "Link I/O"
%% in the module doc.
-type link_io() :: #{pick_connected_link => fun((macula:pool()) -> {ok, pid()} | {error, term()}),
ensure_station_link => fun((macula:pool(), macula_client:seed(), map(),
pos_integer()) -> {ok, pid()} | {error, term()}),
open_content_stream => fun((pid()) -> {ok, reference()} | {error, term()}),
call_on_stream => fun((pid(), reference(), binary(), binary(), term(),
timeout()) -> {ok, term()} | {error, term()}),
close_content_stream => fun((pid(), reference()) -> term()),
abort_content_stream => fun((pid(), reference(), term(), binary()) -> term())}.
%% The functions a feeder or a download starts, awaits and cancels its
%% transfer with, by key; see "Transfer I/O" in the module doc.
-type transfer_io() :: #{start_put => fun((macula:pool(), binary(), map()) -> {ok, pid()}),
start_put_station => fun((macula:pool(), macula_client:seed(), binary(),
pos_integer(), map()) -> {ok, pid()}),
start_get => fun((macula:pool(), macula:mcid(), map()) -> {ok, pid()}),
start_get_station => fun((macula:pool(), macula_client:seed(),
macula:mcid(), pos_integer(), map()) ->
{ok, pid()}),
await => fun((pid()) -> {ok, term()} | {error, term()}),
cancel => fun((pid()) -> ok)}.
-export_type([link_io/0, transfer_io/0]).
%% 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_io :: link_io(),
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). `link_io' gives the functions it reaches
%% its link with (see "Link I/O" above).
-spec start_put(macula:pool(), binary(), map()) -> {ok, pid()}.
start_put(Pool, Bytes, Opts) when is_pid(Pool), is_binary(Bytes), is_map(Opts) ->
start(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 the station this dial must
%% prove, `expected_node_id' (see `macula:put_content_station/5').
%% `pin_tls_cert' and `verify' are read by nothing, and `macula' refuses
%% them; see `macula:trust_options_checked/1'.
-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([expected_node_id], Opts),
start(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'). `Mcid' is the SHA-384 id of a
%% single block or of a manifest; an id of any other shape is refused with
%% function_clause, in the caller, and no transfer starts.
-spec start_get(macula:pool(), macula:mcid(), map()) -> {ok, pid()}.
start_get(Pool, <<2, Codec, _:48/binary>> = Mcid, Opts)
when is_pid(Pool), (Codec =:= 16#55 orelse Codec =:= 16#56), is_map(Opts) ->
start(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'. An
%% `Mcid' of another shape is refused as by `start_get/3'.
-spec start_get_station(macula:pool(), macula_client:seed(), macula:mcid(),
pos_integer(), map()) -> {ok, pid()}.
start_get_station(Pool, Station, <<2, Codec, _:48/binary>> = Mcid, TimeoutMs, Opts)
when is_pid(Pool), (Codec =:= 16#55 orelse Codec =:= 16#56),
is_integer(TimeoutMs), TimeoutMs > 0, is_map(Opts) ->
LinkOpts = maps:with([expected_node_id], Opts),
start(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 the cancelled code (0,
%% `?QUIC_CODE_CANCELLED') and the message `cancelled'.
-spec cancel(pid()) -> ok.
cancel(Pid) -> cancel(Pid, ?QUIC_CODE_CANCELLED, <<"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).
%% A transfer starts on link functions link_io/2 accepts; any other is
%% refused with function_clause, in the caller.
start(Kind, Dial, Payload, Opts) ->
LinkIo = link_io(default_link_io(), maps:get(link_io, Opts, undefined)),
gen_server:start_link(?MODULE, {Kind, Dial, Payload, LinkIo, Opts}, []).
%% The link functions a transfer runs on: `Defaults', or `Given' when it
%% has every key in `Defaults', each function at the arity its key takes,
%% and no key outside `link_io()'. Any other set is refused with
%% function_clause.
link_io(Defaults, undefined) when is_map(Defaults) ->
Defaults;
link_io(Defaults, Given) when is_map(Defaults), is_map(Given) ->
ok = maps:foreach(fun link_function/2, Given),
ok = lists:foreach(fun(Key) -> given_key(Key, Given) end, maps:keys(Defaults)),
Given.
link_function(pick_connected_link, Fun) when is_function(Fun, 1) -> ok;
link_function(ensure_station_link, Fun) when is_function(Fun, 4) -> ok;
link_function(open_content_stream, Fun) when is_function(Fun, 1) -> ok;
link_function(call_on_stream, Fun) when is_function(Fun, 6) -> ok;
link_function(close_content_stream, Fun) when is_function(Fun, 2) -> ok;
link_function(abort_content_stream, Fun) when is_function(Fun, 4) -> ok.
given_key(Key, Given) when is_map_key(Key, Given) -> ok.
%% @doc The transfer functions a feeder or a download runs on. `Defaults'
%% are the ones it calls, by key, and `Given' the `transfer_io' its caller
%% gave, or `undefined' for none, which gives `Defaults'. A given set has
%% every key in `Defaults', each function at the arity its key takes, and
%% may carry other `transfer_io()' functions; any other is refused with
%% `function_clause', in the calling process.
-spec transfer_io(transfer_io(), transfer_io() | undefined) -> transfer_io().
transfer_io(Defaults, undefined) when is_map(Defaults) ->
Defaults;
transfer_io(Defaults, Given) when is_map(Defaults), is_map(Given) ->
ok = maps:foreach(fun transfer_function/2, Given),
ok = lists:foreach(fun(Key) -> given_key(Key, Given) end, maps:keys(Defaults)),
Given.
transfer_function(start_put, Fun) when is_function(Fun, 3) -> ok;
transfer_function(start_put_station, Fun) when is_function(Fun, 5) -> ok;
transfer_function(start_get, Fun) when is_function(Fun, 3) -> ok;
transfer_function(start_get_station, Fun) when is_function(Fun, 5) -> ok;
transfer_function(await, Fun) when is_function(Fun, 1) -> ok;
transfer_function(cancel, Fun) when is_function(Fun, 1) -> ok.
default_link_io() ->
#{pick_connected_link => fun macula_client:pick_connected_link/1,
ensure_station_link => fun macula_client:ensure_station_link/4,
open_content_stream => fun macula_station_link:open_content_stream/1,
call_on_stream => fun macula_station_link:call_on_stream/6,
close_content_stream => fun macula_station_link:close_content_stream/2,
abort_content_stream => fun macula_station_link:abort_content_stream/4}.
%%%===================================================================
%%% gen_server callbacks
%%%===================================================================
%% @private
init({Kind, Dial, Payload, LinkIo, 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, LinkIo, Kind, Dial, Payload) end),
{ok, #state{kind = Kind, payload = Payload, share_id = ShareId, link_io = LinkIo,
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_io = LinkIo, link_pid = LinkPid, stream = Stream,
chunk = Chunk, waiters = Waiters} = State) ->
kill_worker(Worker),
kill_lane_workers(Chunk),
abort_all_streams(LinkIo, 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(#{abort_content_stream := Abort}, LinkPid, Stream, Code, Message)
when is_pid(LinkPid), is_reference(Stream) ->
Abort(LinkPid, Stream, Code, Message);
abort_stream_if_open(_LinkIo, _LinkPid, _Stream, _Code, _Message) ->
ok.
abort_all_streams(LinkIo, LinkPid, PrimalStream, undefined, Code, Message) ->
abort_stream_if_open(LinkIo, LinkPid, PrimalStream, Code, Message);
abort_all_streams(LinkIo, LinkPid, PrimalStream, #chunk{lanes = undefined}, Code, Message) ->
abort_stream_if_open(LinkIo, LinkPid, PrimalStream, Code, Message);
abort_all_streams(LinkIo, LinkPid, _PrimalStream, #chunk{lanes = Lanes}, Code, Message) ->
lists:foreach(fun(#lane{stream = S}) ->
abort_stream_if_open(LinkIo, 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(), link_io(), kind(), dial(), binary()) -> term().
connect_and_run(Parent, LinkIo, Kind, Dial, Payload) ->
case connect(LinkIo, Dial) of
{ok, LinkPid, Stream} ->
Parent ! {content_link, LinkPid, Stream},
run_if_single_block(is_chunked(Kind, Payload), Parent, LinkIo, Kind, LinkPid, Stream,
Payload);
{error, _} = E ->
Parent ! {content_result, E}
end.
run_if_single_block(true, _Parent, _LinkIo, _Kind, _LinkPid, _Stream, _Payload) ->
ok;
run_if_single_block(false, Parent, #{close_content_stream := Close} = LinkIo, Kind, LinkPid, Stream,
Payload) ->
Result = transfer(LinkIo, Kind, LinkPid, Stream, Payload),
try Close(LinkPid, Stream)
catch _:_ -> ok end,
Parent ! {content_result, Result}.
connect(#{pick_connected_link := PickConnectedLink} = LinkIo, {pooled, Pool}) ->
open_on_link(LinkIo, PickConnectedLink(Pool));
connect(#{ensure_station_link := EnsureContentLink} = LinkIo,
{station, Pool, Station, TimeoutMs, LinkOpts}) ->
open_on_link(LinkIo, EnsureContentLink(Pool, Station, LinkOpts, TimeoutMs)).
open_on_link(_LinkIo, {error, _} = E) -> E;
open_on_link(#{open_content_stream := OpenContentStream}, {ok, LinkPid}) ->
stream_opened(OpenContentStream(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, <<2, 16#56, _:48/binary>>) -> true;
is_chunked(get, <<2, 16#55, _:48/binary>>) -> false.
transfer(LinkIo, put, LinkPid, Stream, Bytes) -> put_single_block(LinkIo, LinkPid, Stream, Bytes);
transfer(LinkIo, get, LinkPid, Stream, Mcid) -> get_single_block(LinkIo, 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(LinkIo, LinkPid, Stream, Bytes) ->
MCID = <<2, 16#55, (crypto:hash(sha384, Bytes))/binary>>,
classify_put_content(put_block(LinkIo, LinkPid, Stream, MCID, Bytes), MCID).
put_block(LinkIo, LinkPid, Stream, MCID, Bytes) ->
call_on_stream_with_retry(LinkIo, 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(LinkIo, LinkPid, Stream, MCID) ->
classify_get_content(get_block(LinkIo, LinkPid, Stream, MCID), MCID).
get_block(LinkIo, LinkPid, Stream, MCID) ->
call_on_stream_with_retry(LinkIo, 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(<<2, 16#55, Hash:48/binary>>, Bin) ->
hash_result(crypto:hash(sha384, 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{link_io = LinkIo, stream_count = DesiredN} = State) ->
{ok, Manifest, Chunks} = macula_manifest:create(Bytes),
Lanes = setup_put_lanes(LinkIo, 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(LinkIo, LinkPid, Stream0, DesiredN, Chunks) ->
N = max(1, min(DesiredN, length(Chunks))),
Streams = [Stream0 | open_extra_streams(LinkIo, 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(LinkIo, LinkPid, Stream0, DesiredN, ChunkCount) ->
N = max(1, min(DesiredN, ChunkCount)),
Streams = [Stream0 | open_extra_streams(LinkIo, 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(_LinkIo, _LinkPid, N) when N =< 0 -> [];
open_extra_streams(LinkIo, LinkPid, N) ->
lists:filtermap(fun(_) -> try_open_content_stream(LinkIo, LinkPid) end, lists:seq(1, N)).
try_open_content_stream(#{open_content_stream := OpenContentStream}, LinkPid) ->
case OpenContentStream(LinkPid) of
{ok, S} -> {true, S};
{error, _} -> false
end.
%% 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) -> group_by_lane(Item, KeyFun, NumStreams, 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)].
group_by_lane(Item, KeyFun, NumStreams, Acc) ->
LaneIdx = KeyFun(Item) rem NumStreams,
maps:update_with(LaneIdx, fun(L) -> [Item | L] end, [Item], Acc).
dispatch_get_manifest_step(#state{kind = get, payload = Mcid} = State) ->
start_single_step(State, fun(Self, LinkIo, LinkPid, Stream) ->
step_get_manifest(Self, LinkIo, 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_io = LinkIo, link_pid = LinkPid,
chunk = #chunk{manifest = Manifest}},
#lane{stream = Stream, remaining = [Item | Rest]} = Lane) ->
Self = self(),
Worker = spawn_link(fun() ->
run_lane_step(Self, LinkIo, Kind, LinkPid, Stream, Manifest, Item)
end),
Lane#lane{remaining = Rest, in_flight = Item, worker = Worker}.
run_lane_step(Self, LinkIo, put, LinkPid, Stream, Manifest, {Index, Bytes}) ->
{ok, ChunkMcid} = macula_manifest:chunk_mcid(Manifest, Index),
Put = put_block(LinkIo, LinkPid, Stream, ChunkMcid, Bytes),
Outcome = put_chunk_outcome(classify_put_content(Put, ChunkMcid)),
Self ! {lane_step_result, Stream, Outcome};
run_lane_step(Self, LinkIo, get, LinkPid, Stream, Manifest, Index) ->
{ok, ChunkMcid} = macula_manifest:chunk_mcid(Manifest, Index),
Outcome = classify_get_content(get_block(LinkIo, 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, LinkIo, LinkPid, Stream) ->
step_put_manifest(Self, LinkIo, 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_io = LinkIo, link_pid = LinkPid, stream = Stream} = State,
StepFun) ->
Self = self(),
Worker = spawn_link(fun() -> StepFun(Self, LinkIo, LinkPid, Stream) end),
{noreply, State#state{worker = Worker}}.
step_put_manifest(Self, LinkIo, LinkPid, Stream, #{mcid := MCID} = Manifest) ->
Outcome = classify_put_manifest(
call_on_stream_with_retry(LinkIo, 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, LinkIo, LinkPid, Stream, Mcid) ->
Outcome = bind_manifest(
classify_get_manifest_step(
call_on_stream_with_retry(LinkIo, LinkPid, Stream, ?CONTENT_GET_MANIFEST_PROC,
#{mcid => Mcid}, ?CONTENT_MANIFEST_TIMEOUT_MS)),
Mcid),
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 manifest is used only if it describes the content asked for: its
%% MCID, recomputed from its canonical fields, must be `Mcid'. Every chunk
%% is then fetched against this manifest and the whole is checked against
%% its size and root hash, so the bytes returned are the bytes `Mcid' names.
bind_manifest({ok, Manifest}, Mcid) ->
bound_manifest(macula_manifest:verify_mcid(Manifest, Mcid), Manifest);
bind_manifest({error, _} = E, _Mcid) ->
E.
bound_manifest(ok, Manifest) -> {ok, Manifest};
bound_manifest({error, _} = E, _Manifest) -> 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_io = LinkIo, link_pid = LinkPid,
stream = Stream0,
stream_count = DesiredN} = State) ->
#{chunk_count := N} = Manifest,
Lanes = setup_get_lanes(LinkIo, 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_io = LinkIo, link_pid = LinkPid, stream = Stream, chunk = Chunk,
waiters = Waiters} = State, Outcome) ->
close_all_streams(LinkIo, LinkPid, Stream, Chunk),
[gen_server:reply(From, Outcome) || From <- Waiters],
{noreply, State#state{result = Outcome, waiters = [], worker = undefined}}.
close_all_streams(LinkIo, LinkPid, PrimalStream, undefined) ->
close_stream_safely(LinkIo, LinkPid, PrimalStream);
close_all_streams(LinkIo, LinkPid, PrimalStream, #chunk{lanes = undefined}) ->
close_stream_safely(LinkIo, LinkPid, PrimalStream);
close_all_streams(LinkIo, LinkPid, _PrimalStream, #chunk{lanes = Lanes}) ->
lists:foreach(fun(#lane{stream = S}) -> close_stream_safely(LinkIo, LinkPid, S) end, Lanes).
close_stream_safely(#{close_content_stream := Close}, LinkPid, Stream)
when is_pid(LinkPid), is_reference(Stream) ->
try Close(LinkPid, Stream)
catch _:_ -> ok end;
close_stream_safely(_LinkIo, _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.
%%% A port of macula:call_on_stream_with_retry/5,6, on the transfer's own
%%% call_on_stream function.
%%%===================================================================
call_on_stream_with_retry(#{call_on_stream := CallOnStream}, LinkPid, Stream, Procedure, Payload,
TimeoutMs) ->
Call = fun() ->
CallOnStream(LinkPid, Stream, ?CONTENT_REALM, Procedure, Payload, TimeoutMs)
end,
call_with_retry(Call, 3).
call_with_retry(Call, AttemptsLeft) ->
retry_stream_result(Call(), Call, AttemptsLeft).
retry_stream_result({error, {call_error, Code, _Name}} = E, Call, AttemptsLeft)
when AttemptsLeft > 1 ->
retry_stream_if_retryable(macula_bolt4:is_retryable(Code), E, Call, AttemptsLeft);
retry_stream_result(Result, _Call, _AttemptsLeft) ->
Result.
retry_stream_if_retryable(true, _E, Call, AttemptsLeft) ->
timer:sleep(?CONTENT_RETRY_BACKOFF_MS),
call_with_retry(Call, AttemptsLeft - 1);
retry_stream_if_retryable(false, E, _Call, _AttemptsLeft) ->
E.