Skip to content

Commit cdad3ce

Browse files
committed
Finalize fetch relay coverage and error handling
Closes #11
1 parent 9ec1e91 commit cdad3ce

3 files changed

Lines changed: 259 additions & 13 deletions

File tree

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Today `moqx` supports a single client-side path:
4444
- broadcasts, tracks, and frame delivery
4545
- live subscription via SUBSCRIBE with `FilterType::LatestObject`
4646
- raw fetch for retrieving track objects by range (subscriber sessions only)
47+
- on the current moqtail relay, standalone fetch succeeds only for objects the relay already has in cache; upstream relay-to-publisher standalone fetch is not implemented yet
4748
- optional helper-layer catalog publication via `MOQX.Helpers.publish_catalog/2`
4849
and `MOQX.Helpers.update_catalog/2`
4950
- optional helper-layer catalog retrieval via `MOQX.Helpers.fetch_catalog/2`
@@ -315,12 +316,12 @@ mix moqx.inspect https://draft-14.cloudflare.mediaoverquic.com --namespace bbb -
315316
The task will:
316317

317318
1. connect as a subscriber,
318-
2. load catalog via fetch (with live-subscribe fallback when fetch has no objects),
319+
2. load catalog via fetch (with live-subscribe fallback when fetch has no objects or the relay has not cached the track yet),
319320
3. optionally apply a known relay preset (`--preset`) or choose one interactively (`--choose-relay`),
320321
4. try `"catalog"` and then `".catalog"` unless `--catalog-track` is set,
321322
5. optionally skip fetch entirely with `--no-fetch` and go straight to live subscribe,
322323
6. prompt you to choose a track (or use `--track <name>`),
323-
5. subscribe and print live stats each interval:
324+
7. subscribe and print live stats each interval:
324325
- PRFT latency (or `n/a` if unavailable),
325326
- bandwidth (`B/s` and `kbps`),
326327
- groups/sec,
@@ -364,6 +365,14 @@ Fetch retrieves raw track objects by range from a subscriber session.
364365
`fetch/4` returns `{:ok, ref}` immediately, then delivers messages to the
365366
caller's mailbox correlated by `ref`.
366367

368+
Important moqtail relay note: the current relay only serves standalone fetches
369+
from its local track cache. In practice that means fetch works end-to-end for
370+
objects the relay has already seen (for example after live delivery to a
371+
subscriber), but it does not yet forward standalone fetch upstream to a
372+
publisher on cache miss. On such a cache miss, `moqx` surfaces the relay reply
373+
as a typed `{:moqx_request_error, %MOQX.RequestError{op: :fetch, ...}}` rather
374+
than hanging silently.
375+
367376
The fetch message contract is:
368377

369378
- `{:moqx_fetch_ok, %MOQX.FetchOk{ref, namespace, track_name}}`

native/moqx_native/src/lib.rs

Lines changed: 124 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,9 @@ impl SessionInner {
374374
track_name: String,
375375
) -> (Option<u64>, bool) {
376376
let mut guard = self.track_lifecycle.lock().unwrap();
377-
let entry = guard.entry(key.to_string()).or_insert_with(TrackLifecycle::pending);
377+
let entry = guard
378+
.entry(key.to_string())
379+
.or_insert_with(TrackLifecycle::pending);
378380
entry.created = true;
379381
entry.closed = false;
380382

@@ -401,7 +403,9 @@ impl SessionInner {
401403

402404
fn activate_track(&self, key: &str, track_alias: u64) {
403405
let mut guard = self.track_lifecycle.lock().unwrap();
404-
let entry = guard.entry(key.to_string()).or_insert_with(TrackLifecycle::pending);
406+
let entry = guard
407+
.entry(key.to_string())
408+
.or_insert_with(TrackLifecycle::pending);
405409
if entry.closed {
406410
return;
407411
}
@@ -435,7 +439,9 @@ impl SessionInner {
435439

436440
fn close_track(&self, key: &str) -> Vec<TrackNotifier> {
437441
let mut guard = self.track_lifecycle.lock().unwrap();
438-
let entry = guard.entry(key.to_string()).or_insert_with(TrackLifecycle::created);
442+
let entry = guard
443+
.entry(key.to_string())
444+
.or_insert_with(TrackLifecycle::created);
439445

440446
if entry.closed {
441447
return Vec::new();
@@ -919,26 +925,128 @@ async fn control_loop(
919925
inner: Arc<SessionInner>,
920926
shutdown_rx: &mut watch::Receiver<bool>,
921927
) {
922-
loop {
928+
let exit_reason = loop {
923929
tokio::select! {
924-
_ = shutdown_rx.changed() => break,
930+
_ = shutdown_rx.changed() => break None,
925931
msg = control_rx.recv() => {
926932
match msg {
927933
Some(msg) => {
928-
if handler.send(&msg).await.is_err() {
929-
break;
934+
if let Err(err) = handler.send(&msg).await {
935+
break Some(format!("control stream send failed: {err:?}"));
930936
}
931937
}
932-
None => break,
938+
None => break None,
933939
}
934940
}
935941
result = handler.next_message() => {
936942
match result {
937943
Ok(msg) => dispatch_control_response(msg, &inner),
938-
Err(_) => break,
944+
Err(err) => break Some(format!("control stream receive failed: {err:?}")),
939945
}
940946
}
941947
}
948+
};
949+
950+
if let Some(reason) = exit_reason {
951+
fail_pending_control_ops(&inner, &reason);
952+
}
953+
}
954+
955+
fn fail_pending_control_ops(inner: &Arc<SessionInner>, reason: &str) {
956+
let pending_publishes = {
957+
let mut guard = inner.pending_publishes.lock().unwrap();
958+
std::mem::take(&mut *guard)
959+
};
960+
961+
for (_, pending) in pending_publishes {
962+
let mut msg_env = OwnedEnv::new();
963+
let pid = pending.caller_pid;
964+
pending.ref_env.run(|ref_env| {
965+
let publish_ref = pending.publish_ref_term.load(ref_env);
966+
let _ = msg_env.send_and_clear(&pid, |env| {
967+
let nil = rustler::types::atom::nil().to_term(env);
968+
let payload = TransportErrorOut {
969+
op: atoms::publish(),
970+
message: reason.to_string(),
971+
kind: Some(atoms::runtime()),
972+
r#ref: publish_ref.in_env(env),
973+
handle: nil,
974+
};
975+
(atoms::moqx_transport_error(), payload).encode(env)
976+
});
977+
});
978+
}
979+
980+
let pending_subscribes = {
981+
let mut guard = inner.pending_subscribes.lock().unwrap();
982+
std::mem::take(&mut *guard)
983+
};
984+
985+
for (_, pending) in pending_subscribes {
986+
let mut msg_env = OwnedEnv::new();
987+
let pid = pending.caller_pid;
988+
pending.term_env.run(|term_env| {
989+
let sub_ref = pending.subscription_ref_term.load(term_env);
990+
let _ = msg_env.send_and_clear(&pid, |env| {
991+
let nil = rustler::types::atom::nil().to_term(env);
992+
let payload = TransportErrorOut {
993+
op: atoms::subscribe(),
994+
message: reason.to_string(),
995+
kind: Some(atoms::runtime()),
996+
r#ref: nil,
997+
handle: sub_ref.in_env(env),
998+
};
999+
(atoms::moqx_transport_error(), payload).encode(env)
1000+
});
1001+
});
1002+
}
1003+
1004+
let pending_fetches = {
1005+
let mut guard = inner.pending_fetches.lock().unwrap();
1006+
std::mem::take(&mut *guard)
1007+
};
1008+
1009+
for (_, pending) in pending_fetches {
1010+
let mut msg_env = OwnedEnv::new();
1011+
let pid = pending.caller_pid;
1012+
pending.ref_env.run(|ref_env| {
1013+
let fetch_ref = pending.ref_term.load(ref_env);
1014+
let _ = msg_env.send_and_clear(&pid, |env| {
1015+
let nil = rustler::types::atom::nil().to_term(env);
1016+
let payload = TransportErrorOut {
1017+
op: atoms::fetch(),
1018+
message: reason.to_string(),
1019+
kind: Some(atoms::runtime()),
1020+
r#ref: fetch_ref.in_env(env),
1021+
handle: nil,
1022+
};
1023+
(atoms::moqx_transport_error(), payload).encode(env)
1024+
});
1025+
});
1026+
}
1027+
1028+
let active_subscriptions = {
1029+
let mut guard = inner.active_subscriptions.lock().unwrap();
1030+
std::mem::take(&mut *guard)
1031+
};
1032+
1033+
for (_, active) in active_subscriptions {
1034+
let mut msg_env = OwnedEnv::new();
1035+
let pid = active.caller_pid;
1036+
active.ref_env.run(|ref_env| {
1037+
let sub_ref = active.subscription_ref_term.load(ref_env);
1038+
let _ = msg_env.send_and_clear(&pid, |env| {
1039+
let nil = rustler::types::atom::nil().to_term(env);
1040+
let payload = TransportErrorOut {
1041+
op: atoms::subscribe(),
1042+
message: reason.to_string(),
1043+
kind: Some(atoms::runtime()),
1044+
r#ref: nil,
1045+
handle: sub_ref.in_env(env),
1046+
};
1047+
(atoms::moqx_transport_error(), payload).encode(env)
1048+
});
1049+
});
9421050
}
9431051
}
9441052

@@ -1424,8 +1532,13 @@ async fn handle_subgroup_stream(
14241532
// Control and data travel on independent streams. A subgroup stream may arrive
14251533
// just before the control loop processes the matching SubscribeOk(track_alias).
14261534
// Wait briefly for local activation before deciding this alias is unknown.
1427-
let has_subscription =
1428-
wait_for_active_subscription(inner, track_alias, Duration::from_millis(2_000), Duration::from_millis(5)).await;
1535+
let has_subscription = wait_for_active_subscription(
1536+
inner,
1537+
track_alias,
1538+
Duration::from_millis(2_000),
1539+
Duration::from_millis(5),
1540+
)
1541+
.await;
14291542

14301543
if !has_subscription {
14311544
return Ok(());

test/moqx_integration_test.exs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,62 @@ defmodule MOQXIntegrationTest do
125125
end
126126
end
127127

128+
defp await_fetch_ok!(fetch_ref, namespace, track_name, timeout \\ @timeout) do
129+
receive do
130+
{:moqx_fetch_ok,
131+
%MOQX.FetchOk{ref: ^fetch_ref, namespace: ^namespace, track_name: ^track_name}} ->
132+
:ok
133+
134+
{:moqx_request_error, %MOQX.RequestError{op: :fetch, ref: ^fetch_ref, message: reason}} ->
135+
flunk("fetch failed: #{inspect(reason)}")
136+
137+
{:moqx_transport_error, %MOQX.TransportError{op: :fetch, ref: ^fetch_ref, message: reason}} ->
138+
flunk("fetch transport failed: #{inspect(reason)}")
139+
after
140+
timeout -> flunk("fetch ok timeout for #{namespace}/#{track_name}")
141+
end
142+
end
143+
144+
defp await_fetch_object!(fetch_ref, expected_payload, timeout \\ @timeout) do
145+
receive do
146+
{:moqx_fetch_object,
147+
%MOQX.FetchObject{
148+
ref: ^fetch_ref,
149+
group_id: group_id,
150+
object_id: object_id,
151+
payload: payload
152+
}} ->
153+
if payload == expected_payload do
154+
{group_id, object_id, payload}
155+
else
156+
await_fetch_object!(fetch_ref, expected_payload, timeout)
157+
end
158+
159+
{:moqx_request_error, %MOQX.RequestError{op: :fetch, ref: ^fetch_ref, message: reason}} ->
160+
flunk("fetch failed: #{inspect(reason)}")
161+
162+
{:moqx_transport_error, %MOQX.TransportError{op: :fetch, ref: ^fetch_ref, message: reason}} ->
163+
flunk("fetch transport failed: #{inspect(reason)}")
164+
after
165+
timeout -> flunk("fetch object timeout waiting for #{inspect(expected_payload)}")
166+
end
167+
end
168+
169+
defp await_fetch_done!(fetch_ref, timeout \\ @timeout) do
170+
receive do
171+
{:moqx_fetch_done, %MOQX.FetchDone{ref: ^fetch_ref}} ->
172+
:ok
173+
174+
{:moqx_request_error, %MOQX.RequestError{op: :fetch, ref: ^fetch_ref, message: reason}} ->
175+
flunk("fetch failed: #{inspect(reason)}")
176+
177+
{:moqx_transport_error, %MOQX.TransportError{op: :fetch, ref: ^fetch_ref, message: reason}} ->
178+
flunk("fetch transport failed: #{inspect(reason)}")
179+
after
180+
timeout -> flunk("fetch done timeout")
181+
end
182+
end
183+
128184
describe "integration relay: connect" do
129185
@tag :integration
130186
test "subscriber connects and reports draft-14 version" do
@@ -358,6 +414,74 @@ defmodule MOQXIntegrationTest do
358414
end
359415
end
360416

417+
@tag :integration
418+
test "fetch returns cached object bytes end-to-end on the relay" do
419+
publisher = connect_publisher!()
420+
subscriber = connect_subscriber!()
421+
422+
try do
423+
ns = "moqx-e2e-fetch-#{System.system_time(:millisecond)}"
424+
track_name = "catalog"
425+
426+
payload =
427+
~s({"version":1,"supportsDeltaUpdates":false,"tracks":[{"name":"demo","role":"video"}]})
428+
429+
broadcast = publish_and_await_broadcast!(publisher, ns)
430+
{:ok, track} = MOQX.create_track(broadcast, track_name)
431+
432+
handle = subscribe_and_await_handle!(subscriber, ns, track_name)
433+
await_track_active!(track, ns, track_name)
434+
435+
:ok = MOQX.write_frame(track, payload)
436+
437+
{live_group_id, live_payload} = await_matching_payload_frame!(payload)
438+
assert live_group_id == 0
439+
assert live_payload == payload
440+
441+
:ok = MOQX.unsubscribe(handle)
442+
assert_receive {:moqx_publish_done, %MOQX.PublishDone{handle: ^handle}}, @timeout
443+
444+
{:ok, fetch_ref} = MOQX.fetch(subscriber, ns, track_name, start: {0, 0}, end: {0, 1})
445+
446+
:ok = await_fetch_ok!(fetch_ref, ns, track_name)
447+
{group_id, object_id, fetched_payload} = await_fetch_object!(fetch_ref, payload)
448+
:ok = await_fetch_done!(fetch_ref)
449+
450+
assert group_id == 0
451+
assert object_id == 0
452+
assert fetched_payload == payload
453+
454+
:ok = MOQX.finish_track(track)
455+
after
456+
:ok = MOQX.close(subscriber)
457+
:ok = MOQX.close(publisher)
458+
end
459+
end
460+
461+
@tag :integration
462+
test "fetch cache miss returns typed request error on current moqtail relay" do
463+
publisher = connect_publisher!()
464+
subscriber = connect_subscriber!()
465+
466+
try do
467+
ns = "moqx-e2e-fetch-miss-#{System.system_time(:millisecond)}"
468+
track_name = "catalog"
469+
470+
_broadcast = publish_and_await_broadcast!(publisher, ns)
471+
472+
{:ok, fetch_ref} = MOQX.fetch(subscriber, ns, track_name, start: {0, 0}, end: {0, 1})
473+
474+
assert_receive {:moqx_request_error,
475+
%MOQX.RequestError{op: :fetch, ref: ^fetch_ref, message: reason}},
476+
@timeout
477+
478+
assert reason =~ "TrackDoesNotExist"
479+
after
480+
:ok = MOQX.close(subscriber)
481+
:ok = MOQX.close(publisher)
482+
end
483+
end
484+
361485
@tag :integration
362486
test "unsubscribe/1 stops frame delivery and emits moqx_publish_done" do
363487
publisher = connect_publisher!()

0 commit comments

Comments
 (0)